The code generator

candid-core-ts: how a Contract graph becomes a TypeScript module that tsc itself proves correct.

Published as a beta

This page describes the generator the repository builds, the one @candid-core/cli 0.2.0-beta.1 embeds, published under the npm beta dist-tag. latest, @candid-core/cli 0.1.0, still emits the old layout: bare module-scope bindings, an object-shaped type for principals, a refusal of the whole interface for one declaration it cannot represent, and no JSDoc. Migrating from 0.2.0 lists every difference with before and after code, and each is recorded in the package changelogs under 0.3.0-beta.1 and 0.2.0-beta.1.

candid-core-ts is a Rust library that turns a validated Contract into one TypeScript module. A Contract is the canonical, arena-based type graph that the root crate compiles a .did file into. For every declaration in that graph, the generated module carries two things: a type alias a developer reads and uses, and a runtime schema object built from the c combinators. The schema is annotated with the alias, and because Schema<in out T> is invariant, tsc compiling that file is a proof that the two agree in both directions.

It is a one-way consumer. The crate depends on candid-core with default-features = false, which makes the boundary structural: a generator needs no Candid parser, no filesystem capability and no host-value ABI, so nothing here can influence the Contract model, its canonical bytes, or its identities.

What one declaration becomes#

Every Candid declaration produces exactly three lines: the alias, the builder, and the export that gives both their Candid name.

tscrates/candid-core-ts/tests/goldens/collections.ts
type $Item = { id: number; label: string; payload: Uint8Array };
const $Item: $.Schema<$Item> = $.c.rec(() => $.c.record({ id: $.c.nat32, label: $.c.text, payload: $.c.blob() }));
export { $Item as Item };

The emitter renders each declaration twice, once for the alias and once for the builder, through one shared traversal whose guards all run before the syntax split, so the alias and the builder can never disagree about what is representable. The c.rec(() => …) thunk is doing two jobs: declarations are emitted in canonical (name-sorted) order rather than dependency order, so a builder can legitimately reference a name that appears further down the file, and the same laziness is what terminates recursive types.

Every binding the module declares is a local whose name starts with $: the schema runtime is the namespace $, and the declaration Item is the local $Item, exported as Item. A Candid name cannot contain $, so no declaration can collide with the runtime import, with the global types the lowerings use (Array, Record, Uint8Array, Promise), or with another declaration. A declaration named c, Array, delete or string therefore generates like any other, and you import it by that name (import { delete as del } from "./service"); one named default becomes the module's default export. Consumers only ever see the export names, which are the Candid names.

The annotation $.Schema<$Item> is the load-bearing part. Schema is declared in out T, meaning invariant, so Schema<A> is assignable to Schema<B> only when A and B are the same type in both directions. A mapping mistake in either the Rust emitter or the TypeScript runtime therefore turns the type-check red instead of shipping. The full per-type table is on the Candid to TypeScript mapping page.

A fixture and its golden#

These are both real files, verbatim. On the left is a fixture under crates/candid-core-ts/tests/fixtures/; on the right is the checked-in output the test suite requires the generator to produce byte for byte.

candidcrates/candid-core-ts/tests/fixtures/collections.did
type Unit = record {};
type Pair = record { nat; text };
type Item = record { id : nat32; label : text; payload : blob };
type MaybeItem = opt Item;
type Note = opt text;
type Items = vec Item;
type Grid = vec vec nat8;
tscrates/candid-core-ts/tests/goldens/collections.ts
// Generated by candid-core-ts from a candid-core Contract. Do not edit.
import * as $ from "@candid-core/schema";

type $Grid = Array<Uint8Array>;
const $Grid: $.Schema<$Grid> = $.c.rec(() => $.c.vec($.c.blob()));
export { $Grid as Grid };

type $Item = { id: number; label: string; payload: Uint8Array };
const $Item: $.Schema<$Item> = $.c.rec(() => $.c.record({ id: $.c.nat32, label: $.c.text, payload: $.c.blob() }));
export { $Item as Item };

type $Items = Array<$Item>;
const $Items: $.Schema<$Items> = $.c.rec(() => $.c.vec($Item));
export { $Items as Items };

type $MaybeItem = $Item | null;
const $MaybeItem: $.Schema<$MaybeItem> = $.c.rec(() => $.c.opt($Item));
export { $MaybeItem as MaybeItem };

type $Note = string | null;
const $Note: $.Schema<$Note> = $.c.rec(() => $.c.opt($.c.text));
export { $Note as Note };

type $Pair = [bigint, string];
const $Pair: $.Schema<$Pair> = $.c.rec(() => $.c.tuple([$.c.nat, $.c.text]));
export { $Pair as Pair };

type $Unit = Record<string, never>;
const $Unit: $.Schema<$Unit> = $.c.rec(() => $.c.unit());
export { $Unit as Unit };

Several mapping decisions are visible in those thirty lines. blob and the inner vec nat8 of Grid both become Uint8Array. The tuple-syntax record becomes a TypeScript tuple, because Candid lowers record { nat; text } to a record whose labels are the sequential ids 0..n. opt becomes | null. Items references the declared Item by name instead of re-expanding its structure. The empty record becomes Record<string, never>, not {}, because {} in TypeScript means "anything non-nullish" while an empty Candid record is a unit value.

Note also what the output order is not: declarations come out sorted by name and record fields sorted by Candid label id, because that is the Contract's canonical order. That is what makes output byte-identical across runs, but do not expect a generated file to read in the order you wrote your interface.

The actor surface#

When a Contract has an actor — the service : { … } block at the bottom of a .did — the module additionally emits the service schema and the call interface. Here is the deferred fixture, which also covers the two reference types:

candidcrates/candid-core-ts/tests/fixtures/deferred.did
type Callback = func (nat) -> (text) query;
type Registry = service { register : (text) -> (nat) };
type Kept = record { value : nat };
service : { ping : () -> () }
tscrates/candid-core-ts/tests/goldens/deferred.ts
// Generated by candid-core-ts from a candid-core Contract. Do not edit.
import * as $ from "@candid-core/schema";

type $Callback = { principal: $.Principal; method: string };
const $Callback: $.Schema<$Callback> = $.c.rec(() => $.c.func([$.c.nat], [$.c.text], "query"));
export { $Callback as Callback };

type $Kept = { value: bigint };
const $Kept: $.Schema<$Kept> = $.c.rec(() => $.c.record({ value: $.c.nat }));
export { $Kept as Kept };

type $Registry = $.Principal;
const $Registry: $.Schema<$Registry> = $.c.rec(() => $.c.service({ register: $.c.func([$.c.text], [$.c.nat], "update") }));
export { $Registry as Registry };

const $actor: $.Schema<$.Principal> = $.c.rec(() => $.c.service({ ping: $.c.func([], [], "update") }));
type $Actor = {
  ping: () => Promise<void>;
};
export { $actor as actor, type $Actor as Actor };

A func value is inert reference data, so its type is { principal, method } while the signature lives in the c.func builder. A service value is the principal of a running service, so its type is Principal while the method table lives in c.service. The unannotated register and ping methods render their mode as the explicit "update". The exports actor (the service schema) and Actor (a type) are the pair a call layer needs: the service schema to encode and decode with, and the typed interface it cannot re-derive from the schema's type.

A declared primitive names only itself#

The Contract arena stores one node per structure, so every nat64 in an interface is the same node whichever declaration spelled it. Composite nodes render by the name of their first declaration; a primitive never does. Every use renders structurally and a declaration of it is still emitted and exported as itself:

candidcrates/candid-core-ts/tests/fixtures/fidelity.did
type Memo = nat64;
type R = record { a : nat64; b : Memo };

type Byte = nat8;
type Raw = record {
  raw : blob;
  bytes : vec Byte;
  grid : vec vec Byte;
  maybe : opt vec Byte;
  one : Byte;
  plain : nat8;
};

type Tokens = nat;
type BlockIndex = nat;
type Account = record { owner : principal; subaccount : opt blob };
type TransferArg = record { to : Account; amount : Tokens; fee : opt Tokens };
tscrates/candid-core-ts/tests/goldens/fidelity.ts
type $BlockIndex = bigint;
type $Byte = number;
type $Memo = bigint;
type $R = { a: bigint; b: bigint };
type $Raw = { one: number; raw: Uint8Array; maybe: Uint8Array | null; grid: Array<Uint8Array>; bytes: Uint8Array; plain: number };
type $Tokens = bigint;
type $TransferArg = { to: $Account; fee: bigint | null; amount: bigint };

Before issue #191 one declaration renamed every use of its primitive: Tokens and BlockIndex collapsed to whichever came first, and the single type Byte = nat8 turned every blob in the interface into number[]. A blob — any vec of a nat8, however named — is now always Uint8Array, in the generated module and in schemaFromContract alike. The price is the source spelling: amount : Tokens reads amount: bigint, the same type.

Doc comments become JSDoc#

The .did’s doc comments and argument names travel in the provenance sidecar, and the generator writes them as JSDoc: above each exported type and const, on record properties and variant arms, and on the methods of Actor, with a @param for each argument the .did named. The Actor method’s parameters take those names.

candidcrates/candid-core-ts/tests/fixtures/docs.did
/// Only the arm is documented.
type Choice = variant {
  /// The first arm.
  A : nat;
  B;
};
service : {
  /// Same signature as same_a, different names.
  same_b : (y : nat) -> ();
}
tscrates/candid-core-ts/tests/goldens/docs.ts
/** Only the arm is documented. */
type $Choice =
  | {
      /** The first arm. */
      tag: "A";
      value: bigint;
    }
  | { tag: "B" };
type $Actor = {
  /**
   * Same signature as same_a, different names.
   * @param y
   */
  same_b: (y: bigint) => Promise<void>;
};

Candid’s doc comment is the line comment (///, or plain // lines) directly above the item; block comments are not docs. Doc text is neutralised rather than interpreted: */ is written *\/, an @ that could start a tag or an inline link is written \@, and a code fence is escaped, so a comment can neither end early nor forge or swallow a @param. A record or union with any documented member spans several lines; one with none keeps its single-line form, and tuple elements, which have no property to carry a doc, get none. When the arena has de-duplicated two spellings into one node, the docs come from the declaration whose structure is being emitted, and occurrences that disagree are dropped, not merged.

What cannot be represented is omitted#

A declaration the module cannot represent costs only itself and what depends on it, never the whole interface. Four causes make one unrepresentable: a record field or variant arm named like the _N_ id rendering (reserved_field_name — as a schema key it would read back as numeric id N and encode the wrong wire id); a variant arm whose payload is a declared opt of an uninhabited type (ambiguous_variant_arm — its static type is indistinguishable from an alias of null, which marks a bare tag); a declaration named actor or Actor, the module's own export names (reserved_export_name); and, from a Contract document only, a name that is not identifier-shaped (invalid_declaration_name). The declaration is left out together with every declaration and actor method that references it, through any type edge — nested func and service types included — up to the containing declaration (references_omitted). A few lines of the omissions fixture, and the whole module they generate:

candid
type Good = record { a : nat };
type Bad = record { _0_ : nat; ok : text };
type Registry = record { svc : service { f : (Bad) -> (); g : (Good) -> () } };
type Directory = record { svc : service { g : (Good) -> () } };
service : {
  ok : (Good) -> (Good) query;
  bad : (Bad) -> ();
}
ts
// Generated by candid-core-ts from a candid-core Contract. Do not edit.
// Omitted: type Bad (reserved_field_name)
// Omitted: type Registry (references_omitted via Bad)
// Omitted: method bad (references_omitted via Bad)
import * as $ from "@candid-core/schema";

type $Directory = { svc: $.Principal };
const $Directory: $.Schema<$Directory> = $.c.rec(() => $.c.record({ svc: $.c.service({ g: $.c.func([$Good], [], "update") }) }));
export { $Directory as Directory };

type $Good = { a: bigint };
const $Good: $.Schema<$Good> = $.c.rec(() => $.c.record({ a: $.c.nat }));
export { $Good as Good };

const $actor: $.Schema<$.Principal> = $.c.rec(() => $.c.service({ ok: $.c.func([$Good], [$Good], "query") }));
type $Actor = {
  ok: (arg0: $Good) => Promise<$Good>;
};
export { $actor as actor, type $Actor as Actor };

Registry is omitted whole rather than losing f: a service value is encoded with its full method table, so dropping a method from a service type in value position would change the wire type a peer sees. Only the actor's own service drops individual methods, from both the actor schema and the Actor type, because calling a method never encodes the actor's service type. The actor itself is never omitted. The header lists every omission, declarations first and then methods, each by name; a module that omits nothing carries no such line and is byte-identical to what the generator emitted before. Two tests hold the closure: nothing emitted mentions an omitted declaration's $ local, and everything emitted is byte-identical to the module generated from the same source with the omitted declarations and methods deleted. schemaFromContract leaves out the same entries for the same reasons, and the golden crosscheck holds the two lists equal.

The public API#

The crate exposes one function, its result and omission types, two configuration types and one error enum. That is the whole surface.

generate_module
rustcrates/candid-core-ts/src/lib.rs
pub fn generate_module(
    contract: &Contract,
    names: &TsNames,
    options: &TsOptions,
) -> Result<GeneratedModule, TsGenError>

Takes a validated Contract graph, a table of field label texts and options, and returns the complete text of one TypeScript module with the list of what it left out. Generation is deterministic: emission follows the Contract's canonical declaration and field order and depends on nothing in the environment, so the same Contract always produces identical output and an identical omitted list. A test asserts that generating twice, and generating from a Contract round-tripped through its JSON serialization, all give the same result.

GeneratedModule, Omission
rustcrates/candid-core-ts/src/lib.rs
pub struct GeneratedModule {
    pub module: String,
    pub omitted: Vec<Omission>,
}

pub struct Omission {
    pub kind: OmissionKind,     // Declaration | Method
    pub name: String,
    pub reason: OmissionReason,
    pub via: Option<String>,    // Some only for ReferencesOmitted
}

pub enum OmissionReason {
    ReservedFieldName,          // "reserved_field_name"
    AmbiguousVariantArm,        // "ambiguous_variant_arm"
    ReservedExportName,         // "reserved_export_name"
    InvalidDeclarationName,     // "invalid_declaration_name"
    ReferencesOmitted,          // "references_omitted"
}

omitted lists declarations first, then actor methods, each sorted by name. OmissionKind::code and OmissionReason::code give the stable snake_case codes, which are what @candid-core/cli's ModuleSuccess.omitted and schemaFromContract's omitted carry as kind and reason; the reasons are a closed set. via names the omitted declaration a ReferencesOmitted entry references. Omission's Display is the header line without its // Omitted: prefix.

TsNames
rustcrates/candid-core-ts/src/lib.rs
pub struct TsNames { /* private */ }

impl TsNames {
    pub fn new() -> Self;
    pub fn insert(&mut self, container: TypeRef, id: u32, label: impl Into<String>);
    pub fn from_pairs<I, S>(pairs: I) -> Self
    where
        I: IntoIterator<Item = (TypeRef, u32, S)>,
        S: Into<String>;

    #[cfg(feature = "compiler")]
    pub fn from_source_info(source_info: &candid_core::SourceInfo) -> Self;
}

The caller-supplied table of record field and variant arm names, keyed by (container type node, Candid label id). TypeRef is an index into the Contract's type arena. Derives Debug, Clone, Default, PartialEq and Eq.

TsOptions
rustcrates/candid-core-ts/src/lib.rs
pub struct TsOptions {
    pub principal_import: String,
}

// Default:
TsOptions { principal_import: "@candid-core/schema".to_string() }

One option: the module specifier Principal comes from. It defaults to "@candid-core/schema" — the package the module already imports as the namespace $, so the type is written $.Principal and a generated file has exactly one import. Set to any other module, it adds import type { Principal } from "…", emitted only when the Contract actually uses a principal (a contract with an actor always does, since the actor schema is typed with it), and always as import type, so it never implies a runtime dependency. No declaration can collide with that bare import, because every declaration binds a $-prefixed local. The specifier is JSON-escaped into the output rather than interpolated raw.

Principal is the schema runtime's principal type: canonical principal text as a branded string — what the codec decodes, and the only principal value it encodes. It is not the SDK class, which the runtime never constructs or accepts. Point principal_import somewhere else only if that module re-exports the runtime's own Principal: the brand makes the type nominal, so the generated Schema<…> annotations compile against no other type.

TsGenError#

Only an invalid Contract graph refuses the whole module; everything a valid Contract can hold either generates or is omitted (above), and no error path emits placeholder TypeScript. Every public Contract is validated when it is built or loaded, so neither variant is reachable through this API today — both guard the invariants the emitter relies on, and they match the documents schemaFromContract refuses whole.

VariantRaised when
UnsupportedConstruct { declaration, kind } A class type named by a declaration or nested inside a value type — candid-core's class_not_actor_root and class_not_first_class_type, shapes no Candid source can produce.
DanglingTypeRef { reference } A type reference points outside the Contract arena.

Both implement Display and std::error::Error. The refusals the generator made before issue #189 — ReservedFieldName, AmbiguousVariantArm, ReservedDeclarationName and InvalidDeclarationName — are the omission reasons now, and those variants are gone.

Field names are an input, not graph data#

Candid identifies a record field or variant arm by a numeric label id: either written literally, as in 5 : nat, or derived as a hash of the name. A semantic Contract stores only that id, because the id is the authoritative identity and the spelling is provenance — held outside the identity domain, in the SourceInfo sidecar a compilation produces alongside the graph.

So the generator takes names as caller-supplied data. Generate with TsNames::new() and every field renders by the ecosystem's _id_ convention: record { id : nat32 } comes out as { _23515_: number }, because 23515 is the Candid hash of id. That is legal, deterministic and unreadable, and it is exactly what you get from a Contract compiled with --no-source-info, which discards the spellings.

There are three ways to fill the table. insert records one label at a time. from_pairs builds one from (container, id, label) triples, which is the shape a serialized name table takes when it travels alongside a Contract JSON document. And from_source_info, behind this crate's compiler feature, bridges straight from a compilation's provenance sidecar — the normal route when you compiled the .did yourself. Only labels whose provenance kind is Named are recorded: a numeric Candid label carries no name and must keep its _N_ rendering.

Calling the generator#

The complete path, lifted from the golden test harness. Compile .did text to a Compilation, bridge its provenance sidecar into a name table, then generate.

rustcrates/candid-core-ts/tests/golden.rs
use std::path::PathBuf;

use candid_core::compile_did;
use candid_core_ts::{generate_module, TsGenError, TsNames, TsOptions};

fn generate_fixture(name: &str) -> String {
    let root = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests");
    let source = std::fs::read_to_string(root.join("fixtures").join(format!("{name}.did")))
        .expect("fixture must be readable");
    let compilation = compile_did(&source).expect("fixture must compile");
    let names = TsNames::from_source_info(
        compilation
            .source_info()
            .expect("compile_did retains provenance by default"),
    );
    generate_module(compilation.contract(), &names, &TsOptions::default())
        .expect("fixture must generate")
}

compile_did needs the root crate's compiler feature, and TsNames::from_source_info needs this crate's. The featureless library takes names as plain data instead, and that configuration is compiled on its own in CI (cargo check -p candid-core-ts --locked), so the boundary is held by the build rather than by convention.

The golden discipline#

Fifteen fixtures live under tests/fixtures/: primitives, collections, variants, recursion, quoting, deferred, proto, ledger, empties, arms, options, shadowing, fidelity, docs and omissions. Each must generate output byte-identical to its checked-in .ts golden, or the test fails with a diff; the omissions fixture's omitted list is a golden too. The ledger fixture is separately asserted to be byte-for-byte the repository's benchmark corpus ledger interface, so the generator is exercised on a real interface rather than a toy.

When a mapping decision genuinely changes, you regenerate:

bash
UPDATE_GOLDENS=1 cargo test -p candid-core-ts --features compiler

That overwrites the checked-in files. The point is that regenerating is a reviewed act, not a test repair: the goldens are where the per-type mapping decisions actually live, so a diff there is a change to the public mapping and gets read as one.

The goldens are then compiled a second time, by TypeScript itself. ts/tsconfig.json sets "strict": true and lists "../tests/goldens/**/*.ts" in its include, with @candid-core/schema mapped to the local ./schema.ts, so npm ci && npx tsc --noEmit in ts/ type-checks every generated file against the real runtime under the exact TypeScript version pinned in ts/package-lock.json. Because of the invariance described above, that run is a proof and not a lint. The mapping is proven by a compiler, not asserted by a comment.

A regression usually shows up twice

A bad regeneration fails the byte-comparison for anyone who did not regenerate, and fails the type-check for everyone. That is deliberate: the two gates check different things — that the output is what was reviewed, and that the output is internally consistent.

publish = false, and how to depend on it anyway#

The crate is not on crates.io and cannot be pushed there. publish = false is set explicitly, and it stands until the registry name is decided deliberately: crate names on crates.io are as permanent as versions, and candid-core-ts is a working name rather than that decision. Do not expect a cargo add line for it.

Everything else about it works normally, because publish = false only blocks cargo publish. Depend on it by path from a checkout, or straight from git:

toml
[dependencies]
# From a local checkout of the repository
candid-core-ts = { path = "../candid-core/crates/candid-core-ts", features = ["compiler"] }

# Or from git — pin a tag or rev, so your generated output cannot move under you
candid-core-ts = { git = "https://github.com/b3hr4d/candid-core", tag = "v0.1.0-beta.3", features = ["compiler"] }

The crate itself is version 0.0.0, edition 2021, MSRV 1.78, Apache-2.0. It has exactly one feature, compiler = ["candid-core/compiler"], and exactly one runtime dependency: candid-core = { version = "=0.1.0-beta.3", path = "../..", default-features = false }. The exact version requirement is deliberate — this is a pre-1.0 consumer of a format that is not a stable v1, so it pins rather than floats.

What emits TypeScript, and what does not

generate_module is a library call, and the native candid-core binary never emits TypeScript: it accepts compile <path> [--no-source-info | --envelope] and validate <path>, and prints JSON.

The command that does emit a module is @candid-core/cli, which is this generator compiled to WebAssembly. Its one subcommand is candid-core-cli gen <service.did> [-o <dir>], and it writes <stem>.ts alongside <stem>.envelope.json. That package is published on npm, so npx @candid-core/cli@0.2.0-beta.1 gen ./service.did runs the generator this page describes with no Rust toolchain; latest, 0.1.0, predates it.

The route that needs no build step at all is @candid-core/schema's schemaFromContract, which builds the same schemas at runtime from a compiled Contract document — see schemas at runtime. What that route cannot give you is the static Actor interface type, because that is generated TypeScript source.

Next#