Building Pry: A Native JSON Viewer in TypeScript
Pry is a native JSON viewer built entirely in TypeScript and compiled with Perry. It's not a tech demo — it's a real tool we use every day to inspect API responses, configuration files, and data dumps. This post walks through how it was built, how it compiles, and what the developer experience looks like when your TypeScript compiles to a native app.
What Pry Does
Pry reads a JSON file (or accepts JSON from stdin) and renders it as an interactive, navigable tree in a native window. If you've used macOS's built-in Quick Look for JSON, imagine that — but faster, searchable, and with keyboard-driven navigation.
The feature set:
- Tree view — collapsible nodes for objects and arrays, with depth indicators and expand/collapse all
- Search — full-text search across keys and values with real-time highlighting and match navigation
- Keyboard shortcuts — arrow keys to navigate, enter to expand/collapse, slash to search,
⌘Cto copy - Clipboard — copy any node or subtree as formatted JSON
- Syntax coloring — strings in green, numbers in orange, booleans in purple, null in red
- Status bar — shows total node count, current depth, file size, and parse time
The Source Code
Pry is written in standard TypeScript. There's no special syntax, no macros, no build-time code generation. It uses Perry's UI API, which provides native widgets that compile to platform-specific code.
Here's the entry point (simplified for clarity):
import { App, VStack, TreeView, SearchBar, StatusBar, State }
from "perry/ui";
import { readFile, readStdin } from "perry/fs";
// Read input from file arg or stdin
const input = process.argv[2]
? readFile(process.argv[2])
: readStdin();
const startTime = Date.now();
const data = JSON.parse(input);
const parseMs = Date.now() - startTime;
// Reactive state
const searchQuery = new State("");
const matchCount = new State(0);
// Build the app
const app = new App("Pry", {
width: 800,
height: 600,
minWidth: 400,
minHeight: 300,
});
app.body(() => {
return VStack({ spacing: 0 }, [
SearchBar({
placeholder: "Search keys and values...",
onSearch: (q) => searchQuery.value = q,
}),
TreeView(data, {
collapsible: true,
syntaxHighlight: true,
searchQuery: searchQuery,
onMatchCount: (n) => matchCount.value = n,
copyOnClick: true,
}),
StatusBar([
`${countNodes(data)} nodes`,
`Parsed in ${parseMs}ms`,
`${matchCount.value} matches`,
]),
]);
});
app.registerShortcut("/", () => app.focusSearchBar());
app.registerShortcut("Escape", () => {
searchQuery.value = "";
app.focusTree();
});
app.run();
That's the core of a native application. No framework boilerplate, no build configuration, no platform-specific files. One TypeScript file.
The Helper Functions
Pry also includes a countNodes utility that recursively counts all nodes in the JSON tree, and a formatBytes helper for displaying file sizes. These are standard TypeScript functions — nothing Perry-specific about them. They compile to native code just like everything else.
export function countNodes(data: unknown): number {
if (data === null || typeof data !== "object") {
return 1;
}
if (Array.isArray(data)) {
return 1 + data.reduce((sum, item) => sum + countNodes(item), 0);
}
const values = Object.values(data as Record<string, unknown>);
return 1 + values.reduce((sum, val) => sum + countNodes(val), 0);
}
Compiling Pry
Compiling Pry with Perry is a single command. No Xcode project, no Gradle configuration, no webpack config. Just point Perry at the entry file and specify your target.
macOS (ARM64)
$ perry build pry.ts --target macos-arm64
Parsing pry.ts...
Resolving imports: perry/ui, perry/fs
Compiling (cranelift, arm64)...
Linking with AppKit.framework...
✓ Built executable: pry (48 MB)
$ file pry
pry: Mach-O 64-bit executable arm64
$ otool -L pry | head -5
pry:
/System/Library/Frameworks/AppKit.framework/AppKit
/System/Library/Frameworks/Foundation.framework/Foundation
/usr/lib/libSystem.B.dylib
The binary is 48 MB because it includes the full AppKit UI stack — tree view rendering, search highlighting, syntax coloring, and keyboard handling. For comparison, the same app in Electron would be 200+ MB. A CLI-only Perry app compiles to 2–5 MB.
iOS
$ perry build pry.ts --target ios-arm64
✓ Built executable: pry (52 MB)
The iOS build links against UIKit instead of AppKit. Perry maps the same TreeView API to UITableView with expandable sections, SearchBar to UISearchBar, and touch events replace mouse events. The iOS build can be deployed to physical devices and simulators.
Android
$ perry build pry.ts --target android-arm64
✓ Built: pry.apk
The Android build generates a native library loaded through JNI, packaged into an APK. TreeView maps to a RecyclerView with expandable view holders, SearchBar maps to an EditText with a TextWatcher, and the status bar maps to a TextView at the bottom of the layout.
What Happens Under the Hood
When Perry compiles Pry, it goes through several phases:
- Parse — SWC parses the TypeScript source into an AST. Imports from
perry/uiandperry/fsare resolved to Perry's built-in module implementations. - Type analysis — Perry resolves all types, including the generic
State<string>andState<number>, monomorphizing them into concrete types. - Platform resolution — Based on the target flag, Perry selects the appropriate UI backend. Each
TreeView,SearchBar, andButtoncall is resolved to the platform-specific implementation. - IR generation — Perry generates an intermediate representation that includes native API calls — Objective-C message sends for macOS/iOS, JNI calls for Android, C function calls for GTK4/Win32.
- Code generation — Cranelift compiles the IR to native machine code for the target architecture.
- Linking — The native code is linked against the platform frameworks (AppKit, UIKit, Android NDK, GTK4, or Win32) to produce the final executable.
No Runtime, No Web Views
This is worth emphasizing because it's the core difference between Perry and every other TypeScript-to-native approach. The compiled Pry binary has:
- No JavaScript engine — no V8, no Hermes, no JavaScriptCore
- No web views — no Chromium, no WebKit, no WKWebView
- No bridge layer — no serialized messages between JS and native
- No framework runtime — no React, no Flutter engine, no Dart VM
The binary calls platform APIs directly. On macOS, it calls objc_msgSend to interact with AppKit objects. On Android, it calls JNI functions to create and manipulate Views. It's the same thing a native Swift or Kotlin app would do.
The practical consequence: Pry launches instantly. There's no VM startup, no JIT warm-up, no script parsing. The process starts, the window appears, the JSON is rendered. Memory usage is a fraction of what an Electron equivalent would consume.
Developer Experience
Building Pry felt remarkably similar to building any TypeScript application. The workflow is:
- Write TypeScript in your editor (VS Code, Zed, Neovim, whatever you prefer)
- Run
perry build pry.ts - Execute
./pry test.json - Iterate
No Xcode project to configure. No Android Studio to install. No Gradle build that takes 45 seconds. The Perry compiler itself is fast — parsing and compiling Pry takes a few seconds, and we're actively working on making it faster.
The TypeScript you write is standard TypeScript. Your editor's type checking, autocomplete, and refactoring tools all work. You can extract functions, create modules, use generics — all the TypeScript patterns you already know.
What We Learned
Building Pry taught us a lot about what the Perry UI API needs to support. Some lessons:
- Tree views are complex. Expanding, collapsing, search highlighting, keyboard navigation, and clipboard integration all need to be coordinated. Perry's
TreeViewwidget handles this internally, but we had to ensure the native implementation was consistent across all three platforms. - Keyboard shortcuts need platform conventions. On macOS, it's
⌘Cto copy. On Linux and Android, it'sCtrl+C. Perry's shortcut system abstracts this, but it took careful implementation to get right. - Status bars are surprisingly non-trivial. Each platform has a different convention for where and how to display status information. AppKit uses the window's bottom bar, UIKit uses a toolbar, Android uses a bottom view in the layout. Perry's
StatusBarmaps to each correctly. - Stdin support required platform awareness. On macOS and Linux, reading from stdin is straightforward. On iOS and Android, "stdin" doesn't really exist in the same way, so Pry uses file selection instead on mobile platforms. Perry's
readStdinhandles this transparently.
Performance
Pry handles large JSON files comfortably. In our testing:
- A 1 MB JSON file (10,000+ nodes) parses and renders in under 50 ms
- A 10 MB JSON file renders in under 200 ms
- Search across 10,000 nodes returns results as you type, with no visible lag
- Memory usage stays under 50 MB even for large files
This is the advantage of native compilation. JSON parsing in Perry is compiled to tight native loops with no GC pauses. Tree rendering uses the platform's own virtualized list views (NSOutlineView, UITableView, RecyclerView), which are battle-tested for performance.
Source and Downloads
Pry is open source. You can browse the full source, build it yourself, or just look at the code to understand how a Perry native UI app is structured.
- GitHub repo — full source code and build instructions
- Showcase page — screenshots, feature list, and platform details
If you're building something with Perry, we'd love to hear about it. Open an issue on the Perry repo or start a discussion. We're building Perry in the open and feedback from real users building real apps is invaluable.