The code generator

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

Upgrading from an earlier release

This page describes the generator the repository builds, the one @candid-core/cli 0.4.0 embeds, published on 2026-10-27 under the npm latest dist-tag with @candid-core/schema 0.5.0. The previous stable release, @candid-core/cli 0.3.0, names every use of a blob, record {} or variant {} after the first alias of that shape (marked below), and its envelopes name candid-core 0.1.0-beta.3 as their producer, where 0.4.0's name meant 0.1.0, the compiler crate published as candid-core before its rename; Migrating from 0.4.0 says what the upgrade means. The stable release before that, @candid-core/cli 0.2.0, differs in two more ways: it declares a $-prefixed local for each type too (type $Item = …), where 0.3.0 declares the type under its Candid name, and it types each Actor method as a bare call signature, where 0.3.0 adds its mode ($.WithMode<"query">), which needs 0.4.0. Migrating from 0.3.0 says what that upgrade means, and the package changelogs record it under 0.4.0 and 0.3.0. The stable release before those, @candid-core/cli 0.1.0, 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 shows what each break means, with before and after code, and each is recorded in the package changelogs under 0.3.0 and 0.2.0 and the betas before them. The beta dist-tag still names 0.3.0-beta.1 and 0.2.0-beta.1, which lack the changes recorded under 0.3.0 and 0.2.0 and every stable release since.

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
export 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";

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

export 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 };

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

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

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

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

export 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";

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

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

export 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") }));
export type Actor = {
  ping: (() => Promise<void>) & $.WithMode<"update">;
};
export { $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. Each method of Actor is its call signature intersected with $.WithMode<mode>, the same mode literal, which the schema runtime's ModeOf reads back: a call layer can refuse an update where it builds a read at compile time. The mark is one optional property no value has, so the signature, the method names and the exports are unchanged, and a hand-written Actor without it reads as mode unknown. The mark is new in @candid-core/cli 0.3.0 and needs @candid-core/schema 0.4.0, both published on 2026-10-10.

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
export type BlockIndex = bigint;
export type Byte = number;
export type Memo = bigint;
export type R = { a: bigint; b: bigint };
export type Raw = { one: number; raw: Uint8Array; maybe: Uint8Array | null; grid: Array<Uint8Array>; bytes: Uint8Array; plain: number };
export type Tokens = bigint;
export 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.

The same rule covers the three composite shapes with one fixed rendering: blob, record {} and variant {}. Since issue #268, new in @candid-core/cli 0.4.0, a declaration of one of them names only itself: type Subaccount = blob is emitted as export type Subaccount = Uint8Array, and every blob in the interface, the ones written opt Subaccount included, reads Uint8Array. Before, the alias whose name sorted first named every use, so beside a type Hash = blob the ICRC ledger’s memo : opt blob read Hash | null. The value types never changed; only the names in the text did.

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. */
export type Choice =
  | {
      /** The first arm. */
      tag: "A";
      value: bigint;
    }
  | { tag: "B" };
export type Actor = {
  /**
   * Same signature as same_a, different names.
   * @param y
   */
  same_b: ((y: bigint) => Promise<void>) & $.WithMode<"update">;
};

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";

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

export 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") }));
export type Actor = {
  ok: ((arg0: Good) => Promise<Good>) & $.WithMode<"query">;
};
export { $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: every declaration's value binds a $-prefixed local, and a declaration named Principal then keeps that local for its type too. 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 a Contract graph the emitter cannot walk refuses the whole module; everything else 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 the first two variants are not reachable through this API today — they guard the invariants the emitter relies on, and they match the documents schemaFromContract refuses whole. The third is reachable, from a validated Contract that no Candid source produced, and is the one refusal schemaFromContract does not share.

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.
UndeclaredCycle { reference } A cycle the module would render passes through no declared node; reference is a node on it. A cycle the module never renders — in a class actor's init args, or reachable only from an omitted declaration or method — does not refuse. A declared node renders as its name, which is what ends a recursive type, and a TypeScript type alias has no spelling for an anonymous cycle. No .did source produces one — a recursive Candid type needs a name — but Contract validation accepts every cycle, so a Contract read with Contract::from_json can hold one. schemaFromContract accepts the same graph, because it needs no names.

Generation uses constant call-stack depth in the Contract's nesting: the renderer keeps its pending work on an explicit stack, so a Contract of any depth the validator accepts generates on a small thread (issue #218).

All three 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.

Known limitation (issue #229). The table is keyed by (container, id), and a container is a node of the canonical graph, not a declaration. Declarations whose records canonicalize to one node therefore share their field names, and from_source_info gives that node one name per label id. With type A = record { c : record { d : text } }; type B = record { x : record { 100 : text } }; the label hash of d is 100, so both inner records are one node, and the generated B names that field d rather than _100_. The wire bytes are the same either way. A named spelling beats a numeric one, in any declaration and in any order. Two different names for one id need a 32-bit hash collision; then the name the sidecar lists last wins (it sorts by source file, then declaration name, then path, with the service's inline types after every declaration), not the first one written. Keeping names per declaration would change the Contract format, so it is deferred. The same rule applies to schemaFromContract.

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 = "../Meant/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/B3Pay/Meant", tag = "v0.1.0", 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", 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 gen command, candid-core-cli gen <service.did>... [-o <dir>], writes <stem>.ts alongside <stem>.envelope.json. That package is published on npm, so npx @candid-core/[email protected] gen ./service.did runs the generator this page describes with no Rust toolchain. The previous stable release, 0.3.0, names every use of a blob, record {} or variant {} after the first alias of that shape, which 0.4.0 no longer does (see the note at the top). The release before it, 0.2.0, writes the $-prefixed type names and bare method signatures that 0.3.0 replaced, and 0.1.0 predates both.

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#