Skip to content

TypeScript

Lune generates TypeScript declaration files alongside every build. Your frontend gets full autocomplete and type safety derived directly from Crystal method signatures — no manual type definitions needed.


Generated files

Lune writes four files into frontend/lunejs/:

frontend/lunejs/
├── app/
│   ├── App.js       # binding stubs (runtime)
│   └── App.d.ts     # TypeScript declarations for your bindings
└── runtime/
    ├── runtime.js   # quit, openUrl, environment, on/once/off/emit
    └── runtime.d.ts # TypeScript declarations for runtime functions

These are regenerated automatically on every lune dev start and lune build.


App.d.ts — binding types

For each class and its @[Lune::Bind] methods, Lune generates an interface. For example:

crystal
class FileModule
  include Lune::Bindable

  @[Lune::Bind]
  def read(path : String) : String
    File.read(path)
  end

  @[Lune::Bind]
  def exists(path : String) : Bool
    File.exists?(path)
  end
end

Generates:

ts
export interface FileModule {
  read(args: { path: string }): Promise<string>;
  exists(args: { path: string }): Promise<boolean>;
}

export interface Api {
  FileModule: FileModule;
}

export declare const api: Api;
export default api;

runtime.d.ts — runtime types

The runtime declarations export a single nested Lune object plus a short alias lune = Lune.Plugins, so every built-in lives at lune.<Plugin>.<method> (e.g. lune.System.quit, lune.Event.on, lune.Filesystem.homeDir). Third-party plugins published via Lune.use are top-level named exports alongside Lune and LuneError — not nested under lune.

The exact shape is generated per project from the registered plugin set, so the snippet below is illustrative:

ts
export declare class LuneError extends Error {
  readonly code: string;
  constructor(code: string, message: string);
}

export const Lune: {
  Plugins: {
    System: {
      quit(): Promise<void>;
      openUrl(args: { url: string }): Promise<void>;
      environment(): Promise<{
        os: "darwin" | "linux" | "windows";
        arch: string;
        devtools: boolean;
      }>;
    };
    Event: {
      on(name: string, cb: (data: unknown) => void): void;
      once(name: string, cb: (data: unknown) => void): void;
      off(name: string, cb?: (data: unknown) => void): void;
      emit(args: { name: string; data?: unknown }): Promise<void>;
    };
    Filesystem: {
      homeDir(): Promise<string>;
      tempDir(): Promise<string>;
      downloadsDir(): Promise<string>;
      appDataDir(): Promise<string>;
    };
    // …all other built-in plugins…
  };
};
export const lune: typeof Lune.Plugins;

Return types are emitted structurally — Lune doesn't ship named interfaces like LuneEnvironment or ScreenInfo alongside the runtime. The generator derives the shape from the Crystal signature:

  • NamedTuple(width: Int32, height: Int32, scale: Float64){ width: number; height: number; scale: number }
  • A Crystal enum (a return type, or nested in a NamedTuple / struct / Array) → a TS string-literal union, captured generically by vow. The union is built from the values each member serializes to (Enum#to_json), so it always matches the wire — Crystal's default lowercases (enum Status; Pending; Running; Done; end"pending" | "running" | "done"), and a custom Enum#to_json is reflected. If you want a different shape entirely, hand-write it with @[Lune::BindOverride(ts_return_type: ...)].
  • A JSON::Serializable struct or class is captured automatically into a named interface (transitively — nested structs included), so the binding's signature references it by name. A non-serializable type that isn't given an explicit @[Lune::BindOverride(ts_return_type: ...)] is a generation error rather than a silent Record<string, any> (lune's type mapping runs on vow's strict mapper)

If you want a named type for an inlined shape, alias the inferred return type:

ts
type LuneEnvironment = Awaited<ReturnType<typeof lune.System.environment>>;

For the full per-plugin signature list see the Plugins reference — each page documents its JS surface, which is what shows up in runtime.d.ts.


Setting up TypeScript in a scaffolded project

The --template vue scaffold creates a TypeScript project out of the box. For a vanilla project, you can add TypeScript support to Vite manually:

sh
npm install -D typescript

Create a tsconfig.json at the frontend root:

json
{
  "compilerOptions": {
    "target": "ESNext",
    "module": "ESNext",
    "moduleResolution": "bundler",
    "strict": true,
    "noEmit": true
  },
  "include": ["src"]
}

Then rename your entry point from .js to .ts and update vite.config.js to vite.config.ts.


Importing with types

ts
import api from "../lunejs/app/App.js";
import { lune } from "../lunejs/runtime/runtime.js";
import type { LuneError } from "../lunejs/runtime/runtime.js";

// Fully typed — autocomplete works here
const result = await api.FileModule.read({ path: "/tmp/hello.txt" });

// environment() returns the structurally-typed object
const env = await lune.System.environment();
if (env.os === "darwin") {
  // macOS-specific code
}

Typing event payloads

Events carry unknown data by default. Cast or validate at the call site:

ts
interface ProgressEvent {
  done: number;
  total: number;
}

lune.Event.on("progress", (data) => {
  const { done, total } = data as ProgressEvent;
  updateProgressBar(done / total);
});

Handling errors with types

LuneError is a real Error subclass, so instanceof narrows automatically — no custom type guard needed:

ts
import { LuneError } from "../lunejs/runtime/runtime.js";

try {
  await api.FileModule.read({ path: "/nonexistent" });
} catch (e) {
  if (e instanceof LuneError) {
    console.error(`[${e.code}] ${e.message}`);
  }
}

See Error Handling for the full pattern including typed code branches.


Introspecting the RPC contract

The generated .d.ts files are the contract, but Lune can also hand you the contract as structured data — the manifest: every procedure (its name, argument names/types, and return type) plus the custom types those signatures reference. It's the same intermediate representation the code generator consumes.

Two ways to reach it:

From the CLIlune doctor api prints a readable table of the surface, and lune doctor api --json dumps the raw manifest JSON for tooling. See the CLI reference.

At runtime — the Introspection plugin (enabled by default) exposes lune.Introspection.manifest(), which returns the typed Manifest:

js
import { lune } from "../lunejs/runtime/runtime.js";

const contract = await lune.Introspection.manifest();
console.log(contract.procedures.length, "procedures");
console.table(
  contract.procedures.map((p) => ({ name: p.name, returns: p.return_type })),
);

For quick console use the plugin also injects a window.__lune.manifest() wrapper, but only when devtools are on — so it's absent in a production build. The typed binding is always available while the plugin is enabled. The shape is { procedures: [...], types: [...] } — handy for building an API explorer panel or asserting in a test that the surface you expect is exposed.

Released under the MIT License.