Schema builders and inference
Every combinator on c, and how a schema and its TypeScript type stay provably in step.
This page describes the surface the repository builds, published as
@candid-core/schema 0.3.0-beta.1 under the npm beta dist-tag. latest is still
0.2.0, which has the old one; in
particular a principal is an object with toText() there, and an opt over
a type that admits null is not boxed.
Migrating from 0.2.0 lists every difference with before and
after code, and each is recorded in the package changelog under 0.3.0-beta.1.
c is the whole construction surface of
@candid-core/schema.
There is no class to instantiate and no registry to populate: every member of c
returns plain data. c.record({ owner: c.principal, balance: c.nat }) evaluates to
{ kind: "record", fields: { … } }, and that object is what the validator and the
codec both walk.
It has 28 members — 18 primitive constants and 10 builder functions. The static type of a schema
is carried by a phantom property that never exists at runtime, so
Infer<typeof X> gives you the TypeScript type without a second declaration.
The section on Infer below explains why that inferred type can be trusted rather than
merely hoped for.
export interface Schema<in out T> {
readonly kind: string;
/** Phantom carrier for `T`; never present at runtime. */
readonly [phantom]?: (value: T) => T;
}
export type Infer<S> = S extends Schema<infer T> ? T : never;The eighteen primitives#
Each is a constant, not a call: c.nat, not c.nat(). Every one is a
PrimitiveSchema<T> whose primitive property names the Candid type
— c.nat64.primitive is "nat64".
| Candid | Builder | Infers | Notes |
|---|---|---|---|
null | c.null | null | The single-valued type. Mostly seen as a variant arm, where it makes the arm a bare tag. |
bool | c.bool | boolean | |
nat | c.nat | bigint | Unbounded. A number is rejected, not coerced. |
int | c.int | bigint | Unbounded and signed. |
nat8 | c.nat8 | number | Integral, [0, 255]. |
nat16 | c.nat16 | number | Integral, [0, 65_535]. |
nat32 | c.nat32 | number | Integral, [0, 4_294_967_295] — the widest unsigned width a JavaScript number holds exactly. |
nat64 | c.nat64 | bigint | [0, 2^64-1]. Past 2^53 a number cannot hold every value. |
int8 | c.int8 | number | Integral, [-128, 127]. |
int16 | c.int16 | number | Integral, [-32_768, 32_767]. |
int32 | c.int32 | number | Integral, [-2_147_483_648, 2_147_483_647]. |
int64 | c.int64 | bigint | [-2^63, 2^63-1]. |
float32 | c.float32 | number | Validation accepts any number; encoding refuses a value that is not Math.fround-exact. |
float64 | c.float64 | number | The same double, so every number encodes exactly. |
text | c.text | string | Validation checks the JavaScript type; encoding refuses a lone surrogate. |
reserved | c.reserved | unknown | Asserts nothing, so a consumer must narrow before use. |
empty | c.empty | never | Uninhabited. No value passes; every one fails with uninhabited_type. |
principal | c.principal | Principal | Canonical principal text as a branded string, not the SDK Principal class. Make one with principal(…). |
Passing 5 where c.nat is declared fails with
invalid_type — it is not silently converted to 5n. The fixed widths up
to 32 bits go the other way: c.nat8 wants a number, and a
bigint there is an invalid_type too. A lossy bridge would corrupt
values past 2^53 without saying so.
Options: opt, and the three inners that box#
opt<T>(inner: Schema<T>): OptSchema<T>
opt T renders as T | null. Absence is exactly null, and on
a record the property stays present rather than going missing — { memo: null }
validates, {} fails with missing_field.
import { c, type Infer } from "@candid-core/schema";
import { validate } from "@candid-core/schema/validate";
const Note = c.opt(c.text);
type Note = Infer<typeof Note>; // string | null
That reading buys ordinary JavaScript ergonomics, and it holds only while T itself can
never be null in TypeScript. Three inner types break that. For exactly those, the
present value is boxed as { some: T }, so absence and a present-but-empty value stay
apart. Every other opt is unaffected.
| Shape | Domain | Why T | null could not carry it |
|---|---|---|
opt opt T |
{ some: T | null } | null |
The classic case. Candid distinguishes None from Some(None);
(T | null) | null would collapse to T | null, losing the
distinction on the way in and on the way out.
|
opt null |
{ some: null } | null |
The inner type's only value is null, which is also the encoding of absence.
|
opt reserved |
{ some: unknown } | null |
reserved infers unknown, which absorbs null entirely. |
const Description = c.opt(c.opt(c.text));
type Description = Infer<typeof Description>; // { some: string | null } | null
validate(Description, null); // ok: None
validate(Description, { some: null }); // ok: Some(None)
validate(Description, { some: "x" }); // ok: Some(Some("x"))
validate(Description, "x"); // invalid_type at $
A box is strict like a record: some must be present as an own enumerable property,
and no other key may be. Issues inside it carry the some segment
($.some, $.label.some.some). The decision is made on the inner
node, not its spelling, so a declared alias or a recursive reference
(type Chain = opt Chain) boxes the same way. Every walker makes it when it walks a
value, not when c.opt is called, so c.opt over a c.rec whose
thunk names a later const stays safe. isBoxedOpt(schema) answers the
question for a schema you hold. opt empty is not boxed: empty has no
values, so its only value is null.
The generator emits the boxed alias, schemaFromContract loads the same documents, and
the codec writes exactly the bytes any other opt would.
Collections: vec, blob and unit#
vec<T>(inner: Schema<T>): VecSchema<T>
blob(): BlobSchema
unit(): UnitSchemac.vec(inner)— infersT[]-
Validation wants a real array. An array-like object or a bare iterable fails with
invalid_type. c.blob()— infersUint8Array-
Every
vec nat8— Candid’sblob— taken wholesale rather than one element at a time. The check reads the typed-array brand, so aUint8Arrayfrom another realm (a NodeBufferincluded) passes while a prototype forgery does not. c.unit()— infersRecord<string, never>-
The empty Candid record. Not
{}, which in TypeScript means "anything non-nullish"; an own enumerable string key on the value is anunexpected_field.
A generated module shows all three next to each other:
type $Grid = Array<Uint8Array>;
const $Grid: $.Schema<$Grid> = $.c.rec(() => $.c.vec($.c.blob()));
export { $Grid as Grid };
type $Unit = Record<string, never>;
const $Unit: $.Schema<$Unit> = $.c.rec(() => $.c.unit());
export { $Unit as Unit };record and tuple#
record<F extends FieldSchemas>(fields: F): RecordSchema<F>
tuple<const S extends readonly AnyFieldSchema[]>(elements: S): TupleSchema<S>
c.record maps each field through Infer, so
c.record({ id: c.nat32, label: c.text }) infers
{ id: number; label: string }. It is strict in both directions: a missing key is
missing_field, an unknown key is unexpected_field, and
opt fields are no exception. Presence means an own enumerable property —
the projection JSON.stringify, object spread and structured clone all see — so a
value that validates never serializes into one that would not.
c.tuple is for the records Candid lowers tuple syntax into: field ids exactly
0..n-1. It infers a fixed-length JavaScript tuple, and a value of the wrong length
fails with invalid_length.
type $Pair = [bigint, string];
const $Pair: $.Schema<$Pair> = $.c.rec(() => $.c.tuple([$.c.nat, $.c.text]));
export { $Pair as Pair };
The const type parameter is what keeps element order and arity in the inferred type
instead of widening them to an array — it is why
Infer<typeof Pair> above is [bigint, string]. Const type
parameters arrived in TypeScript 5.0, and below it the failure is a parse error rather
than a type error: 4.9 stops at
error TS1139: Type parameter declaration expected. in
dist/schema.d.ts before it type-checks anything. The package's support matrix
measures that floor against the packed tarball, not against the manifest.
variant#
variant<A extends FieldSchemas>(arms: A): VariantSchema<A>
A variant infers a discriminated union on tag. An arm whose payload is
null is a bare { tag } — Candid's ok and
ok : null are the same arm, so value: null on every tag-only arm would
be noise. Every other arm carries { tag, value }. A tag that is not an arm fails with
unknown_tag.
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 };The classification is made by the payload node, and the type-level rule matches what the emitter, the validator and the codec do at runtime, in this order:
-
A payload whose domain is
never—c.empty, or an empty variant — carriesvalue. The node is notnull, so the runtime demands the field even though nothing can inhabit it. -
An
opt-kind payload always carriesvalue.c.opt(c.empty)infersnullwithout being thenullprimitive. - Otherwise it is a bare tag exactly when its domain is
null. - Everything else carries
value.
Getting this wrong is a compile error, not a runtime surprise. Writing
{ tag: "ok", value: null } for a tag-only arm fails validation with
unexpected_field at $.value; omitting value on a payload
arm fails missing_field at the same path.
References: func, service and principal#
func(
args: readonly AnyFieldSchema[],
results: readonly AnyFieldSchema[],
mode: MethodMode,
): FuncSchema
service(methods: { readonly [name: string]: AnySchema }): ServiceSchema
A Candid func value is a reference, not a closure — an address you can put
in a record and send over the wire. So c.func(...) infers FuncValue,
which is { principal: Principal; method: string }, and the signature lives in
the node instead of in the type. MethodMode is a closed union:
"update", "query", "composite_query",
"oneway"; an unannotated Candid method is "update", made explicit.
A service value is the principal of a running service, so
c.service(...) infers Principal. The method table it carries is
what serviceMethods reads back. Both appear in a generated module as ordinary declarations:
type $Callback = { principal: $.Principal; method: string };
const $Callback: $.Schema<$Callback> = $.c.rec(() => $.c.func([$.c.nat], [$.c.text], "query"));
export { $Callback as Callback };
type $Registry = $.Principal;
const $Registry: $.Schema<$Registry> = $.c.rec(() => $.c.service({ register: $.c.func([$.c.text], [$.c.nat], "update") }));
export { $Registry as Registry };
c.principal is the primitive for a bare principal value, listed in the table above.
Its type is Principal: the canonical principal text as a branded string, which is
exactly what the codec delivers — plain data that serializes with JSON.stringify,
survives structuredClone, and compares with ===. Validation and encoding
accept exactly canonical text. principal(input) is the one way to make a
Principal: it takes a string or an object with toText(), such as an
@icp-sdk/core Principal, and throws TypeError on
non-canonical text rather than repairing it. isPrincipal(value) is the matching guard.
import { c, isPrincipal, principal, type Principal } from "@candid-core/schema";
const ledger: Principal = principal("ryjl3-tyaaa-aaaaa-aaaba-cai");
isPrincipal("aaaaa-aa"); // true: the management canister
isPrincipal("AAAAA-AA"); // false: not canonical, so principal("AAAAA-AA") throws
c.record({ owner: c.principal }); // { owner: Principal }rec: the lazy indirection#
rec<T>(body: () => Schema<T>): RecSchema<T>
c.rec defers construction into a thunk resolved on demand. It adds a hop, not a
shape: the domain type is the body's, unchanged. That is what lets a schema refer to itself, and
what lets the generator emit declarations in canonical name-sorted order without a forward
reference hitting a temporal dead zone at module initialization.
import { c, type Schema } from "@candid-core/schema";
type List = { head: bigint; tail: List | null };
const List: Schema<List> = c.rec(() => c.record({ head: c.nat, tail: c.opt(List) }));
The explicit Schema<List> annotation is not decoration here. It is what breaks
the circular inference — without it, the type of List would depend on itself — and it
is the equality the compiler then proves about the builder.
Each hop costs one traversal step against a walk's depth and element budgets, which is what makes a mis-built self-referential chain terminate rather than hang.
Infer, and the invariance proof#
Infer<typeof X> reads the domain type back out of a schema, so you write the
schema once and name the type without a second declaration:
const Account = c.record({ owner: c.principal, balance: c.nat });
type Account = Infer<typeof Account>; // { owner: Principal; balance: bigint }Generated modules go the other way round. They emit the alias first, in full, so a human can read it — and then annotate the builder with it:
type $Tokens = { e8s: bigint };
const $Tokens: $.Schema<$Tokens> = $.c.rec(() => $.c.record({ e8s: $.c.nat64 }));
export { $Tokens as Tokens };
That second line is the load-bearing one, and the reason is a single pair of variance annotations
on the interface: Schema<in out T>. in out makes
Schema invariant in T, so
Schema<A> is assignable to Schema<B> only when
A and B are the same type — assignable in both directions, not
merely compatible in one.
The consequence is that the annotation is a proof obligation. The builder expression infers some
type; the annotation states another; tsc checks them against each other in both
directions. If the emitter or the runtime ever mapped nat64 to something other than
bigint, that line would stop compiling. There is no way for the type a developer
reads and the schema the codec executes to drift apart while the file still type-checks.
What a mismatch looks like#
Suppose the alias claimed number where the builder says c.nat64:
import { c, type Schema } from "@candid-core/schema";
type Tokens = { e8s: number };
// The builder infers { e8s: bigint }; the alias says { e8s: number }.
// tsc rejects the assignment — the schema and the type disagree.
const Tokens: Schema<Tokens> = c.rec(() => c.record({ e8s: c.nat64 }));
That one would fail under ordinary assignability too, because bigint is not
assignable to number. Invariance earns its keep on the cases that are compatible in
one direction. Here are two probes from the type-level suite: an alias that adds a field the
builder does not infer, and a widened alias. never is assignable to
unknown, so a covariant check would wave the second through; invariance does not:
type ExtraField = { f: never; g: bigint; h: string };
// @ts-expect-error the alias adds a field the builder does not infer
export const extraField: Schema<ExtraField> = c.rec(() => c.record({ f: c.empty, g: c.nat }));
type WidenedField = { f: unknown; g: bigint };
// @ts-expect-error the alias widens never to unknown
export const widenedField: Schema<WidenedField> = c.rec(() => c.record({ f: c.empty, g: c.nat }));
Those @ts-expect-error lines are themselves the test. Each marks an assignment the
gate must refuse; if the gate ever weakened, the suppressed error would disappear and
tsc would report the unused directive, turning the harness red. The same file carries
positive probes for the classifications the gate must accept. Every golden module sits
inside the same strict type-check project, so every mapping those fixtures exercise
is re-checked on every run.
AnySchema and AnyFieldSchema#
Invariance has one practical cost: a bound of Schema<unknown> would reject
every concrete schema. So code that holds schemas without knowing what they describe uses
AnySchema — Schema<any> — as the variance wildcard in constraint
position, where it never leaks into an inferred output type.
export type AnySchema = Schema<any>;
export type AnyFieldSchema = AnySchema | Schema<never>;
export type FieldSchemas = Record<string, AnyFieldSchema>;
any is assignable to every type except never, so
AnySchema alone would reject c.empty and every empty variant.
AnyFieldSchema re-admits them. The widening lives on the composite bounds rather than
on Schema itself, which is exactly what keeps the equality proof at full strength.
Reading a schema back#
Two functions ship alongside the builders for consumers that walk a schema.
export function resolveSchema(schema: AnyFieldSchema): ResolvedNode
Follows body() through rec indirections until a non-rec
node appears. Bounded at 256 hops, after which it throws TypeError rather than
hanging; it also throws for anything that is not a schema object, and for a kind
this package does not define.
export function serviceMethods(service: AnyFieldSchema): ReadonlyMap<string, ServiceMethod>
The method table of a service schema, keyed by name in declaration order. A
ReadonlyMap rather than an object, so a method named __proto__ is an
ordinary entry. A call layer reads it to build a typed call surface, and the codec's
encodeArgs and decodeArgs move each method's arguments and reply.
import { c, serviceMethods } from "@candid-core/schema";
const table = serviceMethods(c.service({ fee: c.func([], [c.nat], "query") }));
table.get("fee")?.mode; // "query"
Every generated declaration is wrapped in c.rec, and schemas built from a Contract
document at runtime add a hop at every edge. So reading .kind off a named schema
gives you "rec" and tells you nothing about the type. The introspection tests pin
this on the ledger golden: (ledger.Account as { kind: string }).kind is
"rec", while resolveSchema(ledger.Account).kind is
"record". Chains of two or more hops are ordinary — a declaration whose body is
another declaration — so resolve rather than unwrapping once.
Where to go next#
What each issue code means, the $-rooted path grammar, and the traversal budgets.
The complete mapping table, including the declarations that are left out.
Guide The code generatorHow a Contract graph becomes the annotated module this page's examples come from.