Migrating from 0.2.0

What changes in @candid-core/schema 0.3.0 and @candid-core/cli 0.2.0, published on 2026-10-06 (and in the betas 0.3.0-beta.1 and 0.2.0-beta.1 before them), for code written against @candid-core/schema 0.2.0 and @candid-core/cli 0.1.0, with before and after code where the compiler shows it.

This page is for code written against @candid-core/schema 0.2.0 or @candid-core/cli 0.1.0, and takes it to the surface published on 2026-10-06 as @candid-core/schema 0.3.0 and @candid-core/cli 0.2.0 under the npm latest dist-tag. latest has moved on since, to 0.4.0 and 0.3.0, published on 2026-10-10, and then to 0.5.0 and 0.4.0, published on 2026-10-27; Migrating from 0.3.0 and Migrating from 0.4.0 cover those steps. Most of the surface below was first published on 2026-10-02 as 0.3.0-beta.1 and 0.2.0-beta.1 under the beta dist-tag, which still names those betas. The record of each change, with the reasoning, is the 0.3.0 and 0.3.0-beta.1 entries of the @candid-core/schema changelog and the 0.2.0 and 0.2.0-beta.1 entries of the @candid-core/cli changelog.

Both changelogs state the compatibility non-goal in one line: @candid-core/schema 0.3 breaks 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 stable pair#

A plain install selects the newest stable pair from latest, @candid-core/schema 0.5.0 and @candid-core/cli 0.4.0, whose optional peer is exactly 0.5.0. Everything below applies there too, and where the CLI's 0.3.0 or 0.4.0 differs this page says so; Migrating from 0.3.0 and Migrating from 0.4.0 have those steps:

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

Keep both exact: while the two packages move in lockstep every schema release is paired with a CLI release that peers it exactly, and npm refuses to install a mismatched pair (ERESOLVE could not resolve).

If you installed the betas with @beta, those lines move you to the stable pair. The beta dist-tag still names 0.3.0-beta.1 and 0.2.0-beta.1, which lack the changes recorded under 0.3.0 and 0.2.0.

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 decodes to a different value, 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
schemaFromContract refuses unknown keys, unreachable type nodes and a rootless arenaA hand-written or tool-mutated Contract documentStricter Contract loading
A rec hop no longer counts against maxDepth or maxElementsA value with a tag-only variant at exactly a caller-set maxDepth, or a message that omits an opt field at a maxDepth edge or past maxElements, which a large message reaches at the default limitsResource limits
The CLI refuses a run of more than 256 consecutive commentsA .did with a long // headerLong comment runs
The CLI refuses a type on a cycle through opt aloneA script that matched on ts_generation_refused for itCycles through opt

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 box the same way (an opt node that is its own inner, the graph of type Chain = opt Chain, boxes too, though the compiler now refuses that source), 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: `results` is an erased list

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. In @candid-core/cli 0.3.0, published on 2026-10-10, the type moves back to the Candid name (export type Tokens = …), so a compiler error names Tokens; only the value keeps the $ local, and a type keeps it too only where the name cannot be a type in the module (Array, delete). The excerpts below show that layout, 0.3.0's; 0.2.0 writes type $Tokens = … and $.Schema<$Tokens>. Migrating from 0.3.0 has the rest of that step.

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";
// …
export 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. */
export 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, and gains unknown_key, orphan_type_node and rootless_type_arena, the refusals of stricter Contract loading below; it has 17 members.

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 { ContractIssueCode } from "@candid-core/schema/contract";

function kind(code: ContractIssueCode): string {
  switch (code) {
    case "invalid_contract_document":
    case "unsupported_contract_format":
    case "unsupported_format_version":
    case "unsupported_semantics_profile":
    case "unsupported_canonicalization_profile":
      return "format";
    case "dangling_type_ref":
    case "unsupported_construct":
    case "duplicate_field_id":
    case "duplicate_field_name":
      return "types";
    case "empty_declaration_name":
    case "duplicate_declaration_name":
    case "invalid_name_table":
    case "invalid_extension_name":
      return "names";
    case "resource_limit_exceeded":
      return "limits";
    default: {
      const unhandled: never = code; // 0.2.0's codes, less unrepresentable_option
      return unhandled;
    }
  }
}
void kind;
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;
ts
import type { ContractIssueCode } from "@candid-core/schema/contract";

function kind(code: ContractIssueCode): string {
  switch (code) {
    case "invalid_contract_document":
    case "unsupported_contract_format":
    case "unsupported_format_version":
    case "unsupported_semantics_profile":
    case "unsupported_canonicalization_profile":
    case "unknown_key":
      return "format";
    case "dangling_type_ref":
    case "unsupported_construct":
    case "duplicate_field_id":
    case "duplicate_field_name":
    case "orphan_type_node":
    case "rootless_type_arena":
      return "types";
    case "empty_declaration_name":
    case "duplicate_declaration_name":
    case "invalid_name_table":
    case "invalid_extension_name":
      return "names";
    case "resource_limit_exceeded":
      return "limits";
    default: {
      const unhandled: never = code;
      return unhandled;
    }
  }
}
void kind;

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.

Resource limits: a rec hop is not charged#

In 0.3.0, validate, encode and decode charge a value for its constructors alone. 0.2.0 and 0.3.0-beta.1 also charged every rec hop a depth step and an element, and a Contract-loaded schema makes every type reference a hop, so its values reached the default maxDepth at about 128 levels and maxElements at about 500,000 elements. Now a generated, a Contract-loaded and a hand-built schema of one type accept the same values, 256 levels deep at the defaults as candid-core does, and 1,000,000 elements wide, where validate and encode also count each record or variant key they examine. Two narrow cases are refused that were accepted:

  • a tag-only variant ({ tag: "a" } for an arm of type null) sitting at exactly maxDepth: its null is now a level below it in validate and encode, as it already was in decode. At the default of 256 no type the compiler accepts reaches it;
  • a message that omits an opt, null or reserved field: the null that decode supplies is now charged at the field's own level and as one element. A record at exactly a maxDepth you set that omits one is refused, and so is a message whose omitted fields take it past maxElements, which can happen at the default limits: a vec of 500,000 or more records that each omit such a field the reader expects costs one element more per record, so a vec record {} of 500,000 elements read at vec record { x : opt nat } is now refused. Both are refused with resource_limit_exceeded, as validate refuses the same value.

A chain of more than maxDepth consecutive hops is still refused with value_depth, so with maxDepth set to 0 a generated module refuses a value at its first declaration reference, and at 1 wherever it reaches an alias of another declaration (a two-hop chain). The changelog's 0.3.0 entry lists every case.

Stricter Contract loading#

schemaFromContract now refuses three kinds of Contract document that 0.2.0 loaded, because candid-core's own loader, Contract::from_json, refuses them. Every Contract and envelope candid-core compile writes still loads exactly as before, with the same schemas, actor and omitted; only a hand-written or tool-mutated document is affected.

  • Unknown keys (unknown_key, at the key's path, such as $.types[0].extra). The root, a type node, a field or arm, a method, a declaration and the actor accept only the keys the format defines for them, and a key of another node kind counts as unknown, so an opt node carrying fields is refused. An unknown envelope key, already refused, is now reported the same way ($.format), where 0.2.0 reported invalid_contract_document at $.
  • Unreachable type nodes (orphan_type_node, one issue per node at $.types[i]): every node must be reachable from a declaration or the actor, a class actor's init arguments included.
  • A rootless arena (rootless_type_arena at $.types): a non-empty types with no declarations and no actor. An empty one still loads.

A tool that writes or edits Contract documents must emit only the format's keys and drop nodes nothing reaches; code that matched on invalid_contract_document for an unknown envelope key must match unknown_key. The three codes are in the closed union above.

Long comment runs#

@candid-core/cli 0.2.0 refuses a .did file with more than 256 consecutive comments between two tokens, line and block comments alike, with resource_limit_exceeded and resource source_nesting (limit 256), exit status 1. 0.1.0 and 0.2.0-beta.1 compiled such a run up to a stack-dependent length, about 2,700 to 2,800 comments, and then failed. The likely case is a license header written as hundreds of // lines: write it as one /* */ block, which counts as one comment, or shorten it. Blank lines between comments do not end a run; any token does. The CLI has no flag to raise the limit.

Cycles through opt#

@candid-core/cli 0.2.0 refuses a type that lies on a cycle passing only through opt, such as type T = opt T;, with did_type_check_error naming the type, exit status 1. No decoder can answer for a value that is not an opt at such a type. 0.1.0 refused the same files at generation, with ts_generation_refused, because each such type wraps opt in opt; 0.2.0-beta.1 generated them, boxed. A cycle with any other constructor on it, such as type T = opt vec T, still compiles.

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.