Migrating from 0.2.0

What changes in the 0.3 betas (@candid-core/schema 0.3.0-beta.1 and @candid-core/cli 0.2.0-beta.1, published under the beta dist-tag) for code written against @candid-core/schema 0.2.0 and @candid-core/cli 0.1.0, each with a before and an after the compiler checks.

This page is for code written against the published @candid-core/schema 0.2.0 or @candid-core/cli 0.1.0. Every other page on this site describes the surface the repository builds now, published on 2026-10-02 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 stays on 0.2.0 and 0.1.0. The record of each change, with the reasoning, is the 0.3.0-beta.1 entry of the @candid-core/schema changelog and the 0.2.0-beta.1 entry of the @candid-core/cli changelog.

Both changelogs state the compatibility non-goal in one line: @candid-core/schema 0.3 betas break the 0.2 API (principal values are canonical text, collapsing opts are boxed, every vec nat8 is a Uint8Array, generated modules use a new binding layout and may omit declarations, and the ./actor, ./transport-icp, ./forms and ./labels subpaths are gone); there is no compatibility layer.

Installing the beta#

The pair installs from the npm beta dist-tag, never from latest, which stays on 0.2.0 and 0.1.0. The CLI's optional peer is exactly 0.3.0-beta.1:

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

Keep both exact: one beta may break the next, and while the two packages move in lockstep every schema beta is paired with a new CLI beta that peers it exactly. Because the peer is optional, npm only warns about a mismatched pair (ERESOLVE overriding peer dependency); it does not refuse it.

Removing published subpaths is a breaking change, so the 0.3 line is a minor: under npm's pre-1.0 caret rules a ^0.2.0 dependency does not select it, and moving to it is a deliberate edit. Nothing on the wire moved. No byte a 0.2.0 encoder wrote is read differently, and Contract JSON and every identity are untouched; what changed is the TypeScript value shapes, the package's exports, what the generator emits, and what throws.

The snippets below are compiled

Each before block is 0.2.0-era code, and the site's check compiles it against this tree and requires the compiler to reject it with the error shown. Each after block must compile. The claim that a spelling stopped working is therefore the compiler's, not this page's.

What changedWhat it breaksWhere
A principal is canonical text, branded PrincipalCode that calls .toText() on a decoded principal, or hands an SDK object to encodePrincipals
opt opt T, opt null and opt reserved are { some: T } | nullCode that read or wrote those as a bare T | nullBoxed options
Every vec nat8 is a Uint8ArrayCode typed for number[] beside a declared nat8 aliasBlobs
Four subpaths are gone, and the optional SDK peerAny import of themRemoved subpaths
Bad options throw TypeError; encoded bytes do not depend on how a schema was builtA misspelled or non-integer limit that used to be ignoredStrict options
Generated modules bind $-prefixed locals and carry JSDocCode that parses or patches generated sourceGenerated modules
A declaration that cannot be represented is omitted, not refusedA script that relied on the generator's non-zero exitOmission
Closed unions gain or lose a memberAn exhaustive switch over a resource or a contract codeClosed unions

Principals#

A principal's domain value was { toText(): string }. It is now its canonical text, a plain string branded Principal in the type system only. The object could not be serialized (JSON.stringify gave {}), cloned (structuredClone threw), or compared with ===. The string does all three. Decoding returns the text, for a principal, for a func reference's principal and for a service reference alike. The root export PrincipalValue and its ./codec alias DecodedPrincipal are removed, with no deprecated alias left, because an alias would keep the object shape alive.

In the other direction encoding is strict: it accepts exactly the strings validate accepts, a string holding canonical text, and refuses everything else with invalid_type at the same path. It no longer calls toText(). principal(value) is the one conversion point. It takes a string or an object with toText(), an SDK Principal for one, and returns the text branded, or throws TypeError. It refuses rather than repairs: upper case, a misplaced dash, a checksum mismatch and an id over 29 bytes all throw. isPrincipal(value) is the matching guard.

ts
import { c, type Infer } from "@candid-core/schema";

const Account = c.record({ owner: c.principal });
declare const account: Infer<typeof Account>;

// 0.2.0: a decoded principal was an object with toText().
const text: string = account.owner.toText();
ts
import { c, type Infer } from "@candid-core/schema";

const Account = c.record({ owner: c.principal });
declare const sdkPrincipal: { toText(): string }; // an @icp-sdk/core Principal, say

// 0.2.0: any object with toText() was a principal, to validate and to encode.
const account: Infer<typeof Account> = { owner: sdkPrincipal };
ts
import { c, isPrincipal, principal, type Infer } from "@candid-core/schema";
import { encode } from "@candid-core/schema/codec";

const Account = c.record({ owner: c.principal });
declare const sdkPrincipal: { toText(): string }; // an @icp-sdk/core Principal, say

// Convert once, at your boundary. The result is the canonical text.
const owner = principal(sdkPrincipal);
const account: Infer<typeof Account> = { owner };
const text: string = account.owner; // no toText(): it is already the text

encode(Account, account); // { ok: true, bytes }
isPrincipal("AAAAA-AA"); // false, so principal("AAAAA-AA") throws TypeError

The other direction needs the SDK, which this package no longer knows about: code that wants a class instance from a decoded value converts with the SDK's own Principal.fromText(value). Principal is exported from the root with the same name as the SDK's class; import one of them under an alias. Equal principal text encodes to equal bytes whatever built the schema, and a string over 63 characters, the longest canonical text, is refused before any work proportional to its length. Source: schema.ts, the doc comments on Principal, principal and isPrincipal; the changelog entry Principals are canonical text.

Boxed options#

opt T is T | null unless T itself admits null. For exactly the three collapsing inners, opt opt T, opt null and opt reserved, the present value is boxed as { some: v }, so None, Some(None) and Some(Some(x)) stay three values. Every other opt is unchanged in type, value and wire bytes. In 0.2.0 an interface containing one of the three was refused at generation and at load, and a hand-built c.opt(c.opt(…)) silently decoded Some(None) as None. Interfaces such as Internet Identity's config now load.

ts
import { c, type Infer } from "@candid-core/schema";

const Description = c.opt(c.opt(c.text));

// 0.2.0: the present value was the bare inner value.
const present: Infer<typeof Description> = "x";
ts
import { c, isBoxedOpt, type Infer, type OptDomain } from "@candid-core/schema";
import { decode, encode } from "@candid-core/schema/codec";

const Description = c.opt(c.opt(c.text));
type Description = Infer<typeof Description>; // { some: string | null } | null

const none: Description = null; // None
const cleared: Description = { some: null }; // Some(None)
const set: Description = { some: "x" }; // Some(Some("x"))

const bytes = encode(Description, cleared); // the bytes any other opt would write
if (bytes.ok) decode(Description, bytes.bytes); // { ok: true, value: { some: null } }

isBoxedOpt(Description); // true; isBoxedOpt(c.opt(c.text)) is false
type Plain = OptDomain<bigint>; // bigint | null, unboxed

The decision is made on the resolved inner node, so a declared alias and a recursive type (type Chain = opt Chain) box the same way, and opt empty stays null. Validation and encoding require { some: v } for a present value (a bare v is invalid_type, and under opt reserved undefined is no longer read as present), and issues inside a box carry the some segment. OptSchema<T> now extends Schema<OptDomain<T>>. See the mapping reference.

Blobs#

A declared alias of nat8 (type Byte = nat8) used to turn every blob and vec Byte in the interface into number[], because the generator and the loader gave a declaration's name to every use of the node it named. A declaration of a primitive now names only itself, and every vec nat8 is a Uint8Array, in generated modules and in schemaFromContract alike. The encoded bytes are the same, since blob and vec nat8 are one wire type. The same change stops type Tokens = nat; type BlockIndex = nat renaming each other's uses: a field reads bigint, not whichever alias came first.

ts
import { c, type Schema } from "@candid-core/schema";

// 0.2.0, for `type Byte = nat8; type R = record { raw : vec Byte }`.
type R = { raw: number[] };
const R: Schema<R> = c.rec(() => c.record({ raw: c.blob() }));
ts
import { c, type Schema } from "@candid-core/schema";

type R = { raw: Uint8Array };
const R: Schema<R> = c.rec(() => c.record({ raw: c.blob() }));

Removed subpaths#

The package is the Candid layer only: schemas, validation, the codec and the Contract loader. The export map is exactly ., ./validate, ./contract and ./codec. Four subpaths that 0.2.0 exported are gone:

  • ./actor, with its actor factory, its call helper for a decoded func reference, its error class, and the Transport, CallTarget and ActorOptions types. Calling a canister is the job of the call layer built on this package. A func value stays the inert { principal, method } pair.
  • ./transport-icp, the @icp-sdk/core adapter. It built an agent with no identity, a second call path beside whatever identity-aware transport an application uses.
  • ./forms, the form-model builder. The code stays in the repository, compiled and tested, but is not built into the tarball.
  • ./labels, the label-hash and UTF-8 helpers. The module still ships because the codec and the loader import it, but it is not an entry point.

An import of any of them now fails at resolution, with ERR_PACKAGE_PATH_NOT_EXPORTED in Node and TS2307 in TypeScript, and so does a deep import into dist/. The optional @icp-sdk/core >= 6 peer is dropped with them, since only the transport used it. The package declares no runtime dependency and no peer of any kind, and the Node floor is 16 for every subpath. The packaged-consumer gate asserts each of these against the packed tarball.

ts
import { createActor } from "@candid-core/schema/actor";
ts
import { httpTransport } from "@candid-core/schema/transport-icp";

What replaces them is what a call layer already needed from this package and still has: the method table, and the codec that moves a call's arguments and reply as bytes. Sending those bytes, with the agent, the identity, polling and certificate verification, is not done here.

ts
import { serviceMethods } from "@candid-core/schema";
import { decodeArgs, encodeArgs } from "@candid-core/schema/codec";
import { actor, type TransferArg } from "./ledger.ts";

declare const arg: TransferArg;
declare function send(method: string, payload: Uint8Array): Promise<Uint8Array>; // your agent

const transfer = serviceMethods(actor).get("transfer");
if (transfer === undefined) throw new Error("the interface has no transfer method");

const request = encodeArgs(transfer.args, [arg]);
if (!request.ok) throw new Error(request.issues[0].message);

const reply = decodeArgs(transfer.results, await send(transfer.name, request.bytes));
if (reply.ok) reply.values[0]; // the decoded TransferResult, as unknown until you narrow it

Strict options, and bytes that do not depend on the schema#

Every entry point that takes an options object, validate and unwrapResult, encode, encodeArgs, decode and decodeArgs, and schemaFromContract, throws TypeError on an own key it does not define and on a limit that is not a non-negative safe integer: NaN, a negative, a fraction, a string, null and Infinity all throw, and so does maxIssues passed to encode, which only validate defines. In 0.2.0 a misspelled limit applied the default silently, and a NaN limit switched its bound off: a depth-300 value validated under maxDepth: NaN. undefined still means the default and 0 is still a valid, fail-closed limit. A host that wants no practical bound passes a large safe integer. Each option is read from your object exactly once, into a frozen snapshot the whole call reads instead, so a getter or a Proxy cannot pass the check with one value and run with another. Values, byte strings and Contract documents still never make these functions throw.

ts
import { c } from "@candid-core/schema";
import { validate } from "@candid-core/schema/validate";

// A typed caller now meets the misspelling at compile time; a caller with
// an untyped options object meets a TypeError at run time.
validate(c.nat, 1n, { maxDeph: 10 });
ts
import { c } from "@candid-core/schema";
import { validate } from "@candid-core/schema/validate";

declare const limit: number; // read from your configuration
try {
  validate(c.vec(c.nat8), [], { maxDepth: limit });
} catch (error) {
  if (!(error instanceof TypeError)) throw error; // a NaN, a fraction, a string: the caller's bug
}

The second change is to bytes. encode and encodeArgs write a structural type table, so the same value encoded through a generated module, through schemaFromContract and through a hand-built schema produces one byte string, however each shares or duplicates nodes and in whatever order a record's schema or value spells its keys. Anything that keys a cache or deduplicates requests on argument bytes now sees one key for one call. A schema with no repeated structure writes the bytes it always wrote, and one with repeated anonymous structure writes a smaller table that every decoder still reads. Two separately hand-built knots for one recursive type are not merged. Source: options.ts and typetable.ts headers, and the codec header's Determinism section.

Generated modules#

The generator changes the text of every module it emits, and nothing about what a module exports. The schema runtime is imported as the namespace $, and every declaration is bound as a $-prefixed local exported under its Candid name. A Candid name cannot contain $, so a declaration named c, Schema, Array or delete generates instead of being refused, and you import it by name (import { delete as del } from "./service"). Code that imports the generated names is unaffected; code that parses or patches the generated source, or relied on its module-scope names, must follow the new layout.

ts
// 0.2.0 layout: bare bindings, and a separate import for the principal type.
import { c, type PrincipalValue, type Schema } from "@candid-core/schema";

export type Tokens = { e8s: bigint };
export const Tokens: Schema<Tokens> = c.rec(() => c.record({ e8s: c.nat64 }));
tscrates/candid-core-ts/tests/goldens/ledger.ts
import * as $ from "@candid-core/schema";
// …
type $Tokens = { e8s: bigint };
const $Tokens: $.Schema<$Tokens> = $.c.rec(() => $.c.record({ e8s: $.c.nat64 }));
export { $Tokens as Tokens };
ts
import { Tokens, actor, type Actor } from "./ledger.ts"; // the same import as before
import { Array as Arrays, delete as del } from "./shadowing.ts"; // names that used to be refused

void [Tokens, actor, Arrays, del];
declare const ledger: Actor;
void ledger.fee;

Principals follow the change above: the generated type is $.Principal where it was PrincipalValue, for principal fields, func references ({ principal: $.Principal; method: string }), service references and the actor schema. A declaration named Principal still generates. Two more things came with the layout. The .did's doc comments and argument names become JSDoc, above each exported type and const, on record properties and variant arms, and on the methods of Actor with a @param per named argument, so an editor hover shows what the interface says. And a use of a primitive renders structurally (see Blobs).

tscrates/candid-core-ts/tests/goldens/docs.ts
import * as $ from "@candid-core/schema";
// …
/** The amount of a transfer, in e8s. */
type $Tokens = bigint;
/** The amount of a transfer, in e8s. */
const $Tokens: $.Schema<$Tokens> = $.c.rec(() => $.c.nat);
export { $Tokens as Tokens };

Declarations named actor or Actor are the module's own export names and stay reserved. The generated actor schema and Actor interface type are still emitted, under those names.

Omission instead of refusal#

The generator no longer refuses a whole interface for one declaration it cannot represent. It leaves that declaration out, with every declaration and actor method that references it, and lists what it left out. Four causes remain: a field or arm named like the _N_ id rendering, a variant arm whose payload is a declared opt of an uninhabited type, a declaration named actor or Actor, and, from a Contract document only, a name that is not identifier-shaped. The exact rules, the reason codes and the closure are on the code generator page. What changes for a consumer:

  • Exit status. candid-core-cli gen used to print a ts_generation_refused document and exit 1 for such an interface. It now writes the module, prints one warning: omitted … line per entry on stderr, and exits 0. A script that relied on the non-zero exit to reject the interface must read the warnings or the list.
  • Library result. didToModule returns { ok: true, module, omitted } for those inputs, where it returned { ok: false, diagnostics }. omitted is always present, and empty when nothing is left out. ts_generation_refused is now reserved for an invalid Contract graph, which Candid source never produces.
  • The loader. schemaFromContract omits exactly what the generator omits, with the same reasons, and its success carries the same omitted list. A name-table entry shaped like _N_ that honestly hashes to its id no longer fails the document. A declaration whose name is not identifier-shaped is now omitted there, where 0.2.0 loaded it. An invalid document still fails whole.
  • The header. The module lists each entry as a // Omitted: line under its first line. A module that omits nothing has no such line and is byte-identical to what it would have been.

Closed unions#

Three closed unions changed, and an exhaustive switch over them stops compiling until it is updated. resource_limit_exceeded issues gain a "stack" resource in both ResourceLimitInfo["resource"] (./validate) and CodecResourceLimitInfo["resource"] (./codec). It means the host's own call stack ran out, and only user code a walk calls, a getter, a Proxy trap or a rec thunk that itself recurses too deeply, can cause it: the walkers keep their work on explicit stacks. Before, such an overflow was mislabelled unreadable_value or unsupported_schema. ContractIssueCode loses unrepresentable_option, because the documents it refused now load.

ts
import type { ResourceLimitInfo } from "@candid-core/schema/validate";

function describe(info: ResourceLimitInfo): string {
  switch (info.resource) {
    case "value_depth":
      return "too deep";
    case "value_elements":
      return "too many elements";
    default: {
      const unhandled: never = info.resource; // 0.2.0: exhaustive with two cases
      return unhandled;
    }
  }
}
void describe;
ts
import type { ContractIssueCode } from "@candid-core/schema/contract";

function isOption(code: ContractIssueCode): boolean {
  return code === "unrepresentable_option";
}
void isOption;
ts
import type { ResourceLimitInfo } from "@candid-core/schema/validate";

function describe(info: ResourceLimitInfo): string {
  switch (info.resource) {
    case "value_depth":
      return "too deep";
    case "value_elements":
      return "too many elements";
    case "stack":
      return "user code recursed past the host stack";
    default: {
      const unhandled: never = info.resource;
      return unhandled;
    }
  }
}
void describe;

Two behaviours changed without changing a type. The limits are the only bounds on a walk: DEFAULT_MAX_DEPTH stays 256, and with maxDepth raised a 100,000-level value validates, encodes and decodes on any engine, where the recursive walkers used to overflow the host stack from about 1,500 to 2,600 levels. And encode charges maxDepth for Candid nesting depth only, never for a rec hop, so any type the candid-core compiler accepts encodes through its generated module at the default limits, however it is split into declarations. The codec page states both with their numbers.

Order of upgrade#

The pieces are released together and depend on each other. A module the new generator emits imports Principal from @candid-core/schema, which 0.2.0 does not export, and may contain boxed aliases 0.2.0 cannot type. The @candid-core/cli that ships the generator therefore must raise its @candid-core/schema peer range to the release that carries these changes, and a generated module and a loader from opposite sides of the blob change disagree about vec Byte. Move both packages in one step, regenerate committed modules, and then fix what the compiler reports with the sections above. The generated module and the loader are held to the same verdicts by the repository's cross-check, so regenerating and loading agree with each other.