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.

Published as a beta

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.

Pre-1.0, on purpose

@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#

  1. Install the package#

    bash
    npm install --save-exact @candid-core/schema@beta

    That is the whole install. The package ships its own type declarations, so there is no @types package to add. It is ESM only — no CommonJS build ships — and it has no top-level main or types field, only an exports map, so moduleResolution must be node16, nodenext or bundler; node10 cannot see the package at all and fails every import with TS2307. 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.

  2. Describe the interface with the c builders#

    Take a fragment of the .did file you already have and write it out with the builders. One combinator per Candid type, nested the same way the source nests.

    candidservice.did
    type Account = record { owner : principal; balance : nat };
    type TransferResult = variant { ok : nat; err : text; pending };
    ts
    import { 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. nat is bigint, never number: the type is unbounded and exceeds 253 on the wire, so a number is rejected rather than coerced. A variant is a discriminated union on tag, and an arm whose payload is null — Candid's pending and pending : null are the same arm — is a bare { tag } with no value key at all. c.principal is Principal: 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 tsc check them against each other:

    ts
    import { 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, so T may be neither widened nor narrowed across that assignment. A schema that drifted from the alias — a field added, a nat quietly turned into a nat32 — fails to compile. This is the same mechanism the code generator relies on, and c.rec is the lazy indirection that also lets a declaration refer to itself.

  3. Validate a value#

    validate is 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 revoked Proxy comes back as an unreadable_value issue instead.

    ts
    import { 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, path and message — plus an optional resource_limit when a traversal budget was the thing that failed. code and path are the machine surface and are stable; message is for humans and is not. Paths are $-rooted, using .name for an identifier-shaped key, [0] for an index, and a quoted ["odd key"] otherwise.

    ts
    function 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_value and resource_limit_exceeded. Adding one is an API change.

    Records are strict in both directions

    A missing key is missing_field; an unknown key is unexpected_field. That includes opt fields: the domain shape is T | null with the property present, so { memo: null } validates and {} fails at $.memo. undefined is not a Candid value anywhere.

  4. 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.ts
    export 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[] }

    encodeArgs and decodeArgs take one schema and one value per argument of a call, which is what a message really carries.

    ts
    import { 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. decode hands back value: 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. And encode is stricter than validate in a few places on purpose: a float32 that is not Math.fround-exact fails with unrepresentable_float32 rather than being rounded, and a string containing a lone surrogate fails with invalid_text, because Candid text is a sequence of Unicode scalar values.

    CodecIssue has the same { code, path, message } shape as a validation issue, over a superset of the codes; the extra ones name wire-level failures such as invalid_magic, malformed_type_table, overlong_leb128, truncated and trailing_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.

bash
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.

tscrates/candid-core-ts/ts/README.md
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 one

Schemas 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.

The same compile step with no Rust toolchain

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.

bash
# 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 ./generated

Next: 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.