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:
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.
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 changed | What it breaks | Where |
|---|---|---|
A principal is canonical text, branded Principal | Code that calls .toText() on a decoded principal, or hands an SDK object to encode | Principals |
opt opt T, opt null and opt reserved are { some: T } | null | Code that read or wrote those as a bare T | null | Boxed options |
Every vec nat8 is a Uint8Array | Code typed for number[] beside a declared nat8 alias | Blobs |
| Four subpaths are gone, and the optional SDK peer | Any import of them | Removed subpaths |
Bad options throw TypeError; encoded bytes do not depend on how a schema was built | A misspelled or non-integer limit that used to be ignored | Strict options |
Generated modules bind $-prefixed locals and carry JSDoc | Code that parses or patches generated source | Generated modules |
| A declaration that cannot be represented is omitted, not refused | A script that relied on the generator's non-zero exit | Omission |
| Closed unions gain or lose a member | An exhaustive switch over a resource or a contract code | Closed 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.
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();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 };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.
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";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.
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() }));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 theTransport,CallTargetandActorOptionstypes. 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/coreadapter. 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.
import { createActor } from "@candid-core/schema/actor";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.
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 itStrict 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.
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 });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.
// 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 }));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 };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).
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 genused to print ats_generation_refuseddocument and exit 1 for such an interface. It now writes the module, prints onewarning: 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.
didToModulereturns{ ok: true, module, omitted }for those inputs, where it returned{ ok: false, diagnostics }.omittedis always present, and empty when nothing is left out.ts_generation_refusedis now reserved for an invalid Contract graph, which Candid source never produces. -
The loader.
schemaFromContractomits exactly what the generator omits, with the same reasons, and its success carries the sameomittedlist. 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.
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;import type { ContractIssueCode } from "@candid-core/schema/contract";
function isOption(code: ContractIssueCode): boolean {
return code === "unrepresentable_option";
}
void isOption;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.