TypeScript overview

How the generator, the schema runtime, the codec and the Contract loader fit together.

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, @candid-core/schema 0.2.0 and @candid-core/cli 0.1.0, still has the old one. Migrating from 0.2.0 lists each difference with before and after code, and every change is recorded in the package changelogs under 0.3.0-beta.1 and 0.2.0-beta.1.

On the TypeScript side, a Candid interface becomes an ordinary JavaScript value. A schema here is plain inert data — { kind: "record", fields: … } — with the static type riding along in a phantom property that never exists at runtime. That one object is what the validator walks, what the binary codec reads to lay out bytes, and what the Contract loader builds. All three share that one object rather than a description of their own, so none of them can disagree about what your interface says.

The pieces split across a language boundary. A Rust crate, candid-core-ts, turns a validated Contract — candid-core's canonical model of a .did file — into a TypeScript module. An npm package, @candid-core/schema, is what that module imports and what everything downstream is built on. You can also skip the generator entirely and build the same schemas at runtime from a compiled Contract document.

The pieces#

The generator (Rust)#

candid-core-ts is a library crate, not a command. Its entry point produces one TypeScript module for the whole Contract, leaving out — and listing in the result's omitted — any declaration it cannot represent, with what references it. Only an invalid Contract graph refuses:

rustcrates/candid-core-ts/src/lib.rs
pub fn generate_module(
    contract: &Contract,
    names: &TsNames,
    options: &TsOptions,
) -> Result<GeneratedModule, TsGenError>

The published candid-core binary does not emit TypeScript: its usage is compile <path> [--no-source-info | --envelope] and validate <path>, and it stops there. The generator crate itself is marked publish = false and is not on crates.io — a registry name is a permanent decision and has not been made — so you depend on it by path or git from the repository, or use the runtime route below instead. One command in the repository does emit a module: @candid-core/cli is the same compiler and generator built to WebAssembly, and candid-core-cli gen <service.did> [-o <dir>] writes <stem>.ts alongside <stem>.envelope.json. It is published on npm. See Packages and crates.

For every declaration in the Contract the module emits a type alias a developer reads and a runtime schema annotated with it, both bound to the $-prefixed local $X and exported under the Candid name X. The $ keeps every Candid name, even Array or delete, from colliding with the module's own bindings.

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

The annotation is the point. Schema<in out T> is declared invariant, so const $Item: $.Schema<$Item> = … compiles only if what the builders infer equals the alias in both directions. Type-checking a generated file is therefore a proof that the two halves agree. Builders and inference takes that mechanism apart; the code generator covers the emitter itself, including the declarations it leaves out.

The schema runtime (npm)#

@candid-core/schema is where c, Schema and Infer live, along with the node interfaces a walker narrows on and the two functions for reading a schema back: resolveSchema and serviceMethods. It is ESM only, and it installs with nothing else:

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

Validation#

validate(schema, value) returns { ok: true } or { ok: false, issues } and never throws, for any value (a malformed options object is a programmer error and throws TypeError). Each issue carries a stable code and a $-rooted path — $.transactions[2].amount.e8s addresses one leaf — and the walk is bounded, so a hostile value hits a budget rather than running away. See Validation.

The codec#

encode and decode (and encodeArgs / decodeArgs for argument sequences) implement the Candid binary format in TypeScript, directed by the schema. Nothing is inferred from the value: the schema decides the type table and the layout. See The binary codec.

Services, and where calls happen#

A service schema describes a canister interface; it does not call one. serviceMethods(schema) reads its method table — name, mode, argument and result schemas — and encodeArgs/decodeArgs turn a call's arguments and reply into Candid bytes. Sending them — the agent, identity, polling and certificate verification — is the job of the call layer you build on top, and that agent never sees a schema. This package has no actor factory and no transport: it is the Candid layer only.

Candid does not put field names on the wire — it puts a 32-bit hash of them — so a canonical Contract stores label ids and the spellings travel side-band. The codec and the Contract loader bridge the two internally: a key spelled _N_ is the id N, and any other key hashes to its id.

The domain shapes are not agent-js shapes#

This will be the first surprise if you have used @icp-sdk/bindgen or its predecessors. The types here describe the domain you would have written by hand, not the runtime encoding:

  • opt T is T | null, not [] | [T]. The exception is an opt whose inner type admits null (opt opt T, opt null, opt reserved), which is { some: T } | null so that None and Some(None) stay different values.
  • A variant is a { tag, value } discriminated union, not a single-key object.
  • A vec nat8 (blob) is a Uint8Array, not a number[], whatever its element type is called.
  • nat, int and the 64-bit widths are bigint.
  • principal is Principal: its canonical text as a branded string — plain data that serializes, clones and compares with ===. The codec is self-contained and never constructs or accepts an SDK class; principal(value) converts one.
Compatibility with agent-js value shapes is an explicit non-goal

Handing these types to a live agent through the classic bindings path needs a conversion at the boundary. The supported route is this package's own codec: encode with the generated schemas and hand the agent only bytes. Likewise, a decoded principal is not an @icp-sdk/core Principal but its canonical text; convert with the SDK's Principal.fromText(value) when you need the class. In the other direction, validation and encoding accept exactly canonical text, so an SDK instance you built goes through principal(sdkPrincipal) once, at your boundary.

Two ways to get the schemas#

Generated ahead of time#

Run the Rust generator over a Contract and commit the resulting module. You get named exports, hand-readable aliases, and an exported Actor type describing the service as async methods. Editors resolve everything without loading anything, and tsc checks the annotation on every build.

Built at runtime#

Or skip generation. The ./contract subpath builds the same schemas from a compiled Contract JSON document. Producing that document from .did is a separate step, and the route below uses the published candid-core binary: two commands, whose output is data you can ship, fetch or cache. @candid-core/cli compiles the same sources in WebAssembly with no Rust toolchain, and its <stem>.envelope.json is the same document.

bash
cargo install candid-core --version 0.1.0-beta.3 --locked
candid-core compile ./service.did --envelope > ./service.json
ts
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

compile --envelope prints one self-describing document: the canonical Contract plus an extensions map carrying the field-name table, which a Contract does not itself store. Passing no table is legal and renders every field by the _N_ convention. Details are on Schemas at runtime.

What ties the two together#

The repository keeps golden fixtures under crates/candid-core-ts/tests/goldens/, each a generated .ts module, and all but two of them also the canonical Contract JSON it was generated from. A cross-check test builds the dynamic schema from the JSON and runs a list of sample values through both, comparing whole validate results by deep equality:

tscrates/candid-core-ts/ts/tests/crosscheck.test.ts
for (const sample of samples) {
  assert.deepStrictEqual(
    validate(dynamic, sample),
    validate(generated, sample),
    `${fixture.name}.${declaration} diverged on ${String(sample)}`,
  );
}

Same verdict, same issue codes, same paths, same messages, sample by sample — for valid and invalid values alike. The same test also pins that the one-document envelope path and the two-file { contract, names } path agree the same way. So the runtime loader is not a reimplementation that happens to look similar: on these fixtures it is checked against the generator's own output, value by value.

One place the two deliberately differ

A dynamically built schema carries one extra rec hop at every edge, and each hop costs a traversal step against the validator's depth budget. Values near the depth limit can therefore reach the budget on one schema and not the other. The cross-check samples stay far from that limit on purpose; the depth behaviour itself is pinned separately in the validation tests.

The module map#

Every module is a subpath export, so importing the validator does not drag in the codec or an agent. The four below are the whole map; nothing else, including a deep path into dist/, is importable.

SubpathWhat it exports
. The c builders, Schema<in out T>, Infer, Principal with principal and isPrincipal, OptDomain and isBoxedOpt, the node interfaces, resolveSchema and serviceMethods.
./validate validate, its issue and options types, the DEFAULT_MAX_* budgets, plus isResultSchema and unwrapResult.
./contract schemaFromContract, FieldNameEntry, the contract issue codes, and FIELD_NAMES_EXTENSION.
./codec encode, decode, encodeArgs, decodeArgs, principalTextFromBytes, principalBytesFromText.

No dependencies, and no SDK#

The package declares no runtime dependency and no peer of any kind, and no shipped module imports the Internet Computer SDK, at runtime or in its declarations. Principals are the package's own: canonical text as a branded Principal, so nothing needs an SDK class to exist. A value from @icp-sdk/core converts once, at your boundary, with principal(sdkValue), and a decoded principal converts back with the SDK's own Principal.fromText(value). The packaged-consumer gate compiles and runs every subpath in a scratch tree with no SDK installed, under strict TypeScript with skipLibCheck off.

Pin the version

@candid-core/schema is pre-1.0. Until 1.0, a release may change the builder API, the inferred domain types, the codec's wire behaviour, and the codes and paths validation reports. Pin an exact version rather than a range. The Rust side is 0.1.0-beta.3 and carries the same caveat — see Status and releases.

Where to go next#