Candid to TypeScript mapping
The complete type mapping table, including boxed options and the declarations that are left out.
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.
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
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
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 binding in a generated module is a
$-prefixed local: the schema runtime is imported as the namespace$, and a declarationXis the local$X, alias and builder alike, exported under its Candid name byexport { $X as X }. The tables below write builders asc.…and references by their Candid name for readability; in the module they read$.c.…,$Xand$.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 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[]). |
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.
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";
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:
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 } };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:
type Callback = func (nat) -> (text) query;
type Registry = service { register : (text) -> (nat) };
type Kept = record { value : nat };
service : { ping : () -> () }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.
| 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. |
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 locals, 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 = 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.