Quickstart: TypeScript
From a .did file to typed, validated Candid values and bytes — with no Rust toolchain.
The four steps below take you from an empty Node project to a value you have typed, validated and encoded to Candid wire bytes, using one npm package and nothing else. There is no Rust toolchain on that path, no build step, and no code generation — you describe the interface once with the builder API, and TypeScript infers the rest. The two sections after them show how to stop hand-writing the description, and those routes do involve the Rust crate.
The package is @candid-core/schema.
It is a schema runtime for Candid in the shape
Zod made familiar: a builder object called c, one combinator per
Candid type, and a type-level Infer that reads the domain type
back out of a schema. It has no runtime dependencies and no peers, so the
install below pulls in nothing else.
This guide 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, which is what the install and npx lines below
select. A plain npm install @candid-core/schema installs latest, still
0.2.0, which has the old surface: a principal is an object with toText(),
there is no principal() function, and the generated modules use the old layout. What
changes, with before and after code, is on Migrating from 0.2.0, and each change is recorded in the package
changelog under 0.3.0-beta.1.
@candid-core/schema is below 1.0 and versions independently of
the Rust crate. The builder surface and the issue codes are the parts meant to
be depended on; the serialized Contract format they are derived from is
explicitly not a stable v1 yet. Pin an exact version and read the
status page before you build on it.
Four steps to a validated value#
-
Install the package#
bashnpm install --save-exact @candid-core/schema@betaThat is the whole install. The package ships its own type declarations, so there is no
@typespackage to add. It is ESM only — no CommonJS build ships — and it has no top-levelmainortypesfield, only anexportsmap, somoduleResolutionmust benode16,nodenextorbundler;node10cannot see the package at all and fails every import withTS2307. TypeScript 5.0 is a hard floor, and Node 16 is the oldest runtime the subpaths below are exercised on.Each module is a separate subpath export, and you import from the one you need: . for the builders, ./validate, ./codec and ./contract. Nothing is re-exported from the root barrel, so importing the builders never drags the codec in behind them. Those four are the whole export map, and nothing else in the package is importable.
-
Describe the interface with the
cbuilders#Take a fragment of the
.didfile you already have and write it out with the builders. One combinator per Candid type, nested the same way the source nests.candidservice.didtype Account = record { owner : principal; balance : nat }; type TransferResult = variant { ok : nat; err : text; pending };tsimport { c, type Infer } from "@candid-core/schema"; const Account = c.record({ owner: c.principal, balance: c.nat }); type Account = Infer<typeof Account>; // { owner: Principal; balance: bigint } const TransferResult = c.variant({ ok: c.nat, err: c.text, pending: c.null }); type TransferResult = Infer<typeof TransferResult>; // | { tag: "ok"; value: bigint } // | { tag: "err"; value: string } // | { tag: "pending" }Three mapping decisions are visible there, and each is deliberate.
natisbigint, nevernumber: the type is unbounded and exceeds 253 on the wire, so anumberis rejected rather than coerced. A variant is a discriminated union ontag, and an arm whose payload isnull— Candid'spendingandpending : nullare the same arm — is a bare{ tag }with novaluekey at all.c.principalisPrincipal: the canonical principal text as a branded string, because that is exactly what the codec produces on decode — plain data that serializes, clones and compares with===.You do not have to take the comments on faith. Annotate the schema with the type you would have written by hand and let
tsccheck them against each other:tsimport { c, type Principal, type Schema } from "@candid-core/schema"; type Account = { owner: Principal; balance: bigint }; // Compiles only if the builder's inferred type and the alias are the same // type in both directions. const Account: Schema<Account> = c.rec(() => c.record({ owner: c.principal, balance: c.nat }));Schema<in out T>is declared invariant, soTmay be neither widened nor narrowed across that assignment. A schema that drifted from the alias — a field added, anatquietly turned into anat32— fails to compile. This is the same mechanism the code generator relies on, andc.recis the lazy indirection that also lets a declaration refer to itself. -
Validate a value#
validateis a verdict, not an exception and not a type guard. It returns{ ok: true }or{ ok: false, issues }, and it never throws for any value you hand it — a getter that throws or a revokedProxycomes back as anunreadable_valueissue instead.tsimport { principal } from "@candid-core/schema"; import { validate } from "@candid-core/schema/validate"; // The one way to make a Principal: canonical text, or an object with toText() // such as an SDK Principal. Non-canonical text such as "AAAAA-AA" throws a // TypeError. const owner = principal("aaaaa-aa"); validate(Account, { owner, balance: 5n }); // { ok: true } validate(Account, { owner, balance: 5 }); // { // ok: false, // issues: [ // { // code: "invalid_type", // path: "$.balance", // message: "expected a bigint for nat, got number", // }, // ], // }Every issue has the same three keys —
code,pathandmessage— plus an optionalresource_limitwhen a traversal budget was the thing that failed.codeandpathare the machine surface and are stable;messageis for humans and is not. Paths are$-rooted, using.namefor an identifier-shaped key,[0]for an index, and a quoted["odd key"]otherwise.tsfunction handle(incoming: unknown): void { // `incoming` is whatever arrived over the network. const result = validate(Account, incoming); if (!result.ok) { for (const issue of result.issues) { console.error(`${issue.path}: ${issue.code} — ${issue.message}`); } return; } // `incoming` is still `unknown` here: the verdict says the value fits, it // does not narrow the binding. Assert or re-read it deliberately. }The closed set of codes is
invalid_type,not_integer,out_of_range,missing_field,unexpected_field,unknown_tag,invalid_length,uninhabited_type,unsupported_schema,unreadable_valueandresource_limit_exceeded. Adding one is an API change.Records are strict in both directionsA missing key is
missing_field; an unknown key isunexpected_field. That includesoptfields: the domain shape isT | nullwith the property present, so{ memo: null }validates and{}fails at$.memo.undefinedis not a Candid value anywhere. -
Encode and decode Candid bytes#
The codec is schema-directed and self-contained — it builds the wire type table from the schema you pass and constructs no SDK classes. Both directions return a result object rather than throwing.
tscrates/candid-core-ts/ts/codec.tsexport function encode<T>( schema: Schema<T>, value: unknown, options: CodecOptions = {}, ): EncodeResult export function decode<T>( schema: Schema<T>, bytes: Uint8Array, options: CodecOptions = {}, ): { ok: true; value: unknown } | { ok: false; issues: readonly CodecIssue[] }encodeArgsanddecodeArgstake one schema and one value per argument of a call, which is what a message really carries.tsimport { decode, encode } from "@candid-core/schema/codec"; const encoded = encode(Account, { owner, balance: 5n }); if (!encoded.ok) { throw new Error(encoded.issues[0].message); } encoded.bytes; // Uint8Array, a complete one-argument Candid message const decoded = decode(Account, encoded.bytes); if (decoded.ok) { const account = decoded.value as Account; account.balance; // 5n account.owner; // "aaaaa-aa", the principal's canonical text }Two things to note.
decodehands backvalue: unknown, so the assertion above is yours to make — the decoder checked the bytes against the schema, but the signature does not pretend to have narrowed anything. Andencodeis stricter thanvalidatein a few places on purpose: afloat32that is notMath.fround-exact fails withunrepresentable_float32rather than being rounded, and a string containing a lone surrogate fails withinvalid_text, because Candidtextis a sequence of Unicode scalar values.CodecIssuehas the same{ code, path, message }shape as a validation issue, over a superset of the codes; the extra ones name wire-level failures such asinvalid_magic,malformed_type_table,overlong_leb128,truncatedandtrailing_bytes. Paths into a multi-argument message read$args[0],$args[1]and so on.
Two ways to stop hand-writing schemas#
Writing builders by hand is honest for a handful of types and tedious for a
real canister interface. Both routes below start from the same thing: a
Contract, which is what this project calls a
.did interface after it has been parsed, validated, canonicalized
and given content-addressed identities. Producing one from Candid source needs
the Rust compiler crate today; what you do with it afterwards does not.
Generate a TypeScript module ahead of time#
The candid-core-ts crate turns a Contract graph into a
TypeScript module of const $X: $.Schema<$X> = $.c.rec(() => …)
declarations plus the matching type aliases, each exported under its Candid name — which is why the invariance trick
in step 2 matters: tsc compiling the generated file is the
proof that the emitted schemas and the emitted types agree. The crate is
marked publish = false deliberately, because its registry name has
not been decided, so you depend on it by path or by git from this repository
rather than from crates.io. See the code generator
for the full procedure and the mapping it applies.
Build schemas at runtime from a Contract document#
If you would rather not have a generated file in your tree, the same schemas
can be built at startup from the canonical Contract JSON. The
candid-core binary writes that document; schemaFromContract
reads it.
cargo install candid-core --version 0.1.0-beta.3 --locked
candid-core compile ./service.did --envelope > ./service.json--envelope prints one self-describing document: the canonical
Contract plus an extensions map whose
org.candid-core.field-names/v1 entry carries the field-name table.
A semantic Contract stores field label ids, not text, so names have to
travel beside it — and envelope extensions sit outside the canonical identities
by design, so carrying them never moves a contract_id.
import { readFileSync } from "node:fs";
import { schemaFromContract } from "@candid-core/schema/contract";
const built = schemaFromContract(JSON.parse(readFileSync("./service.json", "utf8")));
if (!built.ok) {
throw new Error(JSON.stringify(built.issues));
}
built.schemas.Account; // one Schema per declaration, in declaration order
built.actor; // the service schema, when the document has oneSchemas built this way are lazy at every edge, so a schema reached by name
reports kind === "rec"; call resolveSchema from the
root export before you read its structure. Names are hash-enforced — each one
must be the Candid preimage of the id it claims — so a table that lies fails
closed instead of silently renaming a field. Schemas
at runtime covers the options and the failure modes;
the candid-core binary covers the compile side.
A second package, @candid-core/cli, compiles the same pipeline
to WebAssembly so that .did → schemas needs no Rust at all. It
writes the generated module and the same envelope document the Rust binary
prints. See @candid-core/cli for what it does.
# the CLI beta that pairs with @candid-core/schema 0.3.0-beta.1
npx @candid-core/cli@0.2.0-beta.1 gen ./service.did -o ./generatedNext: calling a canister#
This package stops at the bytes. Once a service schema exists —
hand-written, generated, or loaded from a Contract document —
serviceMethods reads its method table and the codec encodes each
method's arguments and decodes its reply. Sending those bytes to a canister —
the agent, identity, polling and certificate verification — is the job of the
call layer you build on top.
encodeArgs and decodeArgs: the Candid bytes a call layer sends and receives.
Every combinator on c, and the full Candid-to-TypeScript mapping each one applies.
The other half: compiling .did source into the Contract these schemas come from.