TypeScript overview
How the generator, the schema runtime, the codec and the Contract loader fit together.
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:
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.
// 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:
npm install --save-exact @candid-core/schema@betaValidation#
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 TisT | null, not[] | [T]. The exception is anoptwhose inner type admitsnull(opt opt T,opt null,opt reserved), which is{ some: T } | nullso thatNoneandSome(None)stay different values. - A variant is a
{ tag, value }discriminated union, not a single-key object. - A
vec nat8(blob) is aUint8Array, not anumber[], whatever its element type is called. nat,intand the 64-bit widths arebigint.-
principalisPrincipal: 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.
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.
cargo install candid-core --version 0.1.0-beta.3 --locked
candid-core compile ./service.did --envelope > ./service.jsonimport { 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:
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.
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.
| Subpath | What 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.
@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#
Every combinator on c, and the invariance trick that proves a schema matches its type.
Issue codes, $-rooted paths, and the budgets that make a hostile value terminate.
Schema-directed Candid encoding and decoding, fail-closed on every input.
Guide Schemas at runtimeBuilding the same schemas from Contract JSON, with no code generation step.
Guide The code generatorHow a Contract graph becomes a module that tsc itself proves correct.