Skip to content

Kotlin Canonical, TypeScript Derived — Cross-Language Type Generation

The framework’s typed surfaces that cross the Kotlin/TypeScript boundary are generated from the Kotlin model. TypeScript never carries a hand-maintained type that has a Kotlin counterpart.

Background

Trailblaze exposes typed APIs in two languages:

  • Kotlin — where the framework code lives. Tool implementations, the selector grammar, the YAML trail model, the matched-element descriptors, the error envelopes. All of these are already @Serializable because they round-trip through YAML and through the host/on-device dispatch protocol.
  • TypeScript — where trailmap authors write scripted tools that consume the framework’s typed surface (client.tools.<name>(args)) and produce typed results.

Without a generator, every type that crosses the boundary has to be hand-maintained in both languages. That’s a clear drift hazard. Two existing precedents in the codebase show the pattern already creeping in:

  • sdks/typescript/src/built-in-tools.ts is hand-curated with an explicit comment at the top acknowledging auto-generation as a tracked follow-up.
  • Per-trailmap client.d.ts files are already generated by trailblaze-host from Kotlin tool declarations — Kotlin canonical, TS derived in miniature.

The pattern of “Kotlin canonical, TS derived” is partially in place. This devlog formalizes it as the framework’s rule, expands the scope to cover selectors and node descriptors and framework-level types, and documents the workflow.

What we decided

Every typed surface that crosses the Kotlin/TypeScript boundary is generated from the Kotlin model. TypeScript never carries a hand-maintained type that has a Kotlin counterpart.

This applies to:

  1. The selector grammarNodeSelector and every sealed-class branch (AndroidAccessibility, IosMaestro, Web, etc.) plus their fields.
  2. The matched-element descriptor — the shape returned by query tools like findMatches.
  3. Tool input args — derived from the @Serializable data classes that Kotlin tool implementations already define.
  4. Tool output results — when tools start returning structured values via the deferred structuredContent wire shape.
  5. Error envelopes and other framework-level structured types that need to be visible from TS.
  6. Anything else where Kotlin needs an @Serializable type AND TypeScript callers consume it.

This does not apply to:

  1. Pure TS code with no Kotlin counterpart — the trailblaze.tool({...}) wrapper itself, the client.tools.<name>(args) Proxy mechanism, TS-only utility functions in the SDK. These are genuinely TypeScript-native and have no Kotlin source to derive from.
  2. Per-trailmap scripted-tool TS source — that’s author-written TypeScript that the framework reads (via the scripted-tool definition analyzer), not bindings the framework emits.
  3. @Serializable Kotlin types that are not on the framework’s exported allowlist. See the next section.

The export set is an explicit allowlist

Not every @Serializable Kotlin class is exported to TypeScript. The Kotlin codebase contains many @Serializable types that are internal implementation details — transient wire shapes for the host-on-device RPC protocol, persistence formats, intermediate analysis results, etc. These have no business being part of the framework’s TS API surface.

The export set is an opt-in allowlist, expressed at the Kotlin source (see the open questions section for the mechanism — annotation vs config file vs marker interface; the decision is pending). A @Serializable class becomes a TS type if and only if it’s explicitly tagged for export.

Two consequences worth pinning:

  1. Adding a Kotlin type to the TS surface is a deliberate act. A framework engineer adds the export tag, regenerates, and the type appears in the SDK. Reviewers see the export-list change in the PR diff and can scrutinize whether it should cross the boundary.

  2. Removing a type from the surface is a breaking change with the same loud-compile-error properties as removing a field — every TS consumer that references it errors at compile time. So adding to the allowlist commits the framework to maintaining that surface; removing from the allowlist requires migrating consumers.

The starting allowlist (when the first generation pass ships) should be minimal — just the types we explicitly want callers to use. Examples likely to start on the list:

  • NodeSelector and its sealed-class branches
  • The match descriptor returned by query tools
  • Any shared types referenced from tool input/output schemas
  • Error envelopes that scripted-tool authors need to handle

Examples that should NOT start on the list:

  • RPC wire frames between host and on-device runtime
  • Internal session-event types
  • Resolver intermediate state
  • Anything that exists purely to support the framework’s internal machinery and has no consumer-visible role

The factory pattern

Kotlin sealed-class hierarchies generate to TypeScript discriminated unions plus a factory namespace. Example for the selector grammar:

// xyz.block.trailblaze.selectors.NodeSelector
@Serializable
sealed interface NodeSelector {
  @Serializable data class AndroidAccessibility(
    val textRegex: String? = null,
    val resourceIdRegex: String? = null,
    val contentDescriptionRegex: String? = null,
  ) : NodeSelector

  @Serializable data class IosMaestro(
    val textRegex: String? = null,
    val resourceIdRegex: String? = null,
  ) : NodeSelector
}

Generated TypeScript:

// AUTO-GENERATED — do not edit by hand.
// Source: xyz.block.trailblaze.selectors.NodeSelector

export type NodeSelector =
  | { androidAccessibility: AndroidAccessibilitySelector }
  | { iosMaestro: IosMaestroSelector };

export interface AndroidAccessibilitySelector {
  textRegex?: string;
  resourceIdRegex?: string;
  contentDescriptionRegex?: string;
}

export interface IosMaestroSelector {
  textRegex?: string;
  resourceIdRegex?: string;
}

export const selectors = {
  androidAccessibility: (
    args: AndroidAccessibilitySelector,
  ): NodeSelector => ({ androidAccessibility: args }),
  iosMaestro: (
    args: IosMaestroSelector,
  ): NodeSelector => ({ iosMaestro: args }),
};

Authors get two equivalent ways to construct a selector:

// Literal form — copy-paste compatible with the YAML serialization
const a: NodeSelector = { androidAccessibility: { textRegex: "Submit" } };

// Factory form — IDE narrows autocomplete to the chosen platform driver
const b: NodeSelector = selectors.androidAccessibility({ textRegex: "Submit" });

Both produce identical values. The factory is pure sugar — its implementation is (args) => ({ androidAccessibility: args }) — but it gives the author scoped IDE autocomplete (only the fields valid for that platform driver appear) and removes one level of nesting at the call site.

Adding a new platform driver is a single Kotlin sealed-class branch. The TS factory method, the new union member, and every downstream consumer’s autocomplete propagate on the next codegen run. No parallel TypeScript edits to remember.

Workflow — generated, committed, CI-verified

Generated TypeScript files are committed to source control alongside their Kotlin sources. CI asserts git diff --exit-code after running the codegen step. Drift = CI red.

This matches existing patterns already in use:

  • docs/CLI.md — generated from picocli annotations, CI-verified.
  • docs/generated/cli-scenarios.md — generated, CI-verified.
  • The framework docs/generated/TOOLS.md — generated from @TrailblazeToolClass annotations, CI-verified.

Note that per-trailmap <trailmap>/tools/.trailblaze/client.d.ts is a workspace-emitted artifact, not a repo-committed one — it’s written by the daemon / trailblaze check into the user’s workspace at bundle time, and per docs/scripted_tools.md may be gitignored or committed at the workspace’s discretion. The cross-language type generation described in this devlog produces framework-level types that ARE committed + CI-verified in this repo, paralleling the patterns above. Per-trailmap bindings remain workspace artifacts; they consume the framework-level types but aren’t themselves part of the repo’s CI diff loop.

Adding cross-language type generation is consistent with this infrastructure — additional outputs from the existing codegen pass, not a new system.

Refactoring story

Because the TS types are derived:

  • Rename a Kotlin field → regenerate → every TS consumer hits a compile error at the call site.
  • Add a Kotlin field (optional / nullable) → regenerate → every TS consumer’s autocomplete gains the new field. Non-breaking under TypeScript’s structural typing.
  • Remove a Kotlin field → regenerate → every TS consumer using the removed field hits a compile error. Breaking but loud and pinpoint-located.
  • Add a new sealed-class branch → regenerate → new factory method appears, new union member becomes addressable. No existing code breaks.
  • Rename a sealed-class branch → regenerate → the factory method renames; every TS consumer using the old factory hits a compile error.

Combined with TypeScript’s existing LSP refactor tooling — F2-rename on property accesses, cross-file find-references — this gives a refactor workflow that’s stronger than either language provides alone. Kotlin controls the schema; TypeScript’s LSP catches every consumer.

Why this matters

  1. No drift between Kotlin and TS. Two hand-maintained copies of the same type WILL drift over time. Generation eliminates the failure mode structurally, not by discipline.

  2. Refactoring at scale becomes safe. When the Kotlin source is the spec and TS is regenerated, you can confidently rename, restructure, and evolve types — every TS consumer surfaces as a compile error pointing exactly at the line that needs updating.

  3. Adding new platform drivers becomes one-line. A new selector sealed-class branch on the Kotlin side is the entire change. The TS factory, the union type, the per-trailmap .d.ts regeneration, and the downstream IDE autocomplete all follow automatically.

  4. The SDK can be more generous with its surface. A separate design conversation considered shipping minimal surface to minimize breaking- change blast radius. When the surface is generated, the cost of expanding it is near-zero — the maintenance burden lives in the Kotlin source, not in parallel TS files that have to be kept in sync.

  5. Documentation flows from the same source. TSDoc comments on generated TS types can be derived from KDoc comments on the Kotlin classes (depending on the generator chosen). One comment per field serves IDE hovers, generated docs, and LLM-facing schema descriptions across both languages.

Open questions

  1. Generator library choice. Three viable candidates:

  2. Hand-rolled via Kotlin reflection on @Serializable types. Works but verbose; full control over output style.

  3. kxs-ts-gen or similar drop-in libraries. Existing tools that emit TypeScript from kotlinx.serialization-annotated Kotlin. Less code to maintain, less control over output.
  4. Kotlin → JSON Schema → TypeScript via json-schema-to-typescript or quicktype. Reuses JSON Schema infrastructure that’s already in scope for LLM tool registration (the schemas already need to be produced for the LLM function-call contract; emitting TS types from the same JSON Schema is essentially free). Two-hop pipeline; loses some fidelity (KDoc → description mapping varies depending on the intermediate). Note: ts-json-schema-generator runs the opposite direction (TS source → JSON Schema) and is the right tool for reading scripted-tool input/output types in ScriptedToolDefinitionAnalyzer, not for emitting framework TS types from Kotlin.

Decide before the first standalone codegen PR ships. Recommendation based on the constraints: try kxs-ts-gen first; fall back to hand-rolled if its output style doesn’t match the existing per-trailmap .d.ts conventions.

  1. Output destination. Generated framework types live in sdks/typescript/src/generated/ (separate from hand-maintained SDK code, committed, CI-verified). Per-trailmap types continue to live in <trailmap>/tools/.trailblaze/client.d.ts (already emitted by trailblaze-host). The two emission paths share the underlying codegen logic.

  2. Bundling. When the framework ships the binary, the TS distribution includes these generated types alongside the SDK. The per-trailmap client.d.ts emission already does this for tool bindings; framework- type generation is the same pattern extended.

  3. Migrating the hand-curated built-in-tools.ts. That file’s header acknowledges auto-generation as a follow-up; this devlog is the formalization. A future PR replaces the hand-curated content with generated output. Existing trailmap authors see no change in the API surface — same exports, same types — just no more drift between the Kotlin source and the TS bindings.

  4. Comment / docstring round-trip. Whether KDoc → TSDoc happens automatically depends on the generator. If it doesn’t, we either accept “TS types have no documentation” (worst case) or add KDoc extraction as a separate codegen step. Worth pinning before generator choice locks in.

  5. Export allowlist mechanism. The principle (selective opt-in export, not “everything @Serializable”) is decided; the mechanism isn’t. Three candidates:

  6. Annotation-based — a @ExportToTypeScript annotation (or reuse of @TrailblazeToolClass-style metadata) on the Kotlin class. Codegen processes only annotated types. Local to the type, easy to grep, reviewer sees the export commitment in the same PR as the type definition.

  7. Marker interface — types must extend a TypeScriptExported marker interface. Slightly more invasive (forces inheritance), but compile-time enforceable.
  8. Config-file-based — a list of class FQNs in a central config file (e.g. sdks/typescript/exports.yaml). Pros: third- party @Serializable types from libraries we don’t control can be exported. Cons: export status lives away from the type definition, reviewers might miss it in PR diffs.

Recommendation: annotation-based, with a config-file fallback reserved for the rare case where a third-party type needs to cross the boundary. The annotation lives at the type source, surfaces in the same PR diff as the type, and matches the precedent set by @TrailblazeToolClass. Pin this decision in the first codegen PR.

What changed

Positive:

  • Single source of truth for cross-language types. Kotlin schema is the spec; TS is derived.
  • Refactor safety — Kotlin renames surface as TS compile errors at every consumer, IDE-navigable.
  • Adding platform drivers / selector branches / structured tool results becomes a Kotlin-only change with automatic TS propagation.
  • SDK surface can grow more generously because the maintenance cost scales with the Kotlin source, not the TS hand-curation.
  • Reuses existing committed-and-CI-verified codegen infrastructure (docs/CLI.md, generated TOOLS.md, generated cli-scenarios.md). Per-trailmap client.d.ts is workspace-emitted, not repo-CI-verified — it consumes the framework-level generated types but isn’t itself in the repo’s diff loop.

Negative:

  • New build-time dependency: the framework’s own build now has a Kotlin → TS codegen step. If it breaks, framework engineers can’t write TS that compiles.
  • Generator-library choice is a long-lived commitment — switching later means regenerating every TS file in a way that may produce noisy diffs beyond the actual semantic change.
  • KDoc → TSDoc fidelity depends on the generator; some authoring conventions may need to adjust.
  • The existing hand-curated built-in-tools.ts carries a follow-up debt to migrate to generated output; until that lands, the framework runs with two patterns side by side.