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
@Serializablebecause 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.tsis hand-curated with an explicit comment at the top acknowledging auto-generation as a tracked follow-up.- Per-trailmap
client.d.tsfiles are already generated bytrailblaze-hostfrom 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:
- The selector grammar —
NodeSelectorand every sealed-class branch (AndroidAccessibility,IosMaestro,Web, etc.) plus their fields. - The matched-element descriptor — the shape returned by query tools
like
findMatches. - Tool input args — derived from the
@Serializabledata classes that Kotlin tool implementations already define. - Tool output results — when tools start returning structured values
via the deferred
structuredContentwire shape. - Error envelopes and other framework-level structured types that need to be visible from TS.
- Anything else where Kotlin needs an
@Serializabletype AND TypeScript callers consume it.
This does not apply to:
- Pure TS code with no Kotlin counterpart — the
trailblaze.tool({...})wrapper itself, theclient.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. - 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.
@SerializableKotlin 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:
-
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.
-
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:
NodeSelectorand 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@TrailblazeToolClassannotations, 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¶
-
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.
-
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.
-
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.tsregeneration, and the downstream IDE autocomplete all follow automatically. -
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.
-
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¶
-
Generator library choice. Three viable candidates:
-
Hand-rolled via Kotlin reflection on
@Serializabletypes. Works but verbose; full control over output style. kxs-ts-genor similar drop-in libraries. Existing tools that emit TypeScript from kotlinx.serialization-annotated Kotlin. Less code to maintain, less control over output.- Kotlin → JSON Schema → TypeScript via
json-schema-to-typescriptorquicktype. 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-generatorruns the opposite direction (TS source → JSON Schema) and is the right tool for reading scripted-tool input/output types inScriptedToolDefinitionAnalyzer, 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.
-
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 bytrailblaze-host). The two emission paths share the underlying codegen logic. -
Bundling. When the framework ships the binary, the TS distribution includes these generated types alongside the SDK. The per-trailmap
client.d.tsemission already does this for tool bindings; framework- type generation is the same pattern extended. -
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. -
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.
-
Export allowlist mechanism. The principle (selective opt-in export, not “everything
@Serializable”) is decided; the mechanism isn’t. Three candidates: -
Annotation-based — a
@ExportToTypeScriptannotation (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. - Marker interface — types must extend a
TypeScriptExportedmarker interface. Slightly more invasive (forces inheritance), but compile-time enforceable. - Config-file-based — a list of class FQNs in a central config
file (e.g.
sdks/typescript/exports.yaml). Pros: third- party@Serializabletypes 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-trailmapclient.d.tsis 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.tscarries a follow-up debt to migrate to generated output; until that lands, the framework runs with two patterns side by side.