Candid to TypeScript mapping
The complete type mapping table, including boxed options and the declarations that are left out.
This page describes @candid-core/schema 0.5.0 and
@candid-core/cli 0.4.0, published on 2026-10-27 under the npm latest dist-tag. The previous
stable releases, @candid-core/schema 0.4.0 and @candid-core/cli 0.3.0, name every use of a blob, record {} or variant {} after the first alias of that shape (marked below); Migrating from 0.4.0 says what the upgrade means.
The stable releases before those, @candid-core/schema 0.3.0 and @candid-core/cli 0.2.0, differ in more of what the generator writes: the generated export type X = … under the Candid name and the $.WithMode mark on each Actor method are new in @candid-core/cli 0.3.0 and @candid-core/schema 0.4.0 (CLI 0.2.0 writes type $X = … and bare call signatures). Migrating from 0.3.0 says what that upgrade means.
The stable releases before those, @candid-core/schema 0.2.0 and @candid-core/cli 0.1.0, have the old surface: 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 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.
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.
The Rust crate is 0.1.0 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
kindas it appears in the Contract JSON document. There are eight kinds and no more:primitive,opt,vec,record,variant,func,service,class. There is noblobnode, notuplenode and noresultnode — 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
export type X = …alias. -
Builder is the expression emitted for
const $X: $.Schema<X> = $.c.rec(() => …). Every declaration is wrapped inc.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 value in a generated module is a
$-prefixed local: the schema runtime is imported as the namespace$, and a declarationX’s builder is the local$X, exported under its Candid name byexport { $X as X }. Its type is declared under the Candid name itself,export type X = …, so a compiler error names the type a consumer imported. The tables below write builders asc.…and references by their Candid name for readability; in the module a builder reads$.c.…,$Xand$.Principal. Because a Candid name cannot contain$, no declaration name —c,Array,Promise,delete— can shadow the import. A name that cannot be a type in the module keeps the$local for its type too (type $Array = …): a global type the lowerings use (Array,Record,Uint8Array,Promise), and a word TypeScript refuses as a type name (delete,string,keyof). -
Schema<in out T>is invariant, so annotating each builder with the alias just emitted makestscprove 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.
| Candid | Contract node | TypeScript | Builder | Notes |
|---|---|---|---|---|
null | primitive: "null" | null | c.null | The single-valued type. Literal null only; undefined is not a Candid value. |
bool | primitive: "bool" | boolean | c.bool | |
nat | primitive: "nat" | bigint | c.nat | Unbounded. A number is rejected, never coerced. |
int | primitive: "int" | bigint | c.int | Unbounded, signed. |
nat8 | primitive: "nat8" | number | c.nat8 | Integral, [0, 255]. |
nat16 | primitive: "nat16" | number | c.nat16 | Integral, [0, 65_535]. |
nat32 | primitive: "nat32" | number | c.nat32 | Integral, [0, 4_294_967_295] — the widest unsigned width a double holds exactly. |
nat64 | primitive: "nat64" | bigint | c.nat64 | [0, 2^64-1]. |
int8 | primitive: "int8" | number | c.int8 | Integral, [-128, 127]. |
int16 | primitive: "int16" | number | c.int16 | Integral, [-32_768, 32_767]. |
int32 | primitive: "int32" | number | c.int32 | Integral, [-2_147_483_648, 2_147_483_647]. |
int64 | primitive: "int64" | bigint | c.int64 | [-2^63, 2^63-1]. |
float32 | primitive: "float32" | number | c.float32 | Validation 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. |
float64 | primitive: "float64" | number | c.float64 | The same double, so every number encodes exactly. |
text | primitive: "text" | string | c.text | Candid text is Unicode scalar values, so encoding refuses a lone surrogate with invalid_text. |
reserved | primitive: "reserved" | unknown | c.reserved | Accepts anything, asserts nothing — so a consumer must narrow before use. |
empty | primitive: "empty" | never | c.empty | Uninhabited on both sides. Every value fails with uninhabited_type. |
principal | primitive: "principal" | Principal | c.principal | Canonical 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#
| Candid | Contract node | TypeScript | Builder | Notes |
|---|---|---|---|---|
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[]). A declared type Subaccount = blob names only itself too: every blob, opt Subaccount included, renders Uint8Array / c.blob() (issue #268, new in @candid-core/cli 0.4.0; 0.3.0 names every blob after the first alias of it). |
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. A declared type Unit = record {} renders the same way at every use (new in @candid-core/cli 0.4.0). |
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. A declared type Abyss = variant {} renders the same way at every use (new in @candid-core/cli 0.4.0). |
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, the value bound as the local
$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]>. Each method's type is that call signature intersected with
its mode, $.WithMode<"query"> (or "composite_query",
"update", "oneway"), which ModeOf from
@candid-core/schema reads back; the mark is an optional property no value has, so
it changes neither the signature nor the method names. 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.
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;// 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 };
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:
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 } };export 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 };
export 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 };
export 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:
type Callback = func (nat) -> (text) query;
type Registry = service { register : (text) -> (nat) };
type Kept = record { value : nat };
service : { ping : () -> () }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 };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.
| Source | TypeScript | Why the box is needed |
|---|---|---|
type DoubleOpt = opt opt nat; | { some: bigint | null } | null | The classic None versus Some(None): unboxed, both would be null. |
type OptNull = opt null; | { some: null } | null | The payload's only value is already null. |
type OptReserved = opt reserved; | { some: unknown } | null | unknown absorbs null entirely. |
type Inner = opt nat; then type Outer = opt Inner; | { some: Inner } | null | Same as the first row: the alias changes the spelling, not the node. |
type Chain = opt Chain; | { some: Chain } | null | The inner node is the opt itself. The compiler refuses this source (a cycle through opt alone), so only a Contract document that already holds the graph meets this row. |
type OptEmpty = opt empty; | never | null | Not 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.
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.
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,
actororActor, 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 values, and a type keeps the$local wherever its name cannot be a type, so the runtime'sc,SchemaandPrincipal, the globalArray,Record,Uint8ArrayandPromise, and reserved words such asdeleteare 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 holdingservice { f : (Bad) -> () }is omitted whole rather than losingf, because a service value's wire type is its full method table. Only the actor drops individual methods, from bothactorandActor.
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 Candid | Wire-shaped binding | candid-core-ts | What 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.
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: TransferResult = reply.values[0];
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.