Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Introduction

Perry is a native TypeScript compiler that compiles TypeScript source code directly to native executables. No JavaScript runtime, no JIT warmup, no V8 — your TypeScript compiles to a real binary.

// hello.ts
console.log("Hello from Perry!");
$ perry hello.ts -o hello
$ ./hello
Hello from Perry!

Why Perry?

  • Native performance — Compiles to machine code via Cranelift. Integer-heavy code like Fibonacci runs 2x faster than Node.js.
  • Small binaries — A hello world is ~300KB. Perry detects what runtime features you use and only links what’s needed.
  • Native UI — Build desktop and mobile apps with declarative TypeScript that compiles to real AppKit, UIKit, GTK4, Win32, or DOM widgets.
  • 6 platforms — macOS, iOS, Android, Windows, Linux, and Web from the same source code.
  • Familiar ecosystem — Use npm packages like fastify, mysql2, redis, bcrypt, lodash, and more — compiled natively.
  • Zero config — Point Perry at a .ts file and get a binary. No tsconfig.json required.

What Perry Compiles

Perry supports a practical subset of TypeScript:

  • Variables, functions, classes, enums, interfaces
  • Async/await, closures, generators
  • Destructuring, spread, template literals
  • Arrays, Maps, Sets, typed arrays
  • Regular expressions, JSON, Promises
  • Module imports/exports
  • Generic type erasure

See Supported Features for the complete list.

Quick Example: Native App

import { App, Text, Button, VStack, State } from "perry/ui";

const count = State(0);

App("Counter", () =>
  VStack([
    Text(`Count: ${count.get()}`),
    Button("Increment", () => count.set(count.get() + 1)),
  ])
);
$ perry counter.ts -o counter
$ ./counter  # Opens a native macOS/Windows/Linux window

This produces a ~3MB native app with real platform widgets — no Electron, no WebView.

How It Works

TypeScript (.ts)
    ↓ Parse (SWC)
    ↓ Lower to HIR
    ↓ Transform (inline, closure conversion, async)
    ↓ Codegen (Cranelift)
    ↓ Link (system linker)
    ↓
Native Executable

Perry uses SWC for TypeScript parsing and Cranelift for native code generation. Types are erased at compile time (like tsc), and values are represented at runtime using NaN-boxing for efficient 64-bit tagged values.

Next Steps

Installation

Prerequisites

  • Rust toolchain — Perry is built with Cargo. Install via rustup:

    curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
    
  • System linker — Perry uses your system’s C compiler to link:

    • macOS: Xcode Command Line Tools (xcode-select --install)
    • Linux: gcc or clang (apt install build-essential)
    • Windows: MSVC via Visual Studio Build Tools

Install Perry

git clone https://github.com/skelpo/perry.git
cd perry
cargo build --release

The binary is at target/release/perry. Add it to your PATH:

# Add to ~/.zshrc or ~/.bashrc
export PATH="/path/to/perry/target/release:$PATH"

Self-Update

Once installed, Perry can update itself:

perry update

This downloads the latest release and atomically replaces the binary.

Verify Installation

perry doctor

This checks your installation, shows the current version, and reports if an update is available.

perry --version

Platform-Specific Setup

macOS

No additional setup needed. Perry uses the system cc linker and AppKit for UI apps.

For iOS development, install Xcode (not just Command Line Tools) for the iOS SDK and simulator.

Linux

Install GTK4 development libraries for UI apps:

# Ubuntu/Debian
sudo apt install libgtk-4-dev

# Fedora
sudo dnf install gtk4-devel

Windows

Install Visual Studio Build Tools with the “Desktop development with C++” workload.

What’s Next

Hello World

Your First Program

Create a file called hello.ts:

console.log("Hello, Perry!");

Compile and run it:

perry hello.ts -o hello
./hello

Output:

Hello, Perry!

That’s it. Perry compiled your TypeScript to a native executable — no Node.js, no bundler, no runtime.

A Slightly Bigger Example

function fibonacci(n: number): number {
  if (n <= 1) return n;
  return fibonacci(n - 1) + fibonacci(n - 2);
}

const start = Date.now();
const result = fibonacci(40);
const elapsed = Date.now() - start;

console.log(`fibonacci(40) = ${result}`);
console.log(`Completed in ${elapsed}ms`);
perry fib.ts -o fib
./fib

This runs about 2x faster than Node.js because Perry compiles to native machine code with integer specialization.

Using Variables and Functions

const name: string = "World";
const items: number[] = [1, 2, 3, 4, 5];

const doubled = items.map((x) => x * 2);
const sum = doubled.reduce((acc, x) => acc + x, 0);

console.log(`Hello, ${name}!`);
console.log(`Sum of doubled: ${sum}`);

Async Code

async function fetchData(): Promise<string> {
  const response = await fetch("https://httpbin.org/get");
  const data = await response.json();
  return data.origin;
}

const ip = await fetchData();
console.log(`Your IP: ${ip}`);
perry fetch.ts -o fetch
./fetch

Perry compiles async/await to a native async runtime backed by Tokio.

What the Compiler Produces

When you run perry file.ts -o output, Perry:

  1. Parses your TypeScript with SWC
  2. Lowers the AST to an intermediate representation (HIR)
  3. Applies optimizations (inlining, closure conversion, etc.)
  4. Generates native machine code with Cranelift
  5. Links with your system’s C compiler

The result is a standalone executable with no external dependencies.

Binary Size

ProgramBinary Size
Hello world~300KB
CLI with fs/path~3MB
UI app~3MB
Full app with stdlib~48MB

Perry automatically detects which runtime features you use and only links what’s needed.

Next Steps

First Native App

Perry compiles declarative TypeScript UI code to native platform widgets. No Electron, no WebView — real AppKit on macOS, UIKit on iOS, GTK4 on Linux, Win32 on Windows.

A Simple Counter

Create counter.ts:

import { App, Text, Button, VStack, State } from "perry/ui";

const count = State(0);

App("My Counter", () =>
  VStack([
    Text(`Count: ${count.get()}`),
    Button("Increment", () => {
      count.set(count.get() + 1);
    }),
    Button("Reset", () => {
      count.set(0);
    }),
  ])
);

Compile and run:

perry counter.ts -o counter
./counter

A native window opens with a label and two buttons. Clicking “Increment” updates the count in real-time.

How It Works

  • App(title, renderFn) — Creates a native application window. The render function defines the UI.
  • State(initialValue) — Creates reactive state. When you call .set(), the UI re-renders.
  • VStack([...]) — Vertical stack layout (like SwiftUI’s VStack or CSS flexbox column).
  • Text(string) — A text label. Template literals with ${state.get()} update reactively.
  • Button(label, onClick) — A native button with a click handler.

A Todo App

import { App, Text, Button, TextField, VStack, HStack, State, ForEach } from "perry/ui";

const todos = State<string[]>([]);
const input = State("");

App("Todo App", () =>
  VStack([
    HStack([
      TextField(input, "Add a todo..."),
      Button("Add", () => {
        const text = input.get();
        if (text.length > 0) {
          todos.set([...todos.get(), text]);
          input.set("");
        }
      }),
    ]),
    ForEach(todos, (todo, index) =>
      HStack([
        Text(todo),
        Button("Remove", () => {
          const items = todos.get();
          todos.set(items.filter((_, i) => i !== index));
        }),
      ])
    ),
  ])
);

Cross-Platform

The same code runs on all 6 platforms:

# macOS (default)
perry app.ts -o app
./app

# iOS Simulator
perry app.ts -o app --target ios-simulator

# Web (generates HTML)
perry app.ts -o app --target web
open app.html

# Other platforms
perry app.ts -o app --target windows
perry app.ts -o app --target linux
perry app.ts -o app --target android

Each target compiles to the platform’s native widget toolkit. See Platforms for details.

Adding Styling

import { App, Text, Button, VStack, State } from "perry/ui";

const count = State(0);

App("Styled Counter", () => {
  const label = Text(`Count: ${count.get()}`);
  label.setFontSize(24);
  label.setColor("#333333");

  const btn = Button("Increment", () => count.set(count.get() + 1));
  btn.setCornerRadius(8);
  btn.setBackgroundColor("#007AFF");

  const stack = VStack([label, btn]);
  stack.setPadding(20);
  return stack;
});

See Styling for all available style properties.

Next Steps

Project Configuration

Perry projects use standard package.json for configuration. No special config file is required for basic usage, but larger projects benefit from Perry-specific settings.

Basic Setup

perry init my-project
cd my-project

This creates a package.json and a starter src/index.ts.

package.json

{
  "name": "my-project",
  "version": "1.0.0",
  "main": "src/index.ts",
  "perry": {
    "compilePackages": []
  }
}

Perry Configuration

The perry field in package.json controls compiler behavior:

compilePackages

List npm packages to compile natively instead of routing through the JavaScript runtime:

{
  "perry": {
    "compilePackages": ["@noble/curves", "@noble/hashes"]
  }
}

When a package is listed here, Perry:

  1. Resolves the package in node_modules/
  2. Prefers TypeScript source (src/index.ts) over compiled JavaScript (lib/index.js)
  3. Compiles all functions natively through Cranelift
  4. Deduplicates across nested node_modules/ to prevent duplicate linker symbols

This is useful for pure TypeScript/JavaScript packages that don’t rely on Node.js APIs. Packages that use native bindings, eval(), or dynamic require() won’t work.

Using npm Packages

Perry natively supports many popular npm packages without any configuration:

import fastify from "fastify";
import mysql from "mysql2/promise";
import Redis from "ioredis";
import bcrypt from "bcrypt";

These are compiled to native code using Perry’s built-in implementations. See Standard Library for the full list.

For packages not natively supported, use compilePackages for pure TS/JS packages, or the JavaScript runtime fallback for complex packages.

Project Structure

Perry is flexible about project structure. Common patterns:

my-project/
├── package.json
├── src/
│   └── index.ts
└── node_modules/      # Only needed for compilePackages

For UI apps:

my-app/
├── package.json
├── src/
│   ├── index.ts       # Main app entry
│   └── components/    # UI components
└── assets/            # Images, etc.

Compilation

# Compile a file
perry src/index.ts -o build/app

# Compile with a specific target
perry src/index.ts -o build/app --target ios-simulator

# Debug: print intermediate representation
perry src/index.ts --print-hir

See CLI Commands for all options.

Next Steps

Supported TypeScript Features

Perry compiles a practical subset of TypeScript to native code. This page lists what’s supported.

Primitive Types

const n: number = 42;
const s: string = "hello";
const b: boolean = true;
const u: undefined = undefined;
const nl: null = null;

All primitives are represented as 64-bit NaN-boxed values at runtime.

Variables and Constants

let x = 10;
const y = "immutable";
var z = true; // var is supported but let/const preferred

Perry infers types from initializers — let x = 5 is inferred as number without an explicit annotation.

Functions

function add(a: number, b: number): number {
  return a + b;
}

// Optional parameters
function greet(name: string, greeting: string = "Hello"): string {
  return `${greeting}, ${name}!`;
}

// Rest parameters
function sum(...nums: number[]): number {
  return nums.reduce((a, b) => a + b, 0);
}

// Arrow functions
const double = (x: number) => x * 2;

Classes

class Animal {
  name: string;

  constructor(name: string) {
    this.name = name;
  }

  speak(): string {
    return `${this.name} makes a noise`;
  }
}

class Dog extends Animal {
  speak(): string {
    return `${this.name} barks`;
  }
}

// Static methods
class Counter {
  private static instance: Counter;
  private count: number = 0;

  static getInstance(): Counter {
    if (!Counter.instance) {
      Counter.instance = new Counter();
    }
    return Counter.instance;
  }
}

Supported class features:

  • Constructors
  • Instance and static methods
  • Instance and static properties
  • Inheritance (extends)
  • Method overriding
  • instanceof checks (via class ID chain)
  • Singleton patterns (static method return type inference)

Enums

// Numeric enums
enum Direction {
  Up,
  Down,
  Left,
  Right,
}

// String enums
enum Color {
  Red = "RED",
  Green = "GREEN",
  Blue = "BLUE",
}

const dir = Direction.Up;
const color = Color.Red;

Enums are compiled to constants and work across modules.

Interfaces and Type Aliases

interface User {
  name: string;
  age: number;
  email?: string;
}

type Point = { x: number; y: number };
type StringOrNumber = string | number;
type Callback = (value: number) => void;

Interfaces and type aliases are erased at compile time (like tsc). They exist only for documentation and editor tooling.

Arrays

const nums: number[] = [1, 2, 3];

// Array methods
nums.push(4);
nums.pop();
const len = nums.length;
const doubled = nums.map((x) => x * 2);
const filtered = nums.filter((x) => x > 2);
const sum = nums.reduce((acc, x) => acc + x, 0);
const found = nums.find((x) => x === 3);
const idx = nums.indexOf(3);
const joined = nums.join(", ");
const sliced = nums.slice(1, 3);
nums.splice(1, 1);
nums.unshift(0);
const sorted = nums.sort((a, b) => a - b);
const reversed = nums.reverse();
const includes = nums.includes(3);
const every = nums.every((x) => x > 0);
const some = nums.some((x) => x > 2);
nums.forEach((x) => console.log(x));
const flat = [[1, 2], [3]].flat();
const concatted = nums.concat([5, 6]);

// Array.from
const arr = Array.from(someIterable);

// Array.isArray
if (Array.isArray(value)) { /* ... */ }

// for...of iteration
for (const item of nums) {
  console.log(item);
}

Objects

const obj = { name: "Perry", version: 1 };
obj.name = "Perry 2";

// Dynamic property access
const key = "name";
const val = obj[key];

// Object.keys, Object.values, Object.entries
const keys = Object.keys(obj);
const values = Object.values(obj);
const entries = Object.entries(obj);

// Spread
const copy = { ...obj, extra: true };

// delete
delete obj[key];

Destructuring

// Array destructuring
const [a, b, ...rest] = [1, 2, 3, 4, 5];

// Object destructuring
const { name, age, email = "none" } = user;

// Rename
const { name: userName } = user;

// Rest pattern
const { id, ...remaining } = obj;

// Function parameter destructuring
function process({ name, age }: User) {
  console.log(name, age);
}

Template Literals

const name = "world";
const greeting = `Hello, ${name}!`;
const multiline = `
  Line 1
  Line 2
`;
const expr = `Result: ${1 + 2}`;

Spread and Rest

// Array spread
const combined = [...arr1, ...arr2];

// Object spread
const merged = { ...defaults, ...overrides };

// Rest parameters
function log(...args: any[]) { /* ... */ }

Closures

function makeCounter() {
  let count = 0;
  return {
    increment: () => ++count,
    get: () => count,
  };
}

const counter = makeCounter();
counter.increment();
console.log(counter.get()); // 1

Perry performs closure conversion — captured variables are stored in heap-allocated closure objects.

Async/Await

async function fetchUser(id: number): Promise<User> {
  const response = await fetch(`/api/users/${id}`);
  return await response.json();
}

// Top-level await
const data = await fetchUser(1);

Perry compiles async functions to a state machine backed by Tokio’s async runtime.

Promises

const p = new Promise<number>((resolve, reject) => {
  resolve(42);
});

p.then((value) => console.log(value));

// Promise.all
const results = await Promise.all([fetch(url1), fetch(url2)]);

Generators

function* range(start: number, end: number) {
  for (let i = start; i < end; i++) {
    yield i;
  }
}

for (const n of range(0, 10)) {
  console.log(n);
}

Map and Set

const map = new Map<string, number>();
map.set("a", 1);
map.get("a");
map.has("a");
map.delete("a");
map.size;

const set = new Set<number>();
set.add(1);
set.has(1);
set.delete(1);
set.size;

Regular Expressions

const re = /hello\s+(\w+)/;
const match = "hello world".match(re);

if (re.test("hello perry")) {
  console.log("Matched!");
}

const replaced = "hello world".replace(/world/, "perry");

Error Handling

try {
  throw new Error("something went wrong");
} catch (e) {
  console.log(e.message);
} finally {
  console.log("cleanup");
}

JSON

const obj = JSON.parse('{"key": "value"}');
const str = JSON.stringify(obj);
const pretty = JSON.stringify(obj, null, 2);

typeof and instanceof

if (typeof x === "string") {
  console.log(x.length);
}

if (obj instanceof Dog) {
  obj.speak();
}

typeof checks NaN-boxing tags at runtime. instanceof walks the class ID chain.

Modules

// Named exports
export function helper() { /* ... */ }
export const VALUE = 42;

// Default export
export default class MyClass { /* ... */ }

// Import
import MyClass, { helper, VALUE } from "./module";
import * as utils from "./utils";

// Re-exports
export { helper } from "./module";

BigInt

const big = BigInt(9007199254740991);
const result = big + BigInt(1);

// Bitwise operations
const and = big & BigInt(0xFF);
const or = big | BigInt(0xFF);
const xor = big ^ BigInt(0xFF);
const shl = big << BigInt(2);
const shr = big >> BigInt(2);
const not = ~big;

String Methods

const s = "Hello, World!";
s.length;
s.toUpperCase();
s.toLowerCase();
s.trim();
s.split(", ");
s.includes("World");
s.startsWith("Hello");
s.endsWith("!");
s.indexOf("World");
s.slice(0, 5);
s.substring(0, 5);
s.replace("World", "Perry");
s.repeat(3);
s.charAt(0);
s.padStart(20);
s.padEnd(20);

Math

Math.floor(3.7);
Math.ceil(3.2);
Math.round(3.5);
Math.abs(-5);
Math.max(1, 2, 3);
Math.min(1, 2, 3);
Math.sqrt(16);
Math.pow(2, 10);
Math.random();
Math.PI;
Math.E;
Math.log(10);
Math.sin(0);
Math.cos(0);

Date

const now = Date.now();
const d = new Date();
d.getTime();
d.toISOString();

Console

console.log("message");
console.error("error");
console.warn("warning");
console.time("label");
console.timeEnd("label");

JSX/TSX

Perry supports JSX syntax for UI component composition:

// Component functions
function Greeting({ name }: { name: string }) {
  return <Text>{`Hello, ${name}!`}</Text>;
}

// JSX elements
<Button onClick={() => console.log("clicked")}>Click me</Button>

// Fragments
<>
  <Text>Line 1</Text>
  <Text>Line 2</Text>
</>

// Spread props
<Component {...props} extra="value" />

// Conditional rendering
{condition ? <Text>Yes</Text> : <Text>No</Text>}

JSX elements are transformed to function calls via the jsx()/jsxs() runtime.

Next Steps

Type System

Perry erases types at compile time, similar to how tsc removes type annotations when emitting JavaScript. However, Perry also performs type inference to generate efficient native code.

Type Inference

Perry infers types from expressions without requiring annotations:

let x = 5;           // inferred as number
let s = "hello";     // inferred as string
let b = true;        // inferred as boolean
let arr = [1, 2, 3]; // inferred as number[]

Inference works through:

  • Literal values: 5number, "hi"string
  • Binary operations: a + b where both are numbers → number
  • Variable propagation: if x is number, then let y = x is number
  • Method returns: "hello".trim()string, [1,2].lengthnumber
  • Function returns: user-defined function return types are propagated to callers
function double(n: number): number {
  return n * 2;
}
let result = double(5); // inferred as number

Type Annotations

Standard TypeScript annotations work:

let name: string = "Perry";
let count: number = 0;
let items: string[] = [];

function greet(name: string): string {
  return `Hello, ${name}`;
}

interface Config {
  port: number;
  host: string;
}

Utility Types

Common TypeScript utility types are erased at compile time (they don’t affect code generation):

type Partial<T> = { [P in keyof T]?: T[P] };
type Pick<T, K> = { [P in K]: T[P] };
type Record<K, V> = { [P in K]: V };
type Omit<T, K> = Pick<T, Exclude<keyof T, K>>;
type ReturnType<T> = /* ... */;
type Readonly<T> = { readonly [P in keyof T]: T[P] };

These are all recognized and erased — they won’t cause compilation errors.

Generics

Generic type parameters are erased:

function identity<T>(value: T): T {
  return value;
}

class Box<T> {
  value: T;
  constructor(value: T) {
    this.value = value;
  }
}

const box = new Box<number>(42);

At runtime, all values are NaN-boxed — the generic parameter doesn’t affect code generation.

Type Checking with --type-check

For stricter type checking, Perry can integrate with Microsoft’s TypeScript checker:

perry file.ts --type-check

This resolves cross-file types, interfaces, and generics via an IPC protocol. It falls back gracefully if the type checker is not installed.

Without --type-check, Perry relies on its own inference engine, which handles common patterns but doesn’t perform full TypeScript type checking.

Union and Intersection Types

Union types are recognized syntactically but don’t affect code generation:

type StringOrNumber = string | number;

function process(value: StringOrNumber) {
  if (typeof value === "string") {
    console.log(value.toUpperCase());
  } else {
    console.log(value + 1);
  }
}

Use typeof checks for runtime type narrowing.

Type Guards

function isString(value: any): value is string {
  return typeof value === "string";
}

if (isString(x)) {
  console.log(x.toUpperCase());
}

The value is string annotation is erased, but the typeof check works at runtime.

Next Steps

Limitations

Perry compiles a practical subset of TypeScript. This page documents what’s not supported or works differently from Node.js/tsc.

No Runtime Type Checking

Types are erased at compile time. There is no runtime type system — Perry doesn’t generate type guards or runtime type metadata.

// These annotations are erased — no runtime effect
const x: number = someFunction(); // No runtime check that result is actually a number

Use explicit typeof checks where runtime type discrimination is needed.

No eval() or Dynamic Code

Perry compiles to native code ahead of time. Dynamic code execution is not possible:

// Not supported
eval("console.log('hi')");
new Function("return 42");

No Decorators

TypeScript decorators are not currently supported:

// Not supported
@Component
class MyClass {}

No Reflection

There is no Reflect API or runtime type metadata:

// Not supported
Reflect.getMetadata("design:type", target, key);

No Dynamic require()

Only static imports are supported:

// Supported
import { foo } from "./module";

// Not supported
const mod = require("./module");
const mod = await import("./module");

No Prototype Manipulation

Perry compiles classes to fixed structures. Dynamic prototype modification is not supported:

// Not supported
MyClass.prototype.newMethod = function() {};
Object.setPrototypeOf(obj, proto);

No Symbol Type

The Symbol primitive type is not currently supported:

// Not supported
const sym = Symbol("description");

No WeakMap/WeakRef

Weak references are not implemented:

// Not supported
const wm = new WeakMap();
const wr = new WeakRef(obj);

No Proxy

The Proxy object is not supported:

// Not supported
const proxy = new Proxy(target, handler);

Limited Error Types

Error and basic throw/catch work, but custom error subclasses have limited support:

// Works
throw new Error("message");

// Limited
class CustomError extends Error {
  code: number;
  constructor(msg: string, code: number) {
    super(msg);
    this.code = code;
  }
}

Single-Threaded Execution

User code runs on a single thread. Async I/O runs on Tokio worker threads, but there’s no SharedArrayBuffer or true multi-threading for user code.

worker_threads is supported for background tasks, but workers communicate via message passing (not shared memory).

No Computed Property Names

Dynamic property keys in object literals are limited:

// Supported
const key = "name";
obj[key] = "value";

// Not supported
const obj = { [key]: "value" };

npm Package Compatibility

Not all npm packages work with Perry:

  • Natively supported: ~50 popular packages (fastify, mysql2, redis, etc.) — these are compiled natively. See Standard Library.
  • compilePackages: Pure TS/JS packages can be compiled natively via configuration.
  • Not supported: Packages requiring native addons (.node files), eval(), dynamic require(), or Node.js internals.

Workarounds

Dynamic Behavior

For cases where you need dynamic behavior, use the JavaScript runtime fallback:

import { jsEval } from "perry/jsruntime";
// Routes specific code through QuickJS for dynamic evaluation

Type Narrowing

Since there’s no runtime type checking, use explicit checks:

// Instead of relying on type narrowing from generics
if (typeof value === "string") {
  // String path
} else if (typeof value === "number") {
  // Number path
}

Next Steps

UI Overview

Perry’s perry/ui module lets you build native desktop and mobile apps with declarative TypeScript. Your UI code compiles directly to platform-native widgets — AppKit on macOS, UIKit on iOS, GTK4 on Linux, Win32 on Windows, and DOM elements on the web.

Quick Start

import { App, Text, VStack } from "perry/ui";

App("My App", () =>
  VStack([
    Text("Hello from Perry!"),
  ])
);
perry app.ts -o app && ./app

App Lifecycle

Every Perry UI app starts with App():

import { App } from "perry/ui";

App("Window Title", () => {
  // Build and return your widget tree
  return VStack([
    Text("Content here"),
  ]);
});

App(title, renderFn) creates the native application, opens a window, and renders your widget tree. The render function is called initially and re-invoked when reactive state changes.

Lifecycle Hooks

import { App, onActivate, onTerminate } from "perry/ui";

onActivate(() => {
  console.log("App became active");
});

onTerminate(() => {
  console.log("App is closing");
});

App("My App", () => { /* ... */ });

Widget Tree

Perry UIs are built as a tree of widgets:

import { App, Text, Button, VStack, HStack } from "perry/ui";

App("Layout Demo", () =>
  VStack([
    Text("Header"),
    HStack([
      Button("Left", () => console.log("left")),
      Button("Right", () => console.log("right")),
    ]),
    Text("Footer"),
  ])
);

Widgets are created by calling their constructor functions. Layout containers (VStack, HStack, ZStack) accept arrays of child widgets.

Handle-Based Architecture

Under the hood, each widget is a handle — a small integer that references a native platform object. When you call Text("hello"), Perry creates a native NSTextField (macOS), UILabel (iOS), GtkLabel (Linux), or <span> (web) and returns a handle you can use to modify it.

const label = Text("Hello");
label.setFontSize(18);        // Modifies the native widget
label.setColor("#FF0000");     // Through the handle

Imports

All UI functions are imported from perry/ui:

import {
  // App lifecycle
  App, onActivate, onTerminate,

  // Widgets
  Text, Button, TextField, SecureField, Toggle, Slider,
  Image, ProgressView, Picker,

  // Layout
  VStack, HStack, ZStack, ScrollView, Spacer, Divider,
  NavigationStack, LazyVStack, Form, Section,

  // State
  State, ForEach,

  // Dialogs
  openFileDialog, saveFileDialog, alert, Sheet,

  // Menus
  menuBarCreate, menuBarAddMenu, contextMenu,

  // Canvas
  Canvas,

  // Table
  Table,

  // Window
  Window,
} from "perry/ui";

Platform Differences

The same code runs on all platforms, but the look and feel matches each platform’s native style:

FeaturemacOSiOSLinuxWindowsWeb
ButtonsNSButtonUIButtonGtkButtonHWND Button<button>
TextNSTextFieldUILabelGtkLabelStatic HWND<span>
LayoutNSStackViewUIStackViewGtkBoxManual layoutFlexbox
MenusNSMenuGMenuHMENUDOM

Platform-specific behavior is noted on each widget’s documentation page.

Next Steps

Widgets

Perry provides native widgets that map to each platform’s native controls.

Text

Displays read-only text.

import { Text } from "perry/ui";

const label = Text("Hello, World!");
label.setFontSize(18);
label.setColor("#333333");
label.setFontFamily("Menlo");

Methods:

  • setText(text: string) — Update the text content
  • setFontSize(size: number) — Set font size in points
  • setColor(hex: string) — Set text color (hex string)
  • setFontFamily(family: string) — Set font family (e.g., "Menlo" for monospaced)
  • setAccessibilityLabel(label: string) — Set accessibility label

Text widgets inside template literals with State.get() update automatically:

const count = State(0);
Text(`Count: ${count.get()}`); // Updates when count changes

Button

A clickable button.

import { Button } from "perry/ui";

const btn = Button("Click Me", () => {
  console.log("Clicked!");
});
btn.setCornerRadius(8);
btn.setBackgroundColor("#007AFF");

Methods:

  • setOnClick(callback: () => void) — Set click handler
  • setImage(sfSymbolName: string) — Set SF Symbol icon (macOS/iOS)
  • setContentTintColor(hex: string) — Set tint color
  • setImagePosition(position: number) — Set image position relative to text
  • setEnabled(enabled: boolean) — Enable/disable the button
  • setAccessibilityLabel(label: string) — Set accessibility label

TextField

An editable text input.

import { TextField, State } from "perry/ui";

const text = State("");
const field = TextField(text, "Placeholder...");

TextField takes a State for two-way binding — the state updates as the user types, and setting the state updates the field.

Methods:

  • setText(text: string) — Set the text programmatically
  • setPlaceholder(text: string) — Set placeholder text
  • setEnabled(enabled: boolean) — Enable/disable editing

SecureField

A password input field (text is masked).

import { SecureField, State } from "perry/ui";

const password = State("");
SecureField(password, "Enter password...");

Same API as TextField but input is hidden.

Toggle

A boolean on/off switch.

import { Toggle, State } from "perry/ui";

const enabled = State(false);
Toggle("Enable notifications", enabled);

The State is bound two-way — toggling updates the state, and setting the state updates the toggle.

Slider

A numeric slider.

import { Slider, State } from "perry/ui";

const value = State(50);
Slider(value, 0, 100); // state, min, max

Picker

A dropdown selection control.

import { Picker, State } from "perry/ui";

const selected = State(0);
Picker(["Option A", "Option B", "Option C"], selected);

Image

Displays an image.

import { Image } from "perry/ui";

const img = Image("path/to/image.png");
img.setWidth(200);
img.setHeight(150);

On macOS/iOS, you can also use SF Symbol names:

Image("star.fill"); // SF Symbol

ProgressView

An indeterminate or determinate progress indicator.

import { ProgressView } from "perry/ui";

const progress = ProgressView();
// Or with a value (0.0 to 1.0)
const progress = ProgressView(0.5);

Form and Section

Group controls into a form layout.

import { Form, Section, TextField, Toggle, State } from "perry/ui";

const name = State("");
const notifications = State(true);

Form([
  Section("Personal Info", [
    TextField(name, "Name"),
  ]),
  Section("Settings", [
    Toggle("Notifications", notifications),
  ]),
]);

Table

A data table with rows and columns.

import { Table } from "perry/ui";

const table = Table(10, 3, (row, col) => {
  return `Cell ${row},${col}`;
});

table.setColumnHeader(0, "Name");
table.setColumnHeader(1, "Email");
table.setColumnHeader(2, "Role");
table.setColumnWidth(0, 200);
table.setColumnWidth(1, 250);

table.setOnRowSelect((row) => {
  console.log(`Selected row: ${row}`);
});

Methods:

  • setColumnHeader(col: number, title: string) — Set column header text
  • setColumnWidth(col: number, width: number) — Set column width
  • updateRowCount(count: number) — Update the number of rows
  • setOnRowSelect(callback: (row: number) => void) — Row selection handler
  • getSelectedRow() — Get currently selected row index

TextArea

A multi-line text input.

import { TextArea, State } from "perry/ui";

const content = State("");
TextArea(content, "Enter text...");

Methods:

  • setText(text: string) — Set the text programmatically
  • getText() — Get the current text

QRCode

Generates and displays a QR code.

import { QRCode } from "perry/ui";

const qr = QRCode("https://example.com", 200); // data, size
qr.setData("https://other-url.com");            // Update data

Canvas

A drawing surface. See Canvas for the full drawing API.

import { Canvas } from "perry/ui";

const canvas = Canvas(400, 300, (ctx) => {
  ctx.fillRect(10, 10, 100, 100);
  ctx.strokeRect(50, 50, 100, 100);
});

Common Widget Methods

All widgets support these methods:

MethodDescription
setWidth(width)Set width
setHeight(height)Set height
setBackgroundColor(hex)Set background color
setCornerRadius(radius)Set corner radius
setOpacity(alpha)Set opacity (0.0–1.0)
setEnabled(enabled)Enable/disable interaction
setHidden(hidden)Show/hide widget
setTooltip(text)Set tooltip text
setOnClick(callback)Set click handler
setOnHover(callback)Set hover handler
setOnDoubleClick(callback)Set double-click handler

See Styling and Events for complete details.

Next Steps

Layout

Perry provides layout containers that arrange child widgets using the platform’s native layout system.

VStack

Arranges children vertically (top to bottom).

import { VStack, Text, Button } from "perry/ui";

VStack([
  Text("First"),
  Text("Second"),
  Text("Third"),
]);

Methods:

  • setPadding(padding: number) — Set padding around all edges
  • setSpacing(spacing: number) — Set spacing between children

HStack

Arranges children horizontally (left to right).

import { HStack, Text, Button } from "perry/ui";

HStack([
  Button("Cancel", () => {}),
  Spacer(),
  Button("OK", () => {}),
]);

ZStack

Layers children on top of each other (back to front).

import { ZStack, Text, Image } from "perry/ui";

ZStack([
  Image("background.png"),
  Text("Overlay text"),
]);

ScrollView

A scrollable container.

import { ScrollView, VStack, Text } from "perry/ui";

ScrollView(
  VStack(
    Array.from({ length: 100 }, (_, i) => Text(`Row ${i}`))
  )
);

Methods:

  • setRefreshControl(callback: () => void) — Add pull-to-refresh (calls callback on pull)
  • endRefreshing() — Stop the refresh indicator

LazyVStack

A vertically scrolling list that lazily renders items. More efficient than ScrollView + VStack for large lists.

import { LazyVStack, Text } from "perry/ui";

LazyVStack(1000, (index) => {
  return Text(`Row ${index}`);
});

A navigation container that supports push/pop navigation.

import { NavigationStack, Text, Button } from "perry/ui";

NavigationStack([
  Text("Home Screen"),
  Button("Go to Details", () => {
    // Push a new view
  }),
]);

Spacer

A flexible space that expands to fill available room.

import { HStack, Text, Spacer } from "perry/ui";

HStack([
  Text("Left"),
  Spacer(),
  Text("Right"),
]);

Use Spacer() inside HStack or VStack to push widgets apart.

Divider

A visual separator line.

import { VStack, Text, Divider } from "perry/ui";

VStack([
  Text("Section 1"),
  Divider(),
  Text("Section 2"),
]);

Nesting Layouts

Layouts can be nested freely:

import { App, VStack, HStack, Text, Button, Spacer, Divider } from "perry/ui";

App("Layout Example", () =>
  VStack([
    // Header
    HStack([
      Text("My App"),
      Spacer(),
      Button("Settings", () => {}),
    ]),
    Divider(),
    // Content
    VStack([
      Text("Welcome!"),
      HStack([
        Button("Action 1", () => {}),
        Button("Action 2", () => {}),
      ]),
    ]),
    Spacer(),
    // Footer
    Text("v1.0.0"),
  ])
);

Child Management

Containers support dynamic child management:

const stack = VStack([]);
// Add children dynamically
stack.addChild(Text("New child"));
stack.addChildAt(0, Text("Prepended"));
stack.removeChild(someWidget);
stack.reorderChild(widget, 2);
stack.clearChildren();

Methods:

  • addChild(widget) — Append a child widget
  • addChildAt(index, widget) — Insert a child at a specific position
  • removeChild(widget) — Remove a child widget
  • reorderChild(widget, newIndex) — Move a child to a new position
  • clearChildren() — Remove all children

Next Steps

Styling

Perry widgets support native styling properties that map to each platform’s styling system.

Colors

import { Text, Button } from "perry/ui";

const label = Text("Colored text");
label.setColor("#FF0000");              // Text color (hex)
label.setBackgroundColor("#F0F0F0");    // Background color

Colors are specified as hex strings (#RRGGBB).

Fonts

const label = Text("Styled text");
label.setFontSize(24);                // Font size in points
label.setFontFamily("Menlo");         // Font family name

Use "monospaced" for the system monospaced font.

Corner Radius

const btn = Button("Rounded", () => {});
btn.setCornerRadius(12);

Borders

const widget = VStack([]);
widget.setBorderColor("#CCCCCC");
widget.setBorderWidth(1);

Padding and Insets

const stack = VStack([Text("Padded content")]);
stack.setPadding(16);
stack.setEdgeInsets(10, 20, 10, 20); // top, right, bottom, left

Sizing

const widget = VStack([]);
widget.setWidth(300);
widget.setHeight(200);
widget.setFrame(0, 0, 300, 200);  // x, y, width, height

Opacity

const widget = Text("Semi-transparent");
widget.setOpacity(0.5); // 0.0 to 1.0

Background Gradient

const widget = VStack([]);
widget.setBackgroundGradient("#FF0000", "#0000FF"); // Start color, end color

Control Size

const btn = Button("Small", () => {});
btn.setControlSize(0); // 0=mini, 1=small, 2=regular, 3=large

macOS: Maps to NSControl.ControlSize. Other platforms may interpret differently.

Tooltips

const btn = Button("Hover me", () => {});
btn.setTooltip("Click to perform action");

macOS/Windows/Linux: Native tooltips. iOS/Android: No tooltip support. Web: HTML title attribute.

Enabled/Disabled

const btn = Button("Submit", () => {});
btn.setEnabled(false);  // Greys out and disables interaction

Complete Styling Example

import { App, Text, Button, VStack, HStack, State, Spacer } from "perry/ui";

const count = State(0);

App("Styled App", () => {
  const title = Text("Counter");
  title.setFontSize(28);
  title.setColor("#1A1A1A");

  const display = Text(`${count.get()}`);
  display.setFontSize(48);
  display.setFontFamily("monospaced");
  display.setColor("#007AFF");

  const decBtn = Button("-", () => count.set(count.get() - 1));
  decBtn.setCornerRadius(20);
  decBtn.setBackgroundColor("#FF3B30");

  const incBtn = Button("+", () => count.set(count.get() + 1));
  incBtn.setCornerRadius(20);
  incBtn.setBackgroundColor("#34C759");

  const controls = HStack([decBtn, Spacer(), incBtn]);
  controls.setPadding(20);

  const container = VStack([title, display, controls]);
  container.setPadding(40);
  container.setCornerRadius(16);
  container.setBackgroundColor("#FFFFFF");
  container.setBorderColor("#E5E5E5");
  container.setBorderWidth(1);

  return container;
});

Next Steps

State Management

Perry uses reactive state to automatically update the UI when data changes.

Creating State

import { State } from "perry/ui";

const count = State(0);           // number state
const name = State("Perry");      // string state
const items = State<string[]>([]); // array state

State(initialValue) creates a reactive state container.

Reading and Writing

const value = count.get();  // Read current value
count.set(42);              // Set new value → triggers UI update

Every .set() call re-renders the widget tree with the new value.

Reactive Text

Template literals with state.get() update automatically:

import { Text, State } from "perry/ui";

const count = State(0);
Text(`Count: ${count.get()}`);
// The text updates whenever count changes

This works because Perry detects State.get() calls inside template literals and creates reactive bindings.

Two-Way Binding

TextField and other input widgets bind to state bidirectionally:

import { TextField, State } from "perry/ui";

const input = State("");
TextField(input, "Type here...");

// input.get() always reflects what the user typed
// input.set("hello") updates the text field

Controls that support two-way binding:

  • TextField(state, placeholder) — text input
  • SecureField(state, placeholder) — password input
  • Toggle(label, state) — boolean toggle
  • Slider(state, min, max) — numeric slider
  • Picker(options, state) — selection

onChange Callbacks

Listen for state changes:

import { State } from "perry/ui";

const count = State(0);
count.onChange((newValue) => {
  console.log(`Count changed to ${newValue}`);
});

ForEach

Render a list from array state:

import { VStack, Text, ForEach, State } from "perry/ui";

const items = State(["Apple", "Banana", "Cherry"]);

VStack([
  ForEach(items, (item, index) =>
    Text(`${index + 1}. ${item}`)
  ),
]);

ForEach re-renders the list when the state changes:

// Add an item
items.set([...items.get(), "Date"]);

// Remove an item
items.set(items.get().filter((_, i) => i !== 1));

Conditional Rendering

Use state to conditionally show widgets:

import { VStack, Text, Button, State } from "perry/ui";

const showDetails = State(false);

VStack([
  Button("Toggle", () => showDetails.set(!showDetails.get())),
  showDetails.get() ? Text("Details are visible!") : Spacer(),
]);

Multi-State Text

Text can depend on multiple state values:

const firstName = State("John");
const lastName = State("Doe");

Text(`Hello, ${firstName.get()} ${lastName.get()}!`);
// Updates when either firstName or lastName changes

State with Objects and Arrays

const user = State({ name: "Perry", age: 0 });

// Update by replacing the whole object
user.set({ ...user.get(), age: 1 });

const todos = State<{ text: string; done: boolean }[]>([]);

// Add a todo
todos.set([...todos.get(), { text: "New task", done: false }]);

// Toggle a todo
const items = todos.get();
items[0].done = !items[0].done;
todos.set([...items]);

Note: State uses identity comparison. You must create a new array/object reference for changes to be detected. Mutating in-place without calling .set() with a new reference won’t trigger updates.

Complete Example

import { App, Text, Button, TextField, VStack, HStack, State, ForEach, Spacer, Divider } from "perry/ui";

const todos = State<string[]>([]);
const input = State("");

App("Todo App", () =>
  VStack([
    Text("My Todos"),

    HStack([
      TextField(input, "What needs to be done?"),
      Button("Add", () => {
        const text = input.get();
        if (text.length > 0) {
          todos.set([...todos.get(), text]);
          input.set("");
        }
      }),
    ]),

    Divider(),

    ForEach(todos, (todo, index) =>
      HStack([
        Text(todo),
        Spacer(),
        Button("Delete", () => {
          todos.set(todos.get().filter((_, i) => i !== index));
        }),
      ])
    ),

    Spacer(),
    Text(`${todos.get().length} items`),
  ])
);

Next Steps

  • Events — Click, hover, keyboard events
  • Widgets — All available widgets
  • Layout — Layout containers

Events

Perry widgets support native event handlers for user interaction.

onClick

import { Button, Text } from "perry/ui";

Button("Click me", () => {
  console.log("Button clicked!");
});

// Or set it after creation
const label = Text("Clickable text");
label.setOnClick(() => {
  console.log("Text clicked!");
});

onHover

Triggered when the mouse enters or leaves a widget.

import { Button } from "perry/ui";

const btn = Button("Hover me", () => {});
btn.setOnHover((isHovering) => {
  if (isHovering) {
    console.log("Mouse entered");
  } else {
    console.log("Mouse left");
  }
});

Note: Hover events are available on macOS, Windows, Linux, and Web. iOS and Android use touch interactions instead.

onDoubleClick

import { Text } from "perry/ui";

const label = Text("Double-click me");
label.setOnDoubleClick(() => {
  console.log("Double-clicked!");
});

Keyboard Shortcuts

Register global keyboard shortcuts:

import { registerShortcut } from "perry/ui";

// Cmd+N on macOS, Ctrl+N on other platforms
registerShortcut("n", () => {
  console.log("New document");
});

// Cmd+Shift+S
registerShortcut("S", () => {
  console.log("Save as...");
});

Keyboard shortcuts are also supported in menu items:

menuAddItem(menu, "New", () => newDoc(), "n");    // Cmd+N
menuAddItem(menu, "Save As", () => saveAs(), "S"); // Cmd+Shift+S

Clipboard

import { clipboardGet, clipboardSet } from "perry/ui";

// Copy to clipboard
clipboardSet("Hello, clipboard!");

// Read from clipboard
const text = clipboardGet();

Complete Example

import { App, Text, Button, VStack, HStack, State, Spacer, registerShortcut } from "perry/ui";

const lastEvent = State("No events yet");

// Global shortcut
registerShortcut("r", () => {
  lastEvent.set("Keyboard: Cmd+R");
});

App("Events Demo", () =>
  VStack([
    Text(`Last event: ${lastEvent.get()}`),

    Spacer(),

    Button("Click me", () => {
      lastEvent.set("Button clicked");
    }),

    (() => {
      const hoverBtn = Button("Hover me", () => {});
      hoverBtn.setOnHover((h) => {
        lastEvent.set(h ? "Mouse entered" : "Mouse left");
      });
      return hoverBtn;
    })(),

    (() => {
      const dblLabel = Text("Double-click me");
      dblLabel.setOnDoubleClick(() => {
        lastEvent.set("Double-clicked!");
      });
      return dblLabel;
    })(),
  ])
);

Next Steps

Canvas

The Canvas widget provides a 2D drawing surface for custom graphics.

Creating a Canvas

import { Canvas } from "perry/ui";

const canvas = Canvas(400, 300, (ctx) => {
  // Drawing code here
  ctx.fillRect(10, 10, 100, 80);
});

Canvas(width, height, drawCallback) creates a canvas and calls your drawing function.

Drawing Shapes

Rectangles

Canvas(400, 300, (ctx) => {
  // Filled rectangle
  ctx.setFillColor("#FF0000");
  ctx.fillRect(10, 10, 100, 80);

  // Stroked rectangle
  ctx.setStrokeColor("#0000FF");
  ctx.setLineWidth(2);
  ctx.strokeRect(150, 10, 100, 80);
});

Lines

Canvas(400, 300, (ctx) => {
  ctx.setStrokeColor("#000000");
  ctx.setLineWidth(1);
  ctx.beginPath();
  ctx.moveTo(10, 10);
  ctx.lineTo(200, 150);
  ctx.stroke();
});

Circles and Arcs

Canvas(400, 300, (ctx) => {
  ctx.setFillColor("#00FF00");
  ctx.beginPath();
  ctx.arc(200, 150, 50, 0, Math.PI * 2); // x, y, radius, startAngle, endAngle
  ctx.fill();
});

Colors

Canvas(400, 300, (ctx) => {
  ctx.setFillColor("#FF6600");    // Hex color
  ctx.setStrokeColor("#333333");
  ctx.setLineWidth(3);
});

Gradients

Canvas(400, 300, (ctx) => {
  ctx.setGradient("#FF0000", "#0000FF"); // Start color, end color
  ctx.fillRect(0, 0, 400, 300);
});

Text on Canvas

Canvas(400, 300, (ctx) => {
  ctx.setFillColor("#000000");
  ctx.fillText("Hello Canvas!", 50, 50);
});

Platform Notes

PlatformImplementation
macOSCore Graphics (CGContext)
iOSCore Graphics (CGContext)
LinuxCairo
WindowsGDI
AndroidCanvas/Bitmap
WebHTML5 Canvas

Complete Example

import { App, Canvas, VStack } from "perry/ui";

App("Canvas Demo", () =>
  VStack([
    Canvas(400, 300, (ctx) => {
      // Background
      ctx.setFillColor("#1A1A2E");
      ctx.fillRect(0, 0, 400, 300);

      // Sun
      ctx.setFillColor("#FFD700");
      ctx.beginPath();
      ctx.arc(300, 80, 40, 0, Math.PI * 2);
      ctx.fill();

      // Ground
      ctx.setFillColor("#2D5016");
      ctx.fillRect(0, 220, 400, 80);

      // Tree trunk
      ctx.setFillColor("#8B4513");
      ctx.fillRect(80, 150, 20, 70);

      // Tree top
      ctx.setFillColor("#228B22");
      ctx.beginPath();
      ctx.arc(90, 130, 40, 0, Math.PI * 2);
      ctx.fill();
    }),
  ])
);

Next Steps

Menus

Perry supports native menu bars, context menus, and toolbar items across all platforms.

Create a native application menu bar:

import { App, menuBarCreate, menuBarAddMenu, menuAddItem, menuAddSeparator, menuAddSubmenu, menuBarAttach } from "perry/ui";

App("Menu Demo", () => {
  const menuBar = menuBarCreate();

  // File menu
  const fileMenu = menuBarAddMenu(menuBar, "File");
  menuAddItem(fileMenu, "New", () => newDoc(), "n");         // Cmd+N
  menuAddItem(fileMenu, "Open", () => openDoc(), "o");       // Cmd+O
  menuAddSeparator(fileMenu);
  menuAddItem(fileMenu, "Save", () => saveDoc(), "s");       // Cmd+S
  menuAddItem(fileMenu, "Save As...", () => saveAs(), "S");  // Cmd+Shift+S

  // Edit menu
  const editMenu = menuBarAddMenu(menuBar, "Edit");
  menuAddItem(editMenu, "Undo", () => undo(), "z");
  menuAddItem(editMenu, "Redo", () => redo(), "Z");         // Cmd+Shift+Z
  menuAddSeparator(editMenu);
  menuAddItem(editMenu, "Cut", () => cut(), "x");
  menuAddItem(editMenu, "Copy", () => copy(), "c");
  menuAddItem(editMenu, "Paste", () => paste(), "v");

  // Submenu
  const viewMenu = menuBarAddMenu(menuBar, "View");
  const zoomSubmenu = menuAddSubmenu(viewMenu, "Zoom");
  menuAddItem(zoomSubmenu, "Zoom In", () => zoomIn(), "+");
  menuAddItem(zoomSubmenu, "Zoom Out", () => zoomOut(), "-");
  menuAddItem(zoomSubmenu, "Actual Size", () => zoomReset(), "0");

  menuBarAttach(menuBar);

  // ... rest of UI
});
  • menuBarCreate() — Create a new menu bar
  • menuBarAddMenu(menuBar, title) — Add a top-level menu, returns menu handle
  • menuAddItem(menu, label, callback, shortcut?) — Add a menu item with optional keyboard shortcut
  • menuAddSeparator(menu) — Add a separator line
  • menuAddSubmenu(menu, title) — Add a submenu, returns submenu handle
  • menuBarAttach(menuBar) — Attach the menu bar to the application

Keyboard Shortcuts

The 4th argument to menuAddItem is an optional keyboard shortcut:

ShortcutmacOSOther
"n"Cmd+NCtrl+N
"S"Cmd+Shift+SCtrl+Shift+S
"+"Cmd++Ctrl++

Uppercase letters imply Shift.

Context Menus

Right-click menus on widgets:

import { Text, contextMenu } from "perry/ui";

const label = Text("Right-click me");
contextMenu(label, [
  { label: "Copy", action: () => copyText() },
  { label: "Paste", action: () => pasteText() },
  { separator: true },
  { label: "Delete", action: () => deleteItem() },
]);

Toolbar

Add a toolbar to the window:

import { App, toolbarCreate, toolbarAddItem } from "perry/ui";

App("Toolbar Demo", () => {
  const toolbar = toolbarCreate();
  toolbarAddItem(toolbar, "New", () => newDoc());
  toolbarAddItem(toolbar, "Save", () => saveDoc());
  toolbarAddItem(toolbar, "Run", () => runCode());

  // ... rest of UI
});

Platform Notes

PlatformMenu BarContext MenuToolbar
macOSNSMenuNSMenuNSToolbar
iOS— (no menu bar)UIMenuUIToolbar
WindowsHMENU/SetMenuHorizontal layout
LinuxGMenu/set_menubarHeaderBar
WebDOMDOMDOM

iOS: Menu bars are not applicable. Use toolbar and navigation patterns instead.

Next Steps

Dialogs

Perry provides native dialog functions for file selection, alerts, and sheets.

File Open Dialog

import { openFileDialog } from "perry/ui";

const filePath = openFileDialog();
if (filePath) {
  console.log(`Selected: ${filePath}`);
}

Returns the selected file path, or null if cancelled.

Folder Selection Dialog

import { openFolderDialog } from "perry/ui";

const folderPath = openFolderDialog();
if (folderPath) {
  console.log(`Selected folder: ${folderPath}`);
}

Save File Dialog

import { saveFileDialog } from "perry/ui";

const savePath = saveFileDialog();
if (savePath) {
  // Write file to savePath
}

Alert

Display a native alert dialog:

import { alert } from "perry/ui";

alert("Operation Complete", "Your file has been saved successfully.");

alert(title, message) shows a modal alert with an OK button.

Sheets

Sheets are modal panels attached to a window:

import { Sheet, Text, Button, VStack } from "perry/ui";

const sheet = Sheet(() =>
  VStack([
    Text("Sheet Content"),
    Button("Close", () => {
      sheet.dismiss();
    }),
  ])
);

// Show the sheet
sheet.present();

Platform Notes

DialogmacOSiOSWindowsLinuxWeb
File OpenNSOpenPanelUIDocumentPickerIFileOpenDialogGtkFileChooserDialog<input type="file">
File SaveNSSavePanelIFileSaveDialogGtkFileChooserDialogDownload link
FolderNSOpenPanelIFileOpenDialogGtkFileChooserDialog
AlertNSAlertUIAlertControllerMessageBoxWMessageDialogalert()
SheetNSSheetModal VCModal DialogModal WindowModal div

Complete Example

import { App, Text, Button, VStack, HStack, State, openFileDialog, saveFileDialog, alert } from "perry/ui";
import { readFileSync, writeFileSync } from "perry/fs";

const content = State("");
const filePath = State("");

App("Text Editor", () =>
  VStack([
    HStack([
      Button("Open", () => {
        const path = openFileDialog();
        if (path) {
          filePath.set(path);
          content.set(readFileSync(path));
        }
      }),
      Button("Save As", () => {
        const path = saveFileDialog();
        if (path) {
          writeFileSync(path, content.get());
          filePath.set(path);
          alert("Saved", `File saved to ${path}`);
        }
      }),
    ]),
    Text(`File: ${filePath.get() || "No file open"}`),
    TextField(content, "Start typing..."),
  ])
);

Next Steps

Table

The Table widget displays tabular data with columns, headers, and row selection.

Creating a Table

import { Table } from "perry/ui";

const table = Table(10, 3, (row, col) => {
  return `Row ${row}, Col ${col}`;
});

Table(rowCount, colCount, renderCell) creates a table. The render function is called for each cell and should return the text content.

Column Headers

const table = Table(100, 3, (row, col) => {
  const data = [
    ["Alice", "alice@example.com", "Admin"],
    ["Bob", "bob@example.com", "User"],
    // ...
  ];
  return data[row]?.[col] ?? "";
});

table.setColumnHeader(0, "Name");
table.setColumnHeader(1, "Email");
table.setColumnHeader(2, "Role");

Column Widths

table.setColumnWidth(0, 150);  // Name column
table.setColumnWidth(1, 250);  // Email column
table.setColumnWidth(2, 100);  // Role column

Row Selection

table.setOnRowSelect((row) => {
  console.log(`Selected row: ${row}`);
});

// Get the currently selected row
const selected = table.getSelectedRow();

Dynamic Row Count

Update the number of rows after creation:

table.updateRowCount(newCount);

Platform Notes

PlatformImplementation
macOSNSTableView + NSScrollView
WebHTML <table>
iOS/Android/Linux/WindowsStubs (pending native implementation)

Complete Example

import { App, Table, Text, VStack, State } from "perry/ui";

const selectedName = State("None");

const users = [
  { name: "Alice", email: "alice@example.com", role: "Admin" },
  { name: "Bob", email: "bob@example.com", role: "Editor" },
  { name: "Charlie", email: "charlie@example.com", role: "Viewer" },
  { name: "Diana", email: "diana@example.com", role: "Admin" },
  { name: "Eve", email: "eve@example.com", role: "Editor" },
];

App("Table Demo", () =>
  VStack([
    (() => {
      const table = Table(users.length, 3, (row, col) => {
        const user = users[row];
        if (col === 0) return user.name;
        if (col === 1) return user.email;
        return user.role;
      });

      table.setColumnHeader(0, "Name");
      table.setColumnHeader(1, "Email");
      table.setColumnHeader(2, "Role");
      table.setColumnWidth(0, 150);
      table.setColumnWidth(1, 250);
      table.setColumnWidth(2, 100);

      table.setOnRowSelect((row) => {
        selectedName.set(users[row].name);
      });

      return table;
    })(),

    Text(`Selected: ${selectedName.get()}`),
  ])
);

Next Steps

Animation

Perry supports animating widget properties for smooth transitions.

Opacity Animation

import { Text } from "perry/ui";

const label = Text("Fading text");

// Animate opacity from current to target over duration
label.animateOpacity(0.0, 1.0); // targetOpacity, durationSeconds

Position Animation

import { Button } from "perry/ui";

const btn = Button("Moving", () => {});

// Animate position
btn.animatePosition(100, 200, 0.5); // targetX, targetY, durationSeconds

Example: Fade-In Effect

import { App, Text, Button, VStack, State } from "perry/ui";

const visible = State(false);

App("Animation Demo", () =>
  VStack([
    Button("Toggle", () => {
      visible.set(!visible.get());
    }),
    (() => {
      const label = Text("Hello!");
      label.animateOpacity(visible.get() ? 1.0 : 0.0, 0.3);
      return label;
    })(),
  ])
);

Platform Notes

PlatformImplementation
macOSNSAnimationContext / ViewPropertyAnimator
iOSUIView.animate
AndroidViewPropertyAnimator
WindowsWM_TIMER-based animation
LinuxCSS transitions (GTK4)
WebCSS transitions

Next Steps

  • Styling — Widget styling properties
  • Widgets — All available widgets
  • Events — User interaction

Multi-Window

Perry supports creating multiple native windows in a single application.

Creating Windows

import { App, Window, Text, Button, VStack } from "perry/ui";

App("Multi-Window App", () =>
  VStack([
    Text("Main Window"),
    Button("Open New Window", () => {
      Window("Second Window", () =>
        VStack([
          Text("This is a second window"),
          Button("Close", () => {
            // Close this window
          }),
        ])
      );
    }),
  ])
);

Window(title, renderFn) creates a new native window with its own widget tree.

Platform Notes

PlatformImplementation
macOSNSWindow
WindowsCreateWindowEx
LinuxGtkWindow
WebFloating <div>
iOS/AndroidModal view controller / Dialog

On mobile platforms, “windows” are presented as modal views or dialogs since mobile apps typically use a single-window model.

Next Steps

Platform Overview

Perry compiles TypeScript to native executables for 6 platforms from the same source code.

Supported Platforms

PlatformTarget FlagUI ToolkitStatus
macOS(default)AppKitFull support (127/127 FFI functions)
iOS--target ios / --target ios-simulatorUIKitFull support (127/127)
Android--target androidJNI/Android SDKFull support (112/112)
Windows--target windowsWin32Full support (112/112)
Linux--target linuxGTK4Full support (112/112)
Web--target webDOM/CSSFull support (127/127)

Cross-Compilation

# Default: compile for current platform
perry app.ts -o app

# Compile for a specific target
perry app.ts -o app --target ios-simulator
perry app.ts -o app --target web
perry app.ts -o app --target windows
perry app.ts -o app --target linux
perry app.ts -o app --target android

Platform Detection

Use the __platform__ compile-time constant to branch by platform:

declare const __platform__: number;

// Platform constants:
// 0 = macOS
// 1 = iOS
// 2 = Android
// 3 = Windows
// 4 = Linux

if (__platform__ === 0) {
  console.log("Running on macOS");
} else if (__platform__ === 1) {
  console.log("Running on iOS");
} else if (__platform__ === 3) {
  console.log("Running on Windows");
}

__platform__ is resolved at compile time. The compiler constant-folds comparisons and eliminates dead branches, so platform-specific code has zero runtime cost.

Platform Feature Matrix

FeaturemacOSiOSAndroidWindowsLinuxWeb
CLI programsYesYesYes
Native UIYesYesYesYesYesYes
File systemYesSandboxedSandboxedYesYes
NetworkingYesYesYesYesYesFetch
System APIsYesPartialPartialYesYesPartial
Widgets (WidgetKit)Yes

Next Steps

macOS

macOS is Perry’s primary development platform. It uses AppKit for native UI.

Requirements

  • macOS 13+ (Ventura or later)
  • Xcode Command Line Tools: xcode-select --install

Building

# macOS is the default target
perry app.ts -o app
./app

No additional flags needed — macOS is the default compilation target.

UI Toolkit

Perry maps UI widgets to AppKit controls:

Perry WidgetAppKit Class
TextNSTextField (label mode)
ButtonNSButton
TextFieldNSTextField
SecureFieldNSSecureTextField
ToggleNSSwitch
SliderNSSlider
PickerNSPopUpButton
ImageNSImageView
VStack/HStackNSStackView
ScrollViewNSScrollView
TableNSTableView
CanvasNSView + Core Graphics

Code Signing

For distribution, apps need to be signed. Perry supports automatic signing:

perry publish

This auto-detects your signing identity from the macOS Keychain, exports it to a temporary .p12 file, and signs the binary.

For manual signing:

codesign --sign "Developer ID Application: Your Name" ./app

App Store Distribution

perry app.ts -o MyApp
# Sign with App Store certificate
codesign --sign "3rd Party Mac Developer Application: Your Name" MyApp
# Package
productbuild --sign "3rd Party Mac Developer Installer: Your Name" --component MyApp /Applications MyApp.pkg

macOS-Specific Features

  • Menu bar: Full NSMenu support with keyboard shortcuts
  • Toolbar: NSToolbar integration
  • Dock icon: Automatic for GUI apps
  • Dark mode: isDarkMode() detects system appearance
  • Keychain: Secure storage via Security.framework
  • Notifications: Local notifications via UNUserNotificationCenter
  • File dialogs: NSOpenPanel/NSSavePanel

System APIs

import { openURL, isDarkMode, preferencesSet, preferencesGet } from "perry/system";

openURL("https://example.com");          // Opens in default browser
const dark = isDarkMode();                // Check appearance
preferencesSet("key", "value");           // NSUserDefaults
const val = preferencesGet("key");        // NSUserDefaults

Next Steps

iOS

Perry can cross-compile TypeScript apps for iOS devices and the iOS Simulator.

Requirements

  • macOS host (cross-compilation from Linux/Windows is not supported)
  • Xcode (full install, not just Command Line Tools) for iOS SDK and Simulator
  • Rust iOS targets:
    rustup target add aarch64-apple-ios aarch64-apple-ios-sim
    

Building for Simulator

perry app.ts -o app --target ios-simulator

This uses Cranelift cross-compilation with the iOS Simulator SDK. The binary can be run in the Xcode Simulator.

Building for Device

perry app.ts -o app --target ios

This produces an ARM64 binary for physical iOS devices. You’ll need to code sign and package it in an .app bundle for deployment.

UI Toolkit

Perry maps UI widgets to UIKit controls:

Perry WidgetUIKit Class
TextUILabel
ButtonUIButton (TouchUpInside)
TextFieldUITextField
SecureFieldUITextField (secureTextEntry)
ToggleUISwitch
SliderUISlider (Float32, cast at boundary)
PickerUIPickerView
ImageUIImageView
VStack/HStackUIStackView
ScrollViewUIScrollView

App Lifecycle

iOS apps use UIApplicationMain with a deferred creation pattern:

import { App, Text, VStack } from "perry/ui";

App("My iOS App", () =>
  VStack([
    Text("Hello, iPhone!"),
  ])
);

The App() call triggers UIApplicationMain, and your render function is called via PerryAppDelegate once the app is ready.

iOS Widgets (WidgetKit)

Perry can compile TypeScript widget declarations to native SwiftUI WidgetKit extensions:

perry widget.ts --target ios-widget

See Widgets (WidgetKit) for details.

Differences from macOS

  • No menu bar: iOS doesn’t support menu bars. Use toolbar or navigation patterns.
  • Touch events: onHover is not available. Use onClick (mapped to touch).
  • Slider precision: iOS UISlider uses Float32 internally (automatically converted).
  • File dialogs: Limited to UIDocumentPicker.
  • Keyboard shortcuts: Not applicable on iOS.

Next Steps

Android

Perry compiles TypeScript apps for Android using JNI (Java Native Interface).

Requirements

  • Android NDK
  • Android SDK
  • Rust Android targets:
    rustup target add aarch64-linux-android armv7-linux-androideabi
    

Building

perry app.ts -o app --target android

UI Toolkit

Perry maps UI widgets to Android views via JNI:

Perry WidgetAndroid Class
TextTextView
ButtonButton
TextFieldEditText
SecureFieldEditText (ES_PASSWORD)
ToggleSwitch
SliderSeekBar
PickerSpinner + ArrayAdapter
ImageImageView
VStackLinearLayout (vertical)
HStackLinearLayout (horizontal)
ZStackFrameLayout
ScrollViewScrollView
CanvasCanvas + Bitmap
NavigationStackFrameLayout

Android-Specific APIs

  • Dark mode: Configuration.uiMode detection
  • Preferences: SharedPreferences
  • Keychain: Android Keystore
  • Notifications: NotificationManager
  • Open URL: Intent.ACTION_VIEW
  • Alerts: PerryBridge.showAlert
  • Sheets: Dialog (modal)

Differences from Desktop

  • Touch-only: No hover events, no right-click context menus
  • Single window: Multi-window maps to Dialog views
  • Toolbar: Horizontal LinearLayout
  • Font: Typeface-based font family support

Next Steps

Windows

Perry compiles TypeScript apps for Windows using the Win32 API.

Requirements

  • Visual Studio Build Tools with “Desktop development with C++” workload
  • Windows 10 or later

Building

perry app.ts -o app.exe --target windows

UI Toolkit

Perry maps UI widgets to Win32 controls:

Perry WidgetWin32 Class
TextStatic HWND
ButtonHWND Button
TextFieldEdit HWND
SecureFieldEdit (ES_PASSWORD)
ToggleCheckbox
SliderTrackbar (TRACKBAR_CLASSW)
PickerComboBox
ProgressViewPROGRESS_CLASSW
ImageGDI
VStack/HStackManual layout
ScrollViewWS_VSCROLL
CanvasGDI drawing
Form/SectionGroupBox

Windows-Specific APIs

  • Menu bar: HMENU / SetMenu
  • Dark mode: Windows Registry detection
  • Preferences: Windows Registry
  • Keychain: CredWrite/CredRead/CredDelete (Windows Credential Manager)
  • Notifications: Toast notifications
  • File dialogs: IFileOpenDialog / IFileSaveDialog (COM)
  • Alerts: MessageBoxW
  • Open URL: ShellExecuteW

Next Steps

Linux (GTK4)

Perry compiles TypeScript apps for Linux using GTK4.

Requirements

  • GTK4 development libraries:
    # Ubuntu/Debian
    sudo apt install libgtk-4-dev
    
    # Fedora
    sudo dnf install gtk4-devel
    
    # Arch
    sudo pacman -S gtk4
    
  • Cairo development libraries (for canvas):
    sudo apt install libcairo2-dev
    

Building

perry app.ts -o app --target linux
./app

UI Toolkit

Perry maps UI widgets to GTK4 widgets:

Perry WidgetGTK4 Widget
TextGtkLabel
ButtonGtkButton
TextFieldGtkEntry
SecureFieldGtkPasswordEntry
ToggleGtkSwitch
SliderGtkScale
PickerGtkDropDown
ProgressViewGtkProgressBar
ImageGtkImage
VStackGtkBox (vertical)
HStackGtkBox (horizontal)
ZStackGtkOverlay
ScrollViewGtkScrolledWindow
CanvasCairo drawing
NavigationStackGtkStack

Linux-Specific APIs

  • Menu bar: GMenu / set_menubar
  • Toolbar: GtkHeaderBar
  • Dark mode: GTK settings detection
  • Preferences: GSettings or file-based
  • Keychain: libsecret
  • Notifications: GNotification
  • File dialogs: GtkFileChooserDialog
  • Alerts: GtkMessageDialog

Styling

GTK4 styling uses CSS under the hood. Perry’s styling methods (colors, fonts, corner radius) are translated to CSS properties applied via CssProvider.

Next Steps

Web

Perry can compile TypeScript UI apps to self-contained HTML files using --target web.

Building

perry app.ts -o app --target web
open app.html   # Opens in your default browser

The output is a single .html file containing all JavaScript and CSS — no build step, no dependencies.

How It Works

Instead of using Cranelift for native code generation, the --target web flag uses the perry-codegen-js crate to emit JavaScript from HIR. The output is a self-contained HTML file with:

  • Inline JavaScript (your compiled TypeScript)
  • A web runtime that maps perry/ui widgets to DOM elements
  • CSS for layout (flexbox) and styling

The web target skips Cranelift, inlining, generator transforms, and closure conversion — JavaScript engines handle these natively.

UI Mapping

Perry widgets map to HTML elements:

Perry WidgetHTML Element
Text<span>
Button<button>
TextField<input type="text">
SecureField<input type="password">
Toggle<input type="checkbox">
Slider<input type="range">
Picker<select>
ProgressView<progress>
Image<img>
VStack<div> (flexbox column)
HStack<div> (flexbox row)
ZStack<div> (position: relative/absolute)
ScrollView<div> (overflow: auto)
Canvas<canvas> (2D context)
Table<table>

Web-Specific Features

  • Clipboard: navigator.clipboard API
  • Notifications: Web Notification API
  • Dark mode: prefers-color-scheme media query
  • Keychain: localStorage (not truly secure — use for preferences only)
  • Dialogs: <input type="file">, alert(), modal <div>
  • Keyboard shortcuts: DOM keyboard event listeners
  • Multi-window: Floating <div> panels

Limitations

  • No file system access (browser sandbox)
  • No database connections
  • No background processes
  • localStorage instead of secure keychain
  • Single-page — no native app lifecycle

Example

import { App, Text, Button, VStack, State } from "perry/ui";

const count = State(0);

App("Web Counter", () =>
  VStack([
    Text(`Count: ${count.get()}`),
    Button("+1", () => count.set(count.get() + 1)),
  ])
);
perry counter.ts -o counter --target web
# Produces counter.html — open in any browser

Next Steps

Standard Library Overview

Perry natively implements many popular npm packages and Node.js APIs. When you import a supported package, Perry compiles it to native code — no JavaScript runtime involved.

How It Works

import fastify from "fastify";
import mysql from "mysql2/promise";

Perry recognizes these imports at compile time and routes them to native Rust implementations in the perry-stdlib crate. The API surface matches the original npm package, so existing code often works unchanged.

Supported Packages

Networking & HTTP

  • fastify — HTTP server framework
  • axios — HTTP client
  • node-fetch / fetch — HTTP fetch API
  • ws — WebSocket client/server

Databases

  • mysql2 — MySQL client
  • pg — PostgreSQL client
  • better-sqlite3 — SQLite
  • mongodb — MongoDB client
  • ioredis / redis — Redis client

Cryptography

  • bcrypt — Password hashing
  • argon2 — Password hashing (Argon2)
  • jsonwebtoken — JWT signing/verification
  • crypto — Node.js crypto module
  • ethers — Ethereum library

Utilities

  • lodash — Utility functions
  • dayjs / moment — Date manipulation
  • uuid — UUID generation
  • nanoid — ID generation
  • slugify — String slugification
  • validator — String validation

CLI & Data

  • commander — CLI argument parsing
  • decimal.js — Arbitrary precision decimals
  • bignumber.js — Big number math
  • lru-cache — LRU caching

Other

  • sharp — Image processing
  • cheerio — HTML parsing
  • nodemailer — Email sending
  • zlib — Compression
  • cron — Job scheduling
  • worker_threads — Background workers
  • exponential-backoff — Retry logic
  • async_hooks — AsyncLocalStorage

Node.js Built-ins

  • fs — File system
  • path — Path manipulation
  • child_process — Process spawning
  • crypto — Cryptographic functions

Binary Size

Perry automatically detects which stdlib features your code uses:

UsageBinary Size
No stdlib imports~300KB
fs + path only~3MB
Full stdlib~48MB

The compiler links only the required runtime components.

compilePackages

For npm packages not natively supported, you can compile pure TypeScript/JavaScript packages natively:

{
  "perry": {
    "compilePackages": ["@noble/curves", "@noble/hashes"]
  }
}

See Project Configuration for details.

JavaScript Runtime Fallback

For packages that can’t be compiled natively (native addons, dynamic code, etc.), Perry includes a QuickJS-based JavaScript runtime as a fallback:

import { jsEval } from "perry/jsruntime";

Next Steps

File System

Perry implements Node.js file system APIs for reading, writing, and managing files.

Reading Files

import { readFileSync } from "fs";

const content = readFileSync("config.json", "utf-8");
console.log(content);

Binary File Reading

import { readFileBuffer } from "fs";

const buffer = readFileBuffer("image.png");
console.log(`Read ${buffer.length} bytes`);

readFileBuffer reads files as binary data (uses fs::read() internally, not read_to_string()).

Writing Files

import { writeFileSync } from "fs";

writeFileSync("output.txt", "Hello, World!");
writeFileSync("data.json", JSON.stringify({ key: "value" }, null, 2));

File Information

import { existsSync, statSync } from "fs";

if (existsSync("config.json")) {
  const stat = statSync("config.json");
  console.log(`Size: ${stat.size}`);
}

Directory Operations

import { mkdirSync, readdirSync, rmRecursive } from "fs";

// Create directory
mkdirSync("output");

// Read directory contents
const files = readdirSync("src");
for (const file of files) {
  console.log(file);
}

// Remove directory recursively
rmRecursive("output"); // Uses fs::remove_dir_all

Path Utilities

import { join, dirname, basename, resolve } from "path";
import { fileURLToPath } from "url";

const dir = dirname(fileURLToPath(import.meta.url));
const configPath = join(dir, "config.json");
const name = basename(configPath);        // "config.json"
const abs = resolve("relative/path");     // Absolute path

Next Steps

HTTP & Networking

Perry natively implements HTTP servers, clients, and WebSocket support.

Fastify Server

import fastify from "fastify";

const app = fastify();

app.get("/", async (request, reply) => {
  return { hello: "world" };
});

app.get("/users/:id", async (request, reply) => {
  const { id } = request.params;
  return { id, name: "User " + id };
});

app.post("/data", async (request, reply) => {
  const body = request.body;
  reply.code(201);
  return { received: body };
});

app.listen({ port: 3000 }, () => {
  console.log("Server running on port 3000");
});

Perry’s Fastify implementation is API-compatible with the npm package. Routes, request/reply objects, params, query strings, and JSON body parsing all work.

Fetch API

// GET request
const response = await fetch("https://api.example.com/data");
const data = await response.json();

// POST request
const result = await fetch("https://api.example.com/data", {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({ key: "value" }),
});

Axios

import axios from "axios";

const { data } = await axios.get("https://api.example.com/users");

const response = await axios.post("https://api.example.com/users", {
  name: "Perry",
  email: "perry@example.com",
});

WebSocket

import { WebSocket } from "ws";

const ws = new WebSocket("ws://localhost:8080");

ws.on("open", () => {
  ws.send("Hello, server!");
});

ws.on("message", (data) => {
  console.log(`Received: ${data}`);
});

ws.on("close", () => {
  console.log("Connection closed");
});

Next Steps

Databases

Perry natively implements clients for MySQL, PostgreSQL, SQLite, MongoDB, and Redis.

MySQL

import mysql from "mysql2/promise";

const connection = await mysql.createConnection({
  host: "localhost",
  user: "root",
  password: "password",
  database: "mydb",
});

const [rows] = await connection.execute("SELECT * FROM users WHERE id = ?", [1]);
console.log(rows);

await connection.end();

PostgreSQL

import { Client } from "pg";

const client = new Client({
  host: "localhost",
  port: 5432,
  user: "postgres",
  password: "password",
  database: "mydb",
});

await client.connect();
const result = await client.query("SELECT * FROM users WHERE id = $1", [1]);
console.log(result.rows);
await client.end();

SQLite

import Database from "better-sqlite3";

const db = new Database("mydb.sqlite");

db.exec(`
  CREATE TABLE IF NOT EXISTS users (
    id INTEGER PRIMARY KEY,
    name TEXT,
    email TEXT
  )
`);

const insert = db.prepare("INSERT INTO users (name, email) VALUES (?, ?)");
insert.run("Perry", "perry@example.com");

const users = db.prepare("SELECT * FROM users").all();
console.log(users);

MongoDB

import { MongoClient } from "mongodb";

const client = new MongoClient("mongodb://localhost:27017");
await client.connect();

const db = client.db("mydb");
const users = db.collection("users");

await users.insertOne({ name: "Perry", email: "perry@example.com" });
const user = await users.findOne({ name: "Perry" });
console.log(user);

await client.close();

Redis

import Redis from "ioredis";

const redis = new Redis();

await redis.set("key", "value");
const value = await redis.get("key");
console.log(value); // "value"

await redis.del("key");
await redis.quit();

Next Steps

Cryptography

Perry natively implements password hashing, JWT tokens, and Ethereum cryptography.

bcrypt

import bcrypt from "bcrypt";

const hash = await bcrypt.hash("mypassword", 10);
const match = await bcrypt.compare("mypassword", hash);
console.log(match); // true

Argon2

import argon2 from "argon2";

const hash = await argon2.hash("mypassword");
const valid = await argon2.verify(hash, "mypassword");
console.log(valid); // true

JSON Web Tokens

import jwt from "jsonwebtoken";

const secret = "my-secret-key";

// Sign a token
const token = jwt.sign({ userId: 123, role: "admin" }, secret, {
  expiresIn: "1h",
});

// Verify a token
const decoded = jwt.verify(token, secret);
console.log(decoded.userId); // 123

Node.js Crypto

import crypto from "crypto";

// Hash
const hash = crypto.createHash("sha256").update("data").digest("hex");

// HMAC
const hmac = crypto.createHmac("sha256", "secret").update("data").digest("hex");

// Random bytes
const bytes = crypto.randomBytes(32);

Ethers

import { ethers } from "ethers";

// Create a wallet
const wallet = ethers.Wallet.createRandom();
console.log(wallet.address);

// Sign a message
const signature = await wallet.signMessage("Hello, Ethereum!");

Next Steps

Utilities

Perry natively implements common utility packages.

lodash

import _ from "lodash";

_.chunk([1, 2, 3, 4, 5], 2);     // [[1,2], [3,4], [5]]
_.uniq([1, 2, 2, 3, 3]);          // [1, 2, 3]
_.groupBy(users, "role");
_.sortBy(users, ["name"]);
_.cloneDeep(obj);
_.merge(defaults, overrides);
_.debounce(fn, 300);
_.throttle(fn, 100);

dayjs

import dayjs from "dayjs";

const now = dayjs();
console.log(now.format("YYYY-MM-DD"));
console.log(now.add(7, "day").format("YYYY-MM-DD"));
console.log(now.subtract(1, "month").toISOString());

const diff = dayjs("2025-12-31").diff(now, "day");
console.log(`${diff} days until end of year`);

moment

import moment from "moment";

const now = moment();
console.log(now.format("MMMM Do YYYY"));
console.log(now.fromNow());
console.log(moment("2025-01-01").isBefore(now));

uuid

import { v4 as uuidv4 } from "uuid";

const id = uuidv4();
console.log(id); // e.g., "550e8400-e29b-41d4-a716-446655440000"

nanoid

import { nanoid } from "nanoid";

const id = nanoid();       // Default 21 chars
const short = nanoid(10);  // Custom length
console.log(id);

slugify

import slugify from "slugify";

const slug = slugify("Hello World!", { lower: true });
console.log(slug); // "hello-world"

validator

import validator from "validator";

validator.isEmail("test@example.com");  // true
validator.isURL("https://example.com"); // true
validator.isUUID(id);                   // true
validator.isEmpty("");                  // true

Next Steps

Other Modules

Additional npm packages and Node.js APIs supported by Perry.

sharp (Image Processing)

import sharp from "sharp";

await sharp("input.jpg")
  .resize(300, 200)
  .toFile("output.png");

cheerio (HTML Parsing)

import cheerio from "cheerio";

const html = "<html><body><h1>Hello</h1><p>World</p></body></html>";
const $ = cheerio.load(html);
console.log($("h1").text()); // "Hello"

nodemailer (Email)

import nodemailer from "nodemailer";

const transporter = nodemailer.createTransport({
  host: "smtp.example.com",
  port: 587,
  auth: { user: "user", pass: "pass" },
});

await transporter.sendMail({
  from: "sender@example.com",
  to: "recipient@example.com",
  subject: "Hello from Perry",
  text: "This email was sent from a compiled TypeScript binary!",
});

zlib (Compression)

import zlib from "zlib";

const compressed = zlib.gzipSync("Hello, World!");
const decompressed = zlib.gunzipSync(compressed);

cron (Job Scheduling)

import { CronJob } from "cron";

const job = new CronJob("*/5 * * * *", () => {
  console.log("Runs every 5 minutes");
});
job.start();

worker_threads

import { Worker, parentPort, workerData } from "worker_threads";

if (parentPort) {
  // Worker thread
  const data = workerData;
  parentPort.postMessage({ result: data.value * 2 });
} else {
  // Main thread
  const worker = new Worker("./worker.ts", {
    workerData: { value: 21 },
  });
  worker.on("message", (msg) => {
    console.log(msg.result); // 42
  });
}

commander (CLI Parsing)

import { Command } from "commander";

const program = new Command();
program.name("my-cli").version("1.0.0").description("My CLI tool");

program
  .command("serve")
  .option("-p, --port <number>", "Port number")
  .option("--verbose", "Verbose output")
  .action((options) => {
    console.log(`Starting server on port ${options.port}`);
  });

program.parse(process.argv);

decimal.js (Arbitrary Precision)

import Decimal from "decimal.js";

const a = new Decimal("0.1");
const b = new Decimal("0.2");
const sum = a.plus(b); // Exactly 0.3 (no floating point errors)

sum.toFixed(2);      // "0.30"
sum.toNumber();      // 0.3
a.times(b);          // 0.02
a.div(b);            // 0.5
a.pow(10);           // 1e-10
a.sqrt();            // 0.316...

lru-cache

import LRUCache from "lru-cache";

const cache = new LRUCache(100); // max 100 entries

cache.set("key", "value");
cache.get("key");       // "value"
cache.has("key");       // true
cache.delete("key");
cache.clear();

child_process

import { spawnBackground, getProcessStatus, killProcess } from "child_process";

// Spawn a background process
const { pid, handleId } = spawnBackground("sleep", ["10"], "/tmp/log.txt");

// Check if it's still running
const status = getProcessStatus(handleId);
console.log(status.alive); // true

// Kill it
killProcess(handleId);

Next Steps

System APIs Overview

The perry/system module provides access to platform-native system features: preferences, secure storage, notifications, URL opening, and dark mode detection.

import { openURL, isDarkMode, preferencesSet, preferencesGet } from "perry/system";

Available APIs

FunctionDescriptionPlatforms
openURL(url)Open URL in default browser/appAll
isDarkMode()Check system dark modeAll
preferencesSet(key, value)Store a preferenceAll
preferencesGet(key)Read a preferenceAll
keychainSet(key, value)Secure storage writeAll
keychainGet(key)Secure storage readAll
sendNotification(title, body)Local notificationAll
clipboardGet()Read clipboardAll
clipboardSet(text)Write clipboardAll

Quick Example

import { isDarkMode, preferencesGet, preferencesSet, openURL } from "perry/system";

// Detect dark mode
if (isDarkMode()) {
  console.log("Dark mode is active");
}

// Store user preferences
preferencesSet("theme", "dark");
const theme = preferencesGet("theme");

// Open a URL
openURL("https://example.com");

Next Steps

Preferences

Store and retrieve user preferences using the platform’s native storage.

Usage

import { preferencesSet, preferencesGet } from "perry/system";

// Store a preference
preferencesSet("username", "perry");
preferencesSet("fontSize", "14");
preferencesSet("darkMode", "true");

// Read a preference
const username = preferencesGet("username");  // "perry"
const fontSize = preferencesGet("fontSize");  // "14"

Values are stored as strings. Convert numbers and booleans as needed:

preferencesSet("count", String(42));
const count = Number(preferencesGet("count"));

Platform Storage

PlatformBackend
macOSNSUserDefaults
iOSNSUserDefaults
AndroidSharedPreferences
WindowsWindows Registry
LinuxGSettings / file-based
WeblocalStorage

Preferences persist across app launches. They are not encrypted — use Keychain for sensitive data.

Next Steps

Keychain

Securely store sensitive data like tokens, passwords, and API keys using the platform’s secure storage.

Usage

import { keychainSet, keychainGet } from "perry/system";

// Store a secret
keychainSet("api-token", "sk-abc123...");

// Retrieve a secret
const token = keychainGet("api-token");

Platform Storage

PlatformBackend
macOSSecurity.framework (Keychain)
iOSSecurity.framework (Keychain)
AndroidAndroid Keystore
WindowsWindows Credential Manager (CredWrite/CredRead/CredDelete)
Linuxlibsecret
WeblocalStorage (not truly secure)

Web: The web platform uses localStorage, which is not encrypted. For web apps handling sensitive data, consider server-side storage instead.

Next Steps

Notifications

Send local notifications using the platform’s notification system.

Usage

import { sendNotification } from "perry/system";

sendNotification("Download Complete", "Your file has been downloaded successfully.");

Platform Implementation

PlatformBackend
macOSUNUserNotificationCenter
iOSUNUserNotificationCenter
AndroidNotificationManager
WindowsToast notifications
LinuxGNotification
WebWeb Notification API

Permissions: On macOS, iOS, and Web, the user may need to grant notification permissions. On first use, the system will prompt for permission automatically.

Next Steps

Other System APIs

Additional platform-level APIs.

Open URL

Open a URL in the default browser or application:

import { openURL } from "perry/system";

openURL("https://example.com");
openURL("mailto:user@example.com");
PlatformImplementation
macOSNSWorkspace.open
iOSUIApplication.open
AndroidIntent.ACTION_VIEW
WindowsShellExecuteW
Linuxxdg-open
Webwindow.open

Dark Mode Detection

import { isDarkMode } from "perry/system";

if (isDarkMode()) {
  // Use dark theme colors
}
PlatformDetection
macOSNSApp.effectiveAppearance
iOSUITraitCollection
AndroidConfiguration.uiMode
WindowsRegistry (AppsUseLightTheme)
LinuxGTK settings
Webprefers-color-scheme media query

Clipboard

import { clipboardGet, clipboardSet } from "perry/system";

clipboardSet("Copied text!");
const text = clipboardGet();

Next Steps

Widgets (WidgetKit) Overview

Perry can compile TypeScript widget declarations to native SwiftUI WidgetKit extensions for iOS home screen widgets.

What Are Widgets?

iOS home screen widgets display glanceable information outside your app. Perry’s perry/widget module lets you define widgets in TypeScript that compile to native SwiftUI code.

import { Widget, Text, VStack } from "perry/widget";

Widget({
  kind: "MyWidget",
  displayName: "My Widget",
  description: "Shows a greeting",
  entryFields: { name: "string" },
  render: (entry) =>
    VStack([
      Text(`Hello, ${entry.name}!`),
    ]),
});

How It Works

TypeScript widget declaration
    ↓ Parse & Lower to WidgetDecl HIR
    ↓ perry-codegen-swiftui emits SwiftUI source
    ↓
Complete WidgetKit extension:
  - Entry struct
  - View
  - TimelineProvider
  - WidgetBundle
  - Info.plist

The compiler generates a complete SwiftUI WidgetKit extension — no Swift knowledge required.

Building

perry widget.ts --target ios-widget

This produces a WidgetKit extension directory that can be added to an Xcode project.

Next Steps

Creating Widgets

Define iOS home screen widgets using the Widget() function.

Widget Declaration

import { Widget, Text, VStack, HStack, Image, Spacer } from "perry/widget";

Widget({
  kind: "WeatherWidget",
  displayName: "Weather",
  description: "Shows current weather",
  entryFields: {
    temperature: "number",
    condition: "string",
    location: "string",
  },
  render: (entry) =>
    VStack([
      HStack([
        Text(entry.location),
        Spacer(),
        Image("cloud.sun.fill"),
      ]),
      Text(`${entry.temperature}°`),
      Text(entry.condition),
    ]),
});

Widget Options

PropertyTypeDescription
kindstringUnique identifier for the widget
displayNamestringName shown in widget gallery
descriptionstringDescription in widget gallery
entryFieldsobjectData fields with types ("string", "number", "boolean")
renderfunctionRender function receiving entry data, returns widget tree

Entry Fields

Entry fields define the data your widget displays. Each field has a name and type:

entryFields: {
  title: "string",
  count: "number",
  isActive: "boolean",
}

These compile to a Swift TimelineEntry struct:

struct WeatherEntry: TimelineEntry {
    let date: Date
    let temperature: Double
    let condition: String
    let location: String
}

Conditionals in Render

Use ternary expressions for conditional rendering:

render: (entry) =>
  VStack([
    Text(entry.isActive ? "Active" : "Inactive"),
    entry.count > 0 ? Text(`${entry.count} items`) : Spacer(),
  ]),

Template Literals

Template literals in widget text are compiled to Swift string interpolation:

Text(`${entry.name}: ${entry.score} points`)
// Compiles to: Text("\(entry.name): \(entry.score) points")

Multiple Widgets

Define multiple widgets in a single file. They’re bundled into a WidgetBundle:

Widget({
  kind: "SmallWidget",
  // ...
});

Widget({
  kind: "LargeWidget",
  // ...
});

Next Steps

  • Components — Available widget components and modifiers
  • Overview — Widget system overview

Widget Components

Available components and modifiers for WidgetKit widgets.

Text

Text("Hello, World!")
Text(`${entry.name}: ${entry.value}`)

Text Modifiers

const t = Text("Styled");
t.font("title");       // .title, .headline, .body, .caption, etc.
t.color("blue");       // Named color or hex
t.bold();

Layout

VStack

VStack([
  Text("Top"),
  Text("Bottom"),
])

HStack

HStack([
  Text("Left"),
  Spacer(),
  Text("Right"),
])

ZStack

ZStack([
  Image("background"),
  Text("Overlay"),
])

Spacer

Flexible space that expands to fill available room:

HStack([
  Text("Left"),
  Spacer(),
  Text("Right"),
])

Image

Display SF Symbols or asset images:

Image("star.fill")           // SF Symbol
Image("cloud.sun.rain.fill") // SF Symbol

Modifiers

Widget components support SwiftUI-style modifiers:

Font

Text("Title").font("title")
Text("Body").font("body")
Text("Caption").font("caption")

Color

Text("Red text").color("red")
Text("Custom").color("#FF6600")

Padding

VStack([...]).padding(16)

Frame

widget.frame(width, height)

Conditionals

Render different components based on entry data:

render: (entry) =>
  VStack([
    entry.isOnline
      ? Text("Online").color("green")
      : Text("Offline").color("red"),
  ]),

Complete Example

import { Widget, Text, VStack, HStack, Image, Spacer } from "perry/widget";

Widget({
  kind: "StatsWidget",
  displayName: "Stats",
  description: "Shows daily stats",
  entryFields: {
    steps: "number",
    calories: "number",
    distance: "string",
  },
  render: (entry) =>
    VStack([
      HStack([
        Image("figure.walk"),
        Text("Daily Stats").font("headline"),
      ]),
      Spacer(),
      HStack([
        VStack([
          Text(`${entry.steps}`).font("title").bold(),
          Text("steps").font("caption").color("gray"),
        ]),
        Spacer(),
        VStack([
          Text(`${entry.calories}`).font("title").bold(),
          Text("cal").font("caption").color("gray"),
        ]),
        Spacer(),
        VStack([
          Text(entry.distance).font("title").bold(),
          Text("km").font("caption").color("gray"),
        ]),
      ]),
    ]).padding(16),
});

Next Steps

Plugin System Overview

Perry supports native plugins as shared libraries (.dylib/.so). Plugins extend Perry applications with custom hooks, tools, services, and routes.

How It Works

  1. A plugin is a Perry-compiled shared library with activate(api) and deactivate() entry points
  2. The host application loads plugins with loadPlugin(path)
  3. Plugins register hooks, tools, and services via the API handle
  4. The host dispatches events to plugins via emitHook(name, data)
Host Application
    ↓ loadPlugin("./my-plugin.dylib")
    ↓ calls plugin_activate(api_handle)
Plugin
    ↓ api.registerHook("beforeSave", callback)
    ↓ api.registerTool("format", callback)
Host
    ↓ emitHook("beforeSave", data) → plugin callback runs

Quick Example

Plugin (compiled with --output-type dylib)

// my-plugin.ts
export function activate(api: PluginAPI) {
  api.setMetadata("my-plugin", "1.0.0", "A sample plugin");

  api.registerHook("beforeSave", (data) => {
    console.log("About to save:", data);
    return data; // Return modified data (filter mode)
  });

  api.registerTool("greet", (args) => {
    return `Hello, ${args.name}!`;
  });
}

export function deactivate() {
  console.log("Plugin deactivated");
}
perry my-plugin.ts --output-type dylib -o my-plugin.dylib

Host Application

import { loadPlugin, emitHook, invokeTool, listPlugins } from "perry/plugin";

loadPlugin("./my-plugin.dylib");

// List loaded plugins
const plugins = listPlugins();
console.log(plugins); // [{ name: "my-plugin", version: "1.0.0", ... }]

// Emit a hook
const result = emitHook("beforeSave", { content: "..." });

// Invoke a tool
const greeting = invokeTool("greet", { name: "Perry" });
console.log(greeting); // "Hello, Perry!"

Plugin ABI

Plugins must export these symbols:

  • perry_plugin_abi_version() — Returns ABI version (for compatibility checking)
  • plugin_activate(api_handle) — Called when plugin is loaded
  • plugin_deactivate() — Called when plugin is unloaded

Perry generates these automatically from your activate/deactivate exports.

Next Steps

Creating Plugins

Build Perry plugins as shared libraries that extend host applications.

Step 1: Write the Plugin

// counter-plugin.ts
let count = 0;

export function activate(api: PluginAPI) {
  api.setMetadata("counter", "1.0.0", "Counts hook invocations");

  api.registerHook("onRequest", (data) => {
    count++;
    console.log(`Request #${count}`);
    return data;
  });

  api.registerTool("getCount", () => {
    return count;
  });
}

export function deactivate() {
  console.log(`Total requests processed: ${count}`);
}

Step 2: Compile as Shared Library

perry counter-plugin.ts --output-type dylib -o counter-plugin.dylib

The --output-type dylib flag tells Perry to produce a .dylib (macOS) or .so (Linux) instead of an executable.

Perry automatically:

  • Generates perry_plugin_abi_version() returning the current ABI version
  • Generates plugin_activate(api_handle) calling your activate() function
  • Generates plugin_deactivate() calling your deactivate() function
  • Exports symbols with -rdynamic for the host to find

Step 3: Load from Host

// host-app.ts
import { loadPlugin, emitHook, invokeTool, discoverPlugins } from "perry/plugin";

// Load a specific plugin
loadPlugin("./counter-plugin.dylib");

// Or discover plugins in a directory
discoverPlugins("./plugins/");

// Use the plugin
emitHook("onRequest", { path: "/api/users" });
const count = invokeTool("getCount", {});
console.log(`Processed ${count} requests`);

Plugin API Reference

The api object passed to activate() provides:

Metadata

api.setMetadata(name: string, version: string, description: string)

Hooks

api.registerHook(name: string, callback: (data: any) => any, priority?: number)

Hooks are called in priority order (lower number = called first).

Tools

api.registerTool(name: string, callback: (args: any) => any)

Tools are invoked by name from the host.

Configuration

const value = api.getConfig(key: string)  // Read host-provided config

Events

api.on(event: string, handler: (data: any) => void)  // Listen for events
api.emit(event: string, data: any)                     // Emit to other plugins

Next Steps

Hooks & Events

Perry plugins communicate through hooks, events, and tools.

Hook Modes

Hooks support three execution modes:

Filter Mode (default)

Each plugin receives data and returns (possibly modified) data. The output of one plugin becomes the input of the next:

api.registerHook("transform", (data) => {
  data.content = data.content.toUpperCase();
  return data; // Returned data goes to next plugin
});

Action Mode

Plugins receive data but return value is ignored. Used for side effects:

api.registerHook("onSave", (data) => {
  console.log(`Saved: ${data.path}`);
  // Return value ignored
});

Waterfall Mode

Like filter mode, but specifically for accumulating/building up a result through the chain:

api.registerHook("buildMenu", (items) => {
  items.push({ label: "My Plugin Action", action: () => {} });
  return items;
});

Hook Priority

Lower priority numbers run first:

api.registerHook("beforeSave", validate, 10);   // Runs first
api.registerHook("beforeSave", transform, 20);   // Runs second
api.registerHook("beforeSave", log, 100);         // Runs last

Default priority is 50.

Event Bus

Plugins can communicate with each other through events:

Emitting Events

// From a plugin
api.emit("dataUpdated", { source: "my-plugin", records: 42 });

// From the host
import { emitEvent } from "perry/plugin";
emitEvent("dataUpdated", { source: "host", records: 100 });

Listening for Events

api.on("dataUpdated", (data) => {
  console.log(`${data.source} updated ${data.records} records`);
});

Tools

Plugins register callable tools:

// Plugin registers a tool
api.registerTool("formatCode", (args) => {
  return formatSource(args.code, args.language);
});
// Host invokes the tool
import { invokeTool } from "perry/plugin";

const formatted = invokeTool("formatCode", {
  code: "const x=1",
  language: "typescript",
});

Configuration

Hosts can pass configuration to plugins:

// Host sets config
import { setConfig } from "perry/plugin";
setConfig("theme", "dark");
setConfig("maxRetries", "3");
// Plugin reads config
export function activate(api: PluginAPI) {
  const theme = api.getConfig("theme");     // "dark"
  const retries = api.getConfig("maxRetries"); // "3"
}

Introspection

Query loaded plugins and their registrations:

import { listPlugins, listHooks, listTools } from "perry/plugin";

const plugins = listPlugins();  // [{ name, version, description }]
const hooks = listHooks();      // [{ name, pluginName, priority }]
const tools = listTools();      // [{ name, pluginName }]

Next Steps

CLI Commands

Perry provides 8 commands for compiling, checking, publishing, and managing your projects.

compile

Compile TypeScript to a native executable.

perry compile main.ts -o app
# Or shorthand (auto-detects compile):
perry main.ts -o app
FlagDescription
-o, --output <PATH>Output file path
--target <TARGET>Platform target (see Compiler Flags)
--output-type <TYPE>executable (default) or dylib (plugin)
--print-hirPrint HIR intermediate representation
--no-linkProduce object file only, skip linking
--keep-intermediatesKeep .o and .asm files
--enable-js-runtimeEnable V8 JavaScript runtime fallback
--type-checkEnable type checking via tsgo
--app-bundle-id <ID>Bundle ID (required for widget targets)
--bundle-extensions <DIR>Bundle TypeScript extensions from directory
# Basic compilation
perry compile app.ts -o app

# Cross-compile for iOS Simulator
perry compile app.ts -o app --target ios-simulator

# Build a plugin
perry compile plugin.ts --output-type dylib -o plugin.dylib

# Debug: view intermediate representation
perry compile app.ts --print-hir

# Build an iOS widget
perry compile widget.ts --target ios-widget --app-bundle-id com.myapp.widget

check

Validate TypeScript for Perry compatibility without compiling.

perry check src/
FlagDescription
--check-depsCheck node_modules for compatibility
--deep-depsScan all transitive dependencies
--allShow all issues including hints
--strictTreat warnings as errors
--fixAutomatically apply fixes
--fix-dry-runPreview fixes without modifying files
--fix-unsafeInclude medium-confidence fixes
# Check a single file
perry check src/index.ts

# Check with dependency analysis
perry check . --check-deps

# Auto-fix issues
perry check . --fix

# Preview fixes without applying
perry check . --fix-dry-run

init

Create a new Perry project.

perry init my-project
cd my-project
FlagDescription
--name <NAME>Project name (defaults to directory name)

Creates perry.toml, src/main.ts, and .gitignore.

doctor

Check your Perry installation and environment.

perry doctor
FlagDescription
--quietOnly report failures

Checks:

  • Perry version
  • System linker availability (cc/MSVC)
  • Runtime library
  • Project configuration
  • Available updates

explain

Get detailed explanations for error codes.

perry explain U001

Error code families:

  • P — Parse errors
  • T — Type errors
  • U — Unsupported features
  • D — Dependency issues

Each explanation includes the error description, example code, and suggested fix.

publish

Build, sign, and distribute your app.

perry publish --macos
perry publish --ios
perry publish --android
FlagDescription
--macosBuild for macOS (App Store/notarization)
--iosBuild for iOS (App Store/TestFlight)
--androidBuild for Android (Google Play)
--linuxBuild for Linux (AppImage/deb/rpm)
--server <URL>Build server (default: https://hub.perryts.com)
--license-key <KEY>Perry Hub license key
--project <PATH>Project directory
-o, --output <PATH>Artifact output directory (default: dist)
--no-downloadSkip artifact download

Apple-specific flags:

FlagDescription
--apple-team-id <ID>Developer Team ID
--apple-identity <NAME>Signing identity
--apple-p8-key <PATH>App Store Connect .p8 key
--apple-key-id <ID>App Store Connect API Key ID
--apple-issuer-id <ID>App Store Connect Issuer ID
--certificate <PATH>.p12 certificate bundle
--provisioning-profile <PATH>.mobileprovision file (iOS)

Android-specific flags:

FlagDescription
--android-keystore <PATH>.jks/.keystore file
--android-keystore-password <PASS>Keystore password
--android-key-alias <ALIAS>Key alias
--android-key-password <PASS>Key password
--google-play-key <PATH>Google Play service account JSON

On first use, publish auto-registers a free license key.

setup

Interactive credential wizard for app distribution.

perry setup          # Show platform menu
perry setup macos    # macOS setup
perry setup ios      # iOS setup
perry setup android  # Android setup

Stores credentials in ~/.perry/config.toml.

update

Check for and install Perry updates.

perry update             # Update to latest
perry update --check-only  # Check without installing
perry update --force       # Ignore 24h cache

Update sources (checked in order):

  1. Custom server (env/config)
  2. Perry Hub
  3. GitHub API

Opt out of automatic update checks with PERRY_NO_UPDATE_CHECK=1 or CI=true.

Next Steps

Compiler Flags

Complete reference for all Perry CLI flags.

Global Flags

Available on all commands:

FlagDescription
--format text|jsonOutput format (default: text)
-v, --verboseIncrease verbosity (repeatable: -v, -vv, -vvv)
-q, --quietSuppress non-error output
--no-colorDisable ANSI color codes

Compilation Targets

Use --target to cross-compile:

TargetPlatformNotes
(none)Current platformDefault behavior
ios-simulatoriOS SimulatorARM64 simulator binary
iosiOS DeviceARM64 device binary
androidAndroidARM64/ARMv7
ios-widgetiOS WidgetWidgetKit extension (requires --app-bundle-id)
ios-widget-simulatoriOS Widget (Sim)Widget for simulator
webWebOutputs HTML file with JS
windowsWindowsWin32 executable
linuxLinuxGTK4 executable

Output Types

Use --output-type to change what’s produced:

TypeDescription
executableStandalone binary (default)
dylibShared library (.dylib/.so) for plugins

Debug Flags

FlagDescription
--print-hirPrint HIR (intermediate representation) to stdout
--no-linkProduce .o object file only, skip linking
--keep-intermediatesKeep .o and .asm intermediate files

Runtime Flags

FlagDescription
--enable-js-runtimeEnable V8 JavaScript runtime for unsupported npm packages
--type-checkEnable type checking via tsgo IPC

Environment Variables

VariableDescription
PERRY_LICENSE_KEYPerry Hub license key for perry publish
PERRY_APPLE_CERTIFICATE_PASSWORDPassword for .p12 certificate
PERRY_NO_UPDATE_CHECK=1Disable automatic update checks
PERRY_UPDATE_SERVERCustom update server URL
CI=trueAuto-skip update checks (set by most CI systems)
RUST_LOGDebug logging level (debug, info, trace)

Configuration Files

perry.toml (project)

[project]
name = "my-app"
entry = "src/main.ts"
version = "1.0.0"

[build]
out_dir = "build"

[app]
name = "My App"
description = "A Perry application"

[macos]
bundle_id = "com.example.myapp"
category = "public.app-category.developer-tools"
minimum_os = "13.0"
distribute = "notarize"  # "appstore", "notarize", or "both"

[ios]
bundle_id = "com.example.myapp"
deployment_target = "16.0"
device_family = ["iphone", "ipad"]

[android]
package_name = "com.example.myapp"
min_sdk = 26
target_sdk = 34

[linux]
format = "appimage"  # "appimage", "deb", "rpm"
category = "Development"

~/.perry/config.toml (global)

[apple]
team_id = "XXXXXXXXXX"
signing_identity = "Developer ID Application: Your Name"

[android]
keystore_path = "/path/to/keystore.jks"
key_alias = "my-key"

Examples

# Simple CLI program
perry main.ts -o app

# iOS app for simulator
perry app.ts -o app --target ios-simulator

# Web app
perry app.ts -o app --target web

# Plugin shared library
perry plugin.ts --output-type dylib -o plugin.dylib

# iOS widget with bundle ID
perry widget.ts --target ios-widget --app-bundle-id com.example.app

# Debug compilation
perry app.ts --print-hir 2>&1 | less

# Verbose compilation
perry compile app.ts -o app -vvv

# Type-checked compilation
perry app.ts -o app --type-check

Next Steps

Architecture

This is a brief overview for contributors. For detailed implementation notes, see the project’s CLAUDE.md.

Compilation Pipeline

TypeScript (.ts)
    ↓ Parse (SWC)
    ↓ AST
    ↓ Lower (perry-hir)
    ↓ HIR (High-level IR)
    ↓ Transform (inline, closure conversion, async lowering)
    ↓ Codegen (Cranelift)
    ↓ Object file (.o)
    ↓ Link (system cc)
    ↓
Native Executable

Crate Map

CratePurpose
perryCLI driver, command parsing, compilation orchestration
perry-parserSWC wrapper for TypeScript parsing
perry-typesType system definitions
perry-hirHIR data structures (ir.rs) and AST→HIR lowering (lower.rs)
perry-transformIR passes: function inlining, closure conversion, async lowering
perry-codegenCranelift-based native code generation (12 modules)
perry-codegen-jsJavaScript code generation for --target web
perry-codegen-swiftuiSwiftUI code generation for WidgetKit extensions
perry-runtimeRuntime library: NaN-boxed values, GC, arena allocator, objects, arrays, strings
perry-stdlibNode.js API implementations: mysql2, redis, fastify, bcrypt, etc.
perry-uiShared UI types
perry-ui-macosmacOS UI (AppKit)
perry-ui-iosiOS UI (UIKit)
perry-jsruntimeJavaScript interop via QuickJS

Key Concepts

NaN-Boxing

All JavaScript values are represented as 64-bit NaN-boxed values. The upper 16 bits encode the type tag:

TagType
0x7FFFString (lower 48 bits = pointer)
0x7FFDPointer/Object (lower 48 bits = pointer)
0x7FFEInt32 (lower 32 bits = integer)
0x7FFABigInt (lower 48 bits = pointer)
Special constantsundefined, null, true, false
Any otherFloat64 (the full 64 bits)

Garbage Collection

Mark-sweep GC with conservative stack scanning. Arena-allocated objects (arrays, objects) are found by linear block walking. Malloc-allocated objects (strings, closures, promises) are tracked in a thread-local Vec.

Handle-Based UI

UI widgets are represented as small integer handles NaN-boxed with POINTER_TAG. Each handle maps to a native platform widget (NSButton, UILabel, GtkButton, etc.). Two dispatch tables route method calls and property accesses to the correct FFI function.

Source Code Organization

The codegen crate was split into 12 focused modules:

perry-codegen/src/
  codegen.rs       # Main entry, module compilation
  types.rs         # Type definitions, context structs
  util.rs          # Helper functions
  stubs.rs         # Stub generation for unresolved deps
  runtime_decls.rs # Runtime function declarations
  classes.rs       # Class compilation
  functions.rs     # Function compilation
  closures.rs      # Closure compilation
  module_init.rs   # Module initialization
  stmt.rs          # Statement compilation
  expr.rs          # Expression compilation

The HIR lowering was split into 8 modules:

perry-hir/src/
  lower.rs           # Main lowering entry
  analysis.rs        # Code analysis passes
  enums.rs           # Enum lowering
  jsx.rs             # JSX lowering
  lower_types.rs     # Type lowering
  lower_patterns.rs  # Pattern lowering
  destructuring.rs   # Destructuring lowering
  lower_decl.rs      # Declaration lowering

Next Steps

Building from Source

Prerequisites

  • Rust toolchain (stable): rustup.rs
  • System C compiler (cc on macOS/Linux, MSVC on Windows)

Build

git clone https://github.com/skelpo/perry.git
cd perry

# Build all crates (release mode recommended)
cargo build --release

The binary is at target/release/perry.

Build Specific Crates

# Runtime only (must rebuild stdlib too!)
cargo build --release -p perry-runtime -p perry-stdlib

# Codegen only
cargo build --release -p perry-codegen

Important: When rebuilding perry-runtime, you must also rebuild perry-stdlib because libperry_stdlib.a embeds perry-runtime as a static dependency.

Run Tests

# All tests (exclude iOS crate on non-iOS host)
cargo test --workspace --exclude perry-ui-ios

# Specific crate
cargo test -p perry-hir
cargo test -p perry-codegen

Compile and Run TypeScript

# Compile a TypeScript file
cargo run --release -- hello.ts -o hello
./hello

# Debug: print HIR
cargo run --release -- hello.ts --print-hir

Development Workflow

  1. Make changes to the relevant crate
  2. cargo build --release to build
  3. cargo test --workspace --exclude perry-ui-ios to verify
  4. Test with a real TypeScript file: cargo run --release -- test.ts -o test && ./test

Project Structure

perry/
├── crates/
│   ├── perry/              # CLI driver
│   ├── perry-parser/       # SWC TypeScript parser
│   ├── perry-types/        # Type definitions
│   ├── perry-hir/          # HIR and lowering
│   ├── perry-transform/    # IR passes
│   ├── perry-codegen/      # Cranelift codegen
│   ├── perry-codegen-js/   # Web target codegen
│   ├── perry-codegen-swiftui/ # Widget codegen
│   ├── perry-runtime/      # Runtime library
│   ├── perry-stdlib/       # npm package implementations
│   ├── perry-ui/           # Shared UI types
│   ├── perry-ui-macos/     # macOS AppKit UI
│   ├── perry-ui-ios/       # iOS UIKit UI
│   └── perry-jsruntime/    # QuickJS integration
├── docs/                   # This documentation (mdBook)
├── CLAUDE.md               # Detailed implementation notes
└── CHANGELOG.md            # Version history

Next Steps

  • Architecture — Crate map and pipeline overview
  • See CLAUDE.md for detailed implementation notes and pitfalls