Candid to TypeScript mapping

The complete type mapping table, including boxed options and the declarations that are left out.

Published as a beta

This page describes the surface the repository builds, published as @candid-core/schema 0.3.0-beta.1 and @candid-core/cli 0.2.0-beta.1 under the npm beta dist-tag. latest, 0.2.0 and 0.1.0, still has the old one: a principal is an object with toText(), a collapsing opt is refused, a type Byte = nat8 turns every blob into number[], and one declaration the generator cannot represent refuses the whole interface. 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.

Every Candid type has exactly one TypeScript rendering in this project, and every rendering has a matching runtime schema — an inert data object built from the c combinators that validation and the binary codec both walk. This page is the complete table of both, plus the handful of declarations that are left out rather than represented wrongly.

Two independent implementations apply this mapping and must agree. The Rust generator candid-core-ts emits a TypeScript module ahead of time; the npm runtime's schemaFromContract builds the same schemas at load time from a canonical Contract JSON document. A Contract is candid-core's validated model of a .did file: a flat array of type nodes addressed by index, a list of named declarations pointing into it, and an optional actor. Both sides read that graph, so the rows below describe both — see The code generator and Schemas from a Contract document for the two entry points.

Pre-1.0

The Rust crate is 0.1.0-beta.3 and the Contract format is not a stable v1. The mapping below is where the reviewed decisions live — the generator's golden files pin it byte-for-byte — but a pre-1.0 release may still change it.

How to read the tables#

  • Contract node is the node kind as it appears in the Contract JSON document. There are eight kinds and no more: primitive, opt, vec, record, variant, func, service, class. There is no blob node, no tuple node and no result node — those are Candid conveniences the graph never encodes, and recognising them is what several rows below do.
  • TypeScript type is the right-hand side of the emitted type $X = … alias.
  • Builder is the expression emitted for const $X: $.Schema<$X> = $.c.rec(() => …). Every declaration is wrapped in c.rec: declarations are emitted in canonical name order rather than dependency order, and the lazy thunk is what makes a forward reference safe at module initialisation.
  • Layout. Every binding in a generated module is a $-prefixed local: the schema runtime is imported as the namespace $, and a declaration X is the local $X, alias and builder alike, exported under its Candid name by export { $X as X }. The tables below write builders as c.… and references by their Candid name for readability; in the module they read $.c.…, $X and $.Principal. Because a Candid name cannot contain $, no declaration name — c, Array, Promise, delete — can shadow the import or a global type the lowerings use.
  • Schema<in out T> is invariant, so annotating each builder with the alias just emitted makes tsc prove the two are the same type in both directions. That type-check is the equality gate, not a convention.

The eighteen primitives#

All eighteen are present in the Contract as {"kind":"primitive","primitive":"<name>"} with the name in snake_case. The width split is the point: nat, int, nat64 and int64 are bigint because a JavaScript number silently corrupts integers past 2^53, while every width that fits a double exactly stays a number.

CandidContract nodeTypeScriptBuilderNotes
nullprimitive: "null"nullc.nullThe single-valued type. Literal null only; undefined is not a Candid value.
boolprimitive: "bool"booleanc.bool
natprimitive: "nat"bigintc.natUnbounded. A number is rejected, never coerced.
intprimitive: "int"bigintc.intUnbounded, signed.
nat8primitive: "nat8"numberc.nat8Integral, [0, 255].
nat16primitive: "nat16"numberc.nat16Integral, [0, 65_535].
nat32primitive: "nat32"numberc.nat32Integral, [0, 4_294_967_295] — the widest unsigned width a double holds exactly.
nat64primitive: "nat64"bigintc.nat64[0, 2^64-1].
int8primitive: "int8"numberc.int8Integral, [-128, 127].
int16primitive: "int16"numberc.int16Integral, [-32_768, 32_767].
int32primitive: "int32"numberc.int32Integral, [-2_147_483_648, 2_147_483_647].
int64primitive: "int64"bigintc.int64[-2^63, 2^63-1].
float32primitive: "float32"numberc.float32Validation accepts any number, NaN and infinities included. Encoding is exact-or-refuse: a value that is not Math.fround-exact fails with unrepresentable_float32 rather than rounding.
float64primitive: "float64"numberc.float64The same double, so every number encodes exactly.
textprimitive: "text"stringc.textCandid text is Unicode scalar values, so encoding refuses a lone surrogate with invalid_text.
reservedprimitive: "reserved"unknownc.reservedAccepts anything, asserts nothing — so a consumer must narrow before use.
emptyprimitive: "empty"neverc.emptyUninhabited on both sides. Every value fails with uninhabited_type.
principalprimitive: "principal"Principalc.principalCanonical principal text as a branded string, not the SDK class. Decoding returns the text; validation and encoding accept exactly canonical text and refuse anything else, an SDK Principal instance included, with invalid_type. Convert with principal(value), which refuses non-canonical text rather than repairing it.

Principal comes from TsOptions::principal_import, defaulting to "@candid-core/schema" — the package the generated file already imports as the namespace $, so the type is written $.Principal and no second import is emitted. Any other module is imported import type and only when the contract actually uses a principal, so it never implies a runtime dependency.

Composite and reference types#

CandidContract nodeTypeScriptBuilderNotes
opt T {"kind":"opt","inner":N} T | null c.opt(T) Absence is exactly null, and on a record the property stays present. When T admits null (opt opt, opt null, opt reserved) the present value is boxed as { some: T }; see below.
vec T {"kind":"vec","inner":N} Array<T> c.vec(T) Validation wants a real array; an array-like or bare iterable fails with invalid_type.
blob, every vec nat8 {"kind":"vec","inner":<nat8>} Uint8Array c.blob() Always, whatever the element is called: type Byte = nat8; type Bytes = vec Byte; is still blob, so Bytes is Uint8Array / c.blob(). A declaration of a primitive names only itself, so declaring Byte never changes another declaration’s blobs (before issue #191 it turned every one into number[]).
record { a : T; b : U } {"kind":"record","fields":[…]} { a: T; b: U } c.record({ a: T, b: U }) Field order is the Contract's canonical order — ascending Candid label id, not source order.
record {} {"kind":"record","fields":[]} Record<string, never> c.unit() {} means "anything non-nullish" in TypeScript; an empty Candid record is a unit value, and this is its honest type.
record { nat; text } (tuple syntax) record whose field ids are exactly 0..n [bigint, string] c.tuple([c.nat, c.text]) Candid lowers tuple syntax to sequential numeric labels; a record with exactly that id sequence is rendered as a TypeScript tuple, with no field keys.
variant { ok; busy : nat32 } {"kind":"variant","fields":[…]} { tag: "ok" } | { tag: "busy"; value: number } c.variant({ ok: c.null, busy: c.nat32 }) A discriminated union. value is omitted for a null payload, because Candid's bare ok and ok : null are the same arm.
variant {} {"kind":"variant","fields":[]} never c.variant({}) A variant with no arms is uninhabited.
func (nat) -> (text) query {"kind":"func","args":[…],"results":[…],"mode":"query"} { principal: Principal; method: string } c.func([c.nat], [c.text], "query") A func value is an address, not a callable. The signature lives in the builder. Modes are "update", "query", "composite_query", "oneway"; an unannotated Candid method is explicitly "update" in the Contract.
service { register : (text) -> (nat) } {"kind":"service","methods":[…]} Principal c.service({ register: c.func([c.text], [c.nat], "update") }) A service value is the principal of a running service. The method table lives in the builder.
service : (nat) -> { … } (class) {"kind":"class","init":[…],"service":N} — (unwrapped at actor position) the wrapped service's builder A class denotes its running service. At actor position it unwraps and the module gains a note that init args are install-time metadata not exposed. A class reached through a value edge is an invalid Contract and refuses the whole module with UnsupportedConstruct.
a declared name, including recursively any node that has a declaration the declaration's first name the declaration's first name What terminates cycles: every Candid cycle passes through a declaration, and a declared node renders as its name instead of being expanded. When two declarations name one node, the later emits as a plain alias of the first.

The actor surface#

When the Contract has an actor — the service : { … } at the bottom of a .did — the module emits two more exports, bound as the locals $actor and $Actor: actor, the service schema typed $.Schema<$.Principal>, and the type Actor, one async method per service method. Parameters are positional and take the .did’s argument names where it wrote a usable one ((to : Account, amount : Tokens) is (to: $Account, amount: bigint), with a @param tag each), and are named arg0, arg1, … for an unnamed argument, a reserved word or a collision; the reply convention is zero results ⇒ Promise<void>, one ⇒ Promise<T>, several ⇒ Promise<[A, B]>. Calling the canister is a call layer's job, built on that pair: the schema to encode arguments and decode replies with, and the interface to type the client with.

Field names are an input#

A Contract stores only the authoritative numeric Candid label id, never the spelling: the id is either written literally (5 : nat) or derived as h = 0; for each UTF-8 byte b: h = (h * 223 + b) mod 2^32. The generator therefore takes names as caller-supplied data through TsNames. With no table, every record field and variant arm renders by the ecosystem's _N_ convention — underscore, the decimal id, underscore — so record { id : nat32 } becomes { _23515_: number }. Service method names are the exception: they are kept in the graph, because you cannot invoke a method by hash.

A whole file, before and after#

These are the generator's checked-in fixtures and goldens. The first golden is the whole file; the two after it drop the generated header and import lines to keep the comparison short. Note that output is sorted by declaration name, not source order.

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

Six rules in one file: blob and the inner vec nat8 of Grid become Uint8Array; the tuple-syntax record becomes a TypeScript tuple; opt becomes | null; Items references the declared Item by name rather than re-expanding it; the empty record becomes Record<string, never>.

Variants, including a self-referential one:

candidcrates/candid-core-ts/tests/fixtures/variants.did
type Status = variant { ok; busy : nat32; failed : text };
type Numbered = variant { 0 : null; 5 : nat };
type Tree = variant { leaf : nat; node : record { left : Tree; right : Tree } };
tscrates/candid-core-ts/tests/goldens/variants.ts
type $Numbered = { tag: "_0_" } | { tag: "_5_"; value: bigint };
const $Numbered: $.Schema<$Numbered> = $.c.rec(() => $.c.variant({ _0_: $.c.null, _5_: $.c.nat }));
export { $Numbered as Numbered };

type $Status = { tag: "ok" } | { tag: "busy"; value: number } | { tag: "failed"; value: string };
const $Status: $.Schema<$Status> = $.c.rec(() => $.c.variant({ ok: $.c.null, busy: $.c.nat32, failed: $.c.text }));
export { $Status as Status };

type $Tree = { tag: "leaf"; value: bigint } | { tag: "node"; value: { left: $Tree; right: $Tree } };
const $Tree: $.Schema<$Tree> = $.c.rec(() => $.c.variant({ leaf: $.c.nat, node: $.c.record({ left: $Tree, right: $Tree }) }));
export { $Tree as Tree };

ok has a null payload, so its arm is the bare { tag: "ok" }. Numbered shows the _N_ convention for explicitly numeric Candid labels, which carry no name at all. Tree is self-referential in both the alias and the builder, which is safe because c.rec defers the thunk.

Reference types and the actor surface:

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

Options whose inner type admits null#

T | null is the honest reading of opt T only when T itself can never be null in TypeScript. Three inner shapes break that: another opt, null, and reserved. For exactly those, the present value is boxed, so Candid's None and Some(None) stay two different values: { some: T } | null. Every other opt keeps T | null. The test is on the inner node, not its spelling, so an opt reached through a declared alias, or through recursion, boxes just the same.

SourceTypeScriptWhy the box is needed
type DoubleOpt = opt opt nat;{ some: bigint | null } | nullThe classic None versus Some(None): unboxed, both would be null.
type OptNull = opt null;{ some: null } | nullThe payload's only value is already null.
type OptReserved = opt reserved;{ some: unknown } | nullunknown absorbs null entirely.
type Inner = opt nat; then type Outer = opt Inner;{ some: Inner } | nullSame as the first row: the alias changes the spelling, not the node.
type Chain = opt Chain;{ some: Chain } | nullThe inner node is the opt itself.
type OptEmpty = opt empty;never | nullNot boxed: empty has no values, so null is the only one.

The builder is c.opt(…) in every row. The runtime's OptDomain type makes the same decision from the inner domain, and validation and both codec directions make it from the resolved inner node when they walk a value, so the invariant annotation proves the generated alias and the builder agree. The box changes the value shape only. Wire bytes and the decoding coercion rules are exactly those of any other opt.

ts
import { c } from "@candid-core/schema";
import { encode } from "@candid-core/schema/codec";

const Description = c.opt(c.opt(c.text)); // type Description = opt opt text;
encode(Description, null);          // None: keep the setting
encode(Description, { some: null }); // Some(None): clear it
encode(Description, { some: "x" });  // Some(Some("x")): set it

schemaFromContract loads the same documents and builds schemas that box identically. Issues inside a box carry the some segment: an invalid payload in { label: { some: 5 } } is reported at $.label.some.

The one opt shape that is omitted

A variant arm whose payload is a declared opt of an uninhabited type — a declared opt empty, or opt of an empty variant — cannot be represented. Through a reference its static type is identical to an alias of null, which the type-level classifier must read as a bare tag, while the emitter and the runtime classify it as carrying a value; no type-level rule satisfies both. The variant is omitted (ambiguous_variant_arm, below); the opt declaration itself generates. The anonymous form works: variant { a : opt empty } renders { tag: "a"; value: never | null } with builder c.opt(c.empty).

What is omitted rather than generated#

A declaration the module cannot represent 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 — and listed in the module header and in the generator's omitted list; the rest of the module generates. Since issue #189 these are omissions, not refusals of the whole interface. The reasons:

reserved_field_name
A supplied field or arm name shaped like the _N_ id rendering. Erased to a schema key it is indistinguishable from the rendering of numeric label id N, and the codec derives wire ids from keys, so such a name would send id N instead of the name's hash. The runtime's Contract loader omits the same declarations.
ambiguous_variant_arm
The declared-opt-of-uninhabited arm above.
reserved_export_name
A declaration named after one of the module's own export names, actor or Actor, which the actor surface exports. Both are omitted unconditionally, actor or not, so adding an actor to a contract cannot change what an unrelated declaration generates. Every other name generates: declarations bind $-prefixed locals, so the runtime's c, Schema and Principal, the global Array, Record, Uint8Array and Promise, and reserved words such as delete are all ordinary declaration names.
invalid_declaration_name
A declaration name that is not identifier-shaped ([A-Za-z_$][A-Za-z0-9_$]*), so it cannot name a binding. Candid source cannot produce one; a hand-built or JSON-loaded Contract can.
references_omitted
The declaration or method references an omitted declaration, named by via. A record holding service { f : (Bad) -> () } is omitted whole rather than losing f, because a service value's wire type is its full method table. Only the actor drops individual methods, from both actor and Actor.

Divergence from agent-js shapes#

These types describe the domain, not the transport. That is a deliberate decision recorded on the project's issue tracker: compatibility with the value shapes the agent-js runtime produces is an explicit non-goal. If you are moving code from bindings generated by @icp-sdk/bindgen, the following is what changes at every touch point.

The same CandidWire-shaped bindingcandid-core-tsWhat your code stops doing
record { memo : opt text } { memo: [] | [string] } { memo: string | null } No array unwrapping. value.memo[0] becomes value.memo, and the empty array becomes null. The property is always present either way.
variant { ok : nat; err : text } a single-key object, one key per arm { tag: "ok"; value: bigint } | { tag: "err"; value: string } No probing for which key exists. switch (value.tag) narrows the union, and TypeScript checks exhaustiveness for you.
record { payload : blob } a byte sequence, spelled per generator { payload: Uint8Array } Nothing is accepted but a real Uint8Array. If your existing values are plain arrays of numbers, wrap them once at the boundary.

The two shapes in the middle column that this repository states it verified against @icp-sdk/bindgen 0.4.0 are the [] | [T] optional and the single-key variant object. The blob row states only what candid-core-ts commits to; check your own generator's output for its byte-vector spelling.

Feeding these types to a classic agent needs a boundary conversion, and this project does not supply one — it is recorded as future work. What it supplies instead is the codec: the generated schemas encode domain values straight to Candid bytes and decode replies back, so an agent only ever moves bytes and never sees a schema. In a value's lifetime the domain shape is what your application code sees, and the wire shape never appears at all.

ts
import { encodeArgs, decodeArgs } from "@candid-core/schema/codec";
import { principal } from "@candid-core/schema";
import { TransferArg, TransferResult } from "./ledger.ts";

const recipient = principal("ryjl3-tyaaa-aaaaa-aaaba-cai");
declare const replyBytes: Uint8Array; // what the canister answered

const arg: TransferArg = {
  to: { owner: recipient, subaccount: null },   // Account; opt blob absent
  fee: null,                                    // opt Tokens, absent
  memo: null,                                   // opt blob, absent
  from_subaccount: null,
  created_at_time: null,
  amount: { e8s: 100_000_000n },
};

const request = encodeArgs([TransferArg], [arg]); // Candid bytes for your transport
// … send request.bytes on the update path, then decode what came back:
const reply = decodeArgs([TransferResult], replyBytes);
if (reply.ok) {
  const result = reply.values[0] as TransferResult;
  if (result.tag === "ok") {
    console.log("block index", result.value);
  }
}

The type names in that snippet come from the generator's ledger golden, whose fixture (crates/candid-core-ts/tests/fixtures/ledger.did) is byte-identical to the ledger interface in the repository's benchmark corpus, benches/corpus/ledger.did.

Next#