Schemas from a Contract document

Building the same schemas dynamically from canonical Contract JSON, with no code generation step.

Published as a beta

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: it refuses a document with a collapsing opt (opt opt T, opt null, opt reserved), builds a number[] for a vec nat8 beside a declared nat8 alias, and lists no omissions. 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.

The code generator turns a Contract into a TypeScript module at build time. That is the right shape when you have the .did file in your repository. It is the wrong shape when you do not know the interface until the program is running — a canister explorer, a wallet that must render an arbitrary call, an agent tool handed a canister id by a user.

@candid-core/schema/contract covers that case. schemaFromContract takes a canonical Contract document — the JSON the candid-core compiler prints from a .did file — and returns the same c.* schema objects the generator would have emitted, built at runtime, with no code generation step and no eval. Every mapping decision the generator makes is applied here too, and a cross-check test holds the two paths to identical verdicts.

The signature#

schemaFromContract ./contract
tscrates/candid-core-ts/ts/contract.ts
export function schemaFromContract(
  contract: unknown,
  options: ContractSchemaOptions = {},
): SchemaFromContractResult

The first parameter is unknown on purpose: the document is untrusted input, and accepting a typed parameter would imply a check that has not happened yet.

tscrates/candid-core-ts/ts/contract.ts
export type FieldNameEntry = readonly [container: number, id: number, name: string];

export interface ContractSchemaOptions {
  readonly names?: readonly FieldNameEntry[];
  /** Arena size cap, mirroring `Limits::max_type_nodes`. */
  readonly maxTypeNodes?: number;
  /** Total field cap across all nodes, mirroring `Limits::max_fields`. */
  readonly maxFields?: number;
  /** Declaration count cap, mirroring `Limits::max_declarations`. */
  readonly maxDeclarations?: number;
}

type OmissionReason =
  | "reserved_field_name"
  | "ambiguous_variant_arm"
  | "reserved_export_name"
  | "invalid_declaration_name"
  | "references_omitted";

type Omission = {
  readonly kind: "declaration" | "method";
  readonly name: string;
  readonly reason: OmissionReason;
  readonly via?: string;
};

export type SchemaFromContractResult =
  | {
      readonly ok: true;
      readonly schemas: { readonly [name: string]: AnySchema };
      readonly actor?: AnySchema;
      readonly omitted: readonly Omission[];
    }
  | { readonly ok: false; readonly issues: readonly ContractIssue[] };

On success you get one schema per declaration, keyed by declaration name and built in declaration order, plus actor when the .did file declared a service, and omitted: the declarations and actor methods left out, exactly as a generated module leaves them out for the same Contract (see the mapping decisions). The map is built with Object.create(null), so a declaration or field legitimately named __proto__ is an ordinary own key rather than a prototype write.

The input: a Contract document and a name table#

A Contract is a flat, index-addressed JSON document. The types array is the arena: every type node is one entry, and every reference between types is an integer index into it. declarations names some of those nodes. actor, when present, points at the service. Because references are indices, a recursive type is a node whose index chain loops — there is no nesting and no cycle in the JSON itself.

This is a checked-in golden, with only identities and producer removed and the objects folded onto single lines:

jsoncrates/candid-core-ts/tests/goldens/collections.contract.json
{
  "canonicalization_profile": "candid-core-canon-1",
  "declarations": [
    { "name": "Grid", "type": 7 },
    { "name": "Item", "type": 3 },
    { "name": "Items", "type": 8 },
    { "name": "MaybeItem", "type": 2 },
    { "name": "Note", "type": 0 },
    { "name": "Pair", "type": 10 },
    { "name": "Unit", "type": 9 }
  ],
  "format": "candid-core",
  "format_version": 1,
  "semantics_profile": "candid-1",
  "types": [
    { "inner": 1, "kind": "opt" },
    { "kind": "primitive", "primitive": "text" },
    { "inner": 3, "kind": "opt" },
    { "fields": [
        { "id": 23515, "type": 4 },
        { "id": 1873743348, "type": 1 },
        { "id": 3979722638, "type": 5 }
      ], "kind": "record" },
    { "kind": "primitive", "primitive": "nat32" },
    { "inner": 6, "kind": "vec" },
    { "kind": "primitive", "primitive": "nat8" },
    { "inner": 5, "kind": "vec" },
    { "inner": 3, "kind": "vec" },
    { "fields": [], "kind": "record" },
    { "fields": [{ "id": 0, "type": 11 }, { "id": 1, "type": 1 }], "kind": "record" },
    { "kind": "primitive", "primitive": "nat" }
  ]
}

Read a few of those nodes and the mapping rules below become concrete. Node 5 is a vec over node 6 (nat8), so node 5 is a blob — which is why Item's payload is a Uint8Array and Grid (node 7, a vec of node 5) is Array<Uint8Array>. Node 9 is the empty record, so Unit is the unit schema. Node 10's field ids are exactly 0 and 1, so Pair is a tuple, not a record.

Note what is not in there: field names. A semantic Contract stores authoritative label ids — the 32-bit numbers Candid actually puts on the wire — because the names are source text, not semantics. Node 3's fields are 23515, 1873743348 and 3979722638, not id, label and payload. Names travel beside the document, as [container, id, name] triples:

jsoncrates/candid-core-ts/tests/goldens/collections.names.json
[
  [3, 23515, "id"],
  [3, 1873743348, "label"],
  [3, 3979722638, "payload"]
]

Four markers must match exactly, or the document is refused outright:

KeyRequired valueCode if it differs
format"candid-core"unsupported_contract_format
format_version1unsupported_format_version
semantics_profile"candid-1"unsupported_semantics_profile
canonicalization_profile"candid-core-canon-1"unsupported_canonicalization_profile

A document claiming a different profile is not half-read on the assumption that it is close enough. A future format revision has to be adopted deliberately.

One document instead of two#

candid-core compile <path> --envelope prints a ContractEnvelope instead: { contract, extensions }, where the org.candid-core.field-names/v1 extension carries the name table. schemaFromContract accepts that shape directly and consumes the table, so one self-describing file replaces the pair. It detects an envelope by the presence of a contract key, which no canonical Contract document has.

Extensions live outside the canonical hashes by design, so carrying names never moves a contract_id or an interface_id. See content-addressed identities for why that separation matters.

A successful load is not provenance

A canonical Contract carries identities.contract, a candid-core:contract:v1:sha256:… string. schemaFromContract does not verify it. Checking it needs the canonicalization procedure, which lives in the Rust crate — and an unkeyed content id does not authenticate itself in any case. Treat a successful load as "this document is well-formed", never as "this document came from where it says".

Why the name table is hash-enforced#

Every entry must be the Candid preimage of its own id — the label hash of name must equal id — checked at load time, on caller-supplied and envelope-carried tables alike. A table that fails it is refused with invalid_name_table, naming what the string actually hashes to.

This is not bookkeeping. The codec derives each field's wire id from its rendered schema key — a key spelled _N_ is the id N, and every other key is hashed. So if a table claimed the name "owner" for an id that is really the hash of "operator", the built schema would carry the key owner, the codec would hash that key back to a different number, and the message would name a field the canister never declared. The interface would silently mean something other than what the Contract says. Hash enforcement makes a lying table fail closed instead.

Names shaped like _N_ never become keys, for the same reason: erased to a schema key, a source name spelled _5_ is indistinguishable from the numeric rendering of id 5, and the two derive different wire ids. Such a name that honestly hashes to its id is real provenance, so it does not fail the table; the declaration that would render it is omitted instead (reserved_field_name), as the generator omits it. One that does not hash to its id is a lying entry like any other.

Passing no table at all is legal. Every field then renders as _<id>_, which is also what candid-core compile --no-source-info leaves you with. The schemas are correct and encode to identical bytes — only the readable keys are gone.

The mapping decisions#

These are the same owner-reviewed decisions the generator applies, and the cross-check test is what keeps them from drifting apart.

Every vec nat8 becomes blob
A vec whose inner node is nat8 becomes c.blob(), so the value is a Uint8Array, whatever the element is called. A declaration of a primitive (type Byte = nat8) names only itself, so declaring one never changes the value domain of another declaration's blobs. The encoded bytes are the same either way, since blob and vec nat8 are one wire type.
Tuple-shaped records become tuples
A record whose field ids are exactly 0..n-1 becomes c.tuple([…]). An empty record becomes c.unit(). Everything else becomes c.record({…}) with keys from the name table, falling back to _<id>_.
Collapsing options are boxed
An opt whose inner node is opt, null or reserved builds like any other opt, and its present values are { some: v }, because T | null cannot distinguish None from Some(None) there. The decision is on the arena node, so type Inner = opt nat; type Outer = opt Inner boxes the same way (an alias is not indirection in the arena), and so does a self-referential type Chain = opt Chain. It is the generator's rule, so a loaded schema and a generated one accept the same values.
Reference types build like everything else
A func node becomes c.func(args, results, mode) and describes { principal, method } values. A service node becomes c.service({…}), keyed by the method names the document itself carries, and describes principal values.
The actor, and the class rule
actor is returned as a service schema. A class actor is unwrapped to its running service — init arguments are install-time metadata a call surface never consumes. A class node anywhere else is refused, mirroring two of candid-core's own rules: class_not_first_class_type, so no type edge may target a class, and class_not_actor_root, so a named declaration must never target one either — even when that class is the actor root.
What the generator omits, the loader omits

A declaration no generated module can represent is left out of schemas, together with every declaration and actor method that references it — through nested func and service types too, up to the containing declaration, since dropping a method from a service type in value position would change its wire type — and listed in omitted as { kind, name, reason, via? }, declarations first, then methods, each by name in code-point order. The reasons are the generator's: reserved_field_name (an honest _N_-shaped field or arm name), ambiguous_variant_arm (an arm whose payload is a declared opt of an uninhabited type), reserved_export_name (a declaration named actor or Actor), invalid_declaration_name (a name that is not identifier-shaped) and references_omitted, whose via names the omitted declaration referenced. The actor is never omitted; it loses only the listed methods.

The loader could build some of these; it leaves them out so that its schemas are exactly the declarations a generated module exports. Declaration names that are not identifier-shaped used to load here as a deliberate divergence from the generator, and are omitted now for that parity; reserved words such as delete are identifier names and load as before. An invalid document still fails whole.

A worked example#

The collections fixture above is a real compiler output, checked into the repository as a golden. Here is the whole flow that produces it and uses it.

  1. Install the runtime#

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

    No other dependency is needed: the package declares no runtime dependency and no peer.

  2. Compile the .did file#

    bash
    cargo install candid-core --version 0.1.0-beta.3 --locked
    candid-core compile ./collections.did > ./compiled.json

    candid-core is pre-1.0 with only prereleases on crates.io, so the version has to be explicit — a bare cargo install candid-core resolves to nothing. Plain compile prints { ok, contract, source_info }: the Contract document, plus the provenance sidecar that carries the field names.

  3. Split out the Contract and the name table#

    ts
    import { readFileSync } from "node:fs";
    import { schemaFromContract, type FieldNameEntry } from "@candid-core/schema/contract";
    import { validate } from "@candid-core/schema/validate";
    
    interface LabelProvenance {
      container: number;
      id: number;
      label: { kind: "named"; name: string } | { kind: "numeric" | "positional" };
    }
    
    const compiled = JSON.parse(readFileSync("./compiled.json", "utf8")) as {
      contract: unknown;
      source_info: { field_labels: LabelProvenance[] };
    };
    
    // Only named labels carry text; numeric and positional ones keep `_id_`.
    const names: FieldNameEntry[] = compiled.source_info.field_labels
      .filter((entry) => entry.label.kind === "named")
      .map((entry): FieldNameEntry => [
        entry.container,
        entry.id,
        (entry.label as { name: string }).name,
      ]);

    These are the triples shown earlier as collections.names.json. The table the loader keeps is one name per (container, id), last entry winning — so if two entries address the same pair, the later one decides the key. --envelope emits a sorted, deduplicated table instead, which is what the generator's own name tables use.

  4. Build the schemas and validate a value#

    ts
    const built = schemaFromContract(compiled.contract, { names });
    if (!built.ok) {
      console.error(built.issues); // [{ code, path, message, resource_limit? }]
      process.exit(1);
    }
    
    const Item = built.schemas.Item;
    
    validate(Item, { id: 7, label: "seven", payload: Uint8Array.from([7]) });
    // { ok: true }
    
    validate(Item, { id: 7, label: "seven" });
    // { ok: false, issues: [{ code: "missing_field", path: "$.payload", … }] }
    
    validate(Item, { id: 7, label: "seven", payload: [7] });
    // { ok: false, issues: [{ code: "invalid_type", path: "$.payload", … }] } — blob wants a Uint8Array

    built.schemas.Item is an ordinary schema. It goes into encode/decode and into validate exactly like a generated one.

With candid-core compile ./collections.did --envelope > ./service.json, steps two and three collapse into one read and the names option goes away entirely:

tscrates/candid-core-ts/ts/README.md
const built = schemaFromContract(JSON.parse(readFileSync("./service.json", "utf8")));

built.schemas.Account; // one Schema per declaration, in declaration order
built.actor; // the service schema, when the document has one

An explicit names option always wins over an envelope-carried table, and when it does, the envelope's table is not consulted at all — so a lying envelope cannot fail a build that supplies its own valid names.

Bounds and failure#

The document is data from outside, so the loader is bounded the same way the rest of the project is. All checks run in one pass over the arena before anything is built, and then every edge becomes a lazy memoized c.rec thunk — construction depth stays constant even for an adversarially deep type graph.

OptionDefaultMirrors
maxTypeNodes100_000Limits::max_type_nodes
maxFields500_000Limits::max_fields
maxDeclarations100_000Limits::max_declarations
(name table entries)500_000caller-side; shares the field cap's magnitude

Nothing throws for a document — not even when the document itself fights inspection with accessors or Proxy traps, which become an invalid_contract_document issue at the choke point. The options object is code, not document data, and is checked before the document is read: an own key other than names and the three caps above, or a cap that is not a non-negative safe integer (NaN, negative, fractional, a string, null, Infinity), throws TypeError. 0 is a valid cap that refuses any non-empty document part it bounds. ContractIssueCode is a closed union of 14 members; several deliberately reuse the codes candid-core's own Rust validator produces for the same condition, so the two loaders refuse the same documents under the same names.

CodeRefused because
invalid_contract_documentMalformed shape, an unknown node kind, an unknown primitive name, or a structurally invalid reference.
unsupported_contract_format, unsupported_format_version, unsupported_semantics_profile, unsupported_canonicalization_profileOne of the four markers does not match.
dangling_type_refA type reference points outside the arena.
unsupported_constructA class node nests inside a supported type.
duplicate_field_id, duplicate_field_nameTwo fields of one node share an id, or two ids render to the same key.
empty_declaration_name, duplicate_declaration_nameA declaration has no name, or two share one.
invalid_name_tableAn entry is not [container, id, name], or does not hash to its id. (An honest _N_-shaped name omits its declaration instead; see above.)
invalid_extension_nameAn envelope extension key is not a reverse-domain name followed by /v<integer>.
resource_limit_exceededOne of the caps above tripped; the issue carries { resource, limit, observed }.

When to build at runtime, and when not to#

Prefer schemaFromContract when the interface is a runtime input.

  • A canister explorer or dashboard where the user supplies the canister id.
  • A wallet or signer that must render an arbitrary call for approval.
  • An agent tool that receives an interface as data and has to act on it.
  • A JavaScript-only deployment where adding a build step is not on the table.

Prefer the generator when you have the .did file at build time.

  • You want static types. built.schemas.Item is AnySchema — the map's keys are not known to the compiler, and a typo is a runtime undefined. A generated module gives you Item as a named export with a reviewed type alias beside it, and tsc itself proves the two agree.
  • You want the interface pinned in review. A generated module is a diffable artefact; a document fetched at runtime is not.
  • You are near the traversal depth limit. A dynamically built schema carries one extra rec hop per arena edge, so a deeply nested value can trip value_depth under the dynamic schema and not under the generated one.

The cross-check that ties the two together#

The claim "the same schemas the generator would have emitted" is a test, not a hope. For every golden fixture, the suite builds the schema set both ways — from the checked-in Contract document and from the generated module — and asserts by deep equality on the whole validate result that they agree on every sample value: same verdict, same issue codes, same paths, same messages. Both documents are emitted from the same fixture, so the two schemas under comparison share one source of truth.

tscrates/candid-core-ts/ts/tests/crosscheck.test.ts
const built = schemaFromContract(contract, { names });

for (const [declaration, samples] of Object.entries(fixture.samples)) {
  const generated = fixture.module[declaration] as AnySchema;
  const dynamic = built.schemas[declaration];
  for (const sample of samples) {
    assert.deepStrictEqual(
      validate(dynamic, sample),
      validate(generated, sample),
      `${fixture.name}.${declaration} diverged on ${String(sample)}`,
    );
  }
}

Two further checks extend it. The envelope path is held to the two-file path's verdicts, so reading names out of an extension cannot diverge from being handed them. And in the codec suite, every reference wire vector is decoded twice — once under the generated schema and once under the dynamic one — and both must produce the same value and re-encode to byte-identical output. The cross-check's own samples deliberately stay far from the depth limit, because of the extra rec hop noted above: near-limit values would diverge on the resource issue alone.

What those gates establish, and what they do not, is set out on Guarantees and verification. The Contract format is not a stable v1: any pre-1.0 release may change the serialized shapes, and therefore every identity computed over them. The loader is crates/candid-core-ts/ts/contract.ts, and its header records each mapping decision against the issue that settled it.

Next#