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 CLI — lune 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.