Migrating from 0.4.0

What changes in @candid-core/schema 0.5.0 and @candid-core/cli 0.4.0, published on 2026-10-27 under the latest dist-tag, for code written against @candid-core/schema 0.4.0 and @candid-core/cli 0.3.0: decoded values typed by their schemas, envelopes from the renamed compiler, and fixed shapes in generated modules, which need the two packages upgraded together.

This page is for code written against @candid-core/schema 0.4.0 and @candid-core/cli 0.3.0, the stable pair published on 2026-10-10, and takes it to the next one, @candid-core/schema 0.5.0 and @candid-core/cli 0.4.0, published on 2026-10-27 under the npm latest dist-tag. Every change is recorded under those two versions in the @candid-core/schema changelog and the @candid-core/cli changelog. Coming from an earlier pair, read Migrating from 0.3.0 and Migrating from 0.2.0 first.

The upgrade is small, and nothing about it changes a wire byte, a Contract or an identity. @candid-core/schema 0.5.0 breaks code only at the type level: decode and decodeArgs type their values by their schemas, where 0.4.0 gives unknown. Its runtime is 0.4.0's but for one omission rule of schemaFromContract, which now leaves out less. What changes on the CLI side is the bytes it writes: every envelope names the renamed compiler crate as its producer, and a module that uses a declared blob, record {} or variant {} changes text, so committed output has to be regenerated.

What changedWhat it breaksWhere
decode and decodeArgs type their values by their schemasA cast or annotation from a decoded value to a type its schema does not describe; one variable reassigned from decodeArgs calls with different schemasDecoded values
Envelopes name meant 0.1.0 as their producerCommitted envelopes, which gen --check now reports as driftedEnvelopes
A declared blob, record {} or variant {} names only itselfCommitted modules that use such a declaration, which gen --check now reports as drifted; code that parses generated sourceFixed shapes
schemaFromContract omits a reserved-name blob, record {} or variant {} declaration on its ownNothing that loaded before: the other uses of that shape now load tooFixed shapes
The CLI's peer is exactly @candid-core/schema 0.5.0Upgrading one package without the otherOrder of upgrade
didToDocAttributes, and each entry's identities in gen --jsonNothing: they are newDoc attributes
The snippets below are compiled

Each block is checked against this tree, with ./shadowing.ts standing for a module CLI 0.4.0 generates. The before blocks must fail with the error shown, and the after blocks must compile.

Decoded values#

After ok, decode(Account, bytes).value is Infer<typeof Account>, and decodeArgs([A, B], bytes).values is the tuple of the two domains. That is a check, not a cast: the decoder reads the bytes under the schema and refuses those that do not fit before it returns ok: true. A schema whose own type is erased, an AnySchema or a FuncSchema's results, still decodes to unknown. Nothing changes at run time.

Code that cast the old unknown to a type the schema does not describe stops compiling, and tsc reports TS2352, "Conversion of type … may be a mistake":

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

const Account = c.record({ owner: c.principal, balance: c.nat });

// A 0.4.0-era cast to a hand-written type the schema does not describe.
type Legacy = { owner: string; balance: number };

declare const bytes: Uint8Array;
const decoded = decode(Account, bytes);
if (decoded.ok) {
  const account = decoded.value as Legacy;
  void account;
}

Drop the cast, and take the schema's own type, which the value already has:

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

const Account = c.record({ owner: c.principal, balance: c.nat });
type Account = Infer<typeof Account>;

declare const bytes: Uint8Array;
const decoded = decode(Account, bytes);
if (decoded.ok) {
  const account: Account = decoded.value;
  const balance: bigint = account.balance;
  void balance;
}

Each decodeArgs call now has its own DecodeResult<…>, so one variable reassigned from calls with different schemas fails with TS2322:

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

declare const reply: Uint8Array;
let result = decodeArgs([c.nat], reply);
result = decodeArgs([c.text], reply);
void result;

Give each call its own binding, or type the variable as the bare DecodeResult, which keeps 0.4.0's meaning, readonly unknown[]:

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

declare const reply: Uint8Array;
const fee = decodeArgs([c.nat], reply);
if (fee.ok) {
  const [amount] = fee.values;
  const tokens: bigint = amount;
  void tokens;
}

let result: DecodeResult = decodeArgs([c.nat], reply);
result = decodeArgs([c.text], reply);
void result;

A typeof or in check that narrowed the old unknown may now narrow to never; delete it, since the type already says what it checked. A generic wrapper <T>(schema: Schema<T>) => T still needs as T on the value. encode and encodeArgs still take unknown values.

Envelopes#

The Rust compiler crate published as candid-core is meant on crates.io since its rename, and meant 0.1.0 is candid-core 0.1.0's code under the new name. @candid-core/cli 0.4.0 embeds it, so every envelope it writes names it as the producer:

jsontests/fixtures/envelope/basic.envelope.json
"producer": {
  "candid_parser_version": "0.4.0",
  "candid_version": "0.10.30",
  "name": "meant",
  "version": "0.1.0"
},

0.3.0's envelopes name candid-core 0.1.0-beta.3. No identity covers the producer and schemaFromContract does not read it, so an envelope either release wrote loads the same, with the same Contract and identities. But candid-core-cli gen --check compares bytes, so it reports every envelope 0.3.0 wrote as drifted, exit 1, until gen runs again. Measured on the repository's tracked .did files, the 40 that both releases compile give envelopes that differ in the producer alone.

The native compiler that writes the same envelope from Rust installs under the new name, and meant compile ./service.did --envelope prints it:

bash
cargo install meant --version 0.1.0 --locked

Fixed shapes#

blob, record {} and variant {} each have one fixed rendering (Uint8Array / $.c.blob(), Record<string, never> / $.c.unit(), never / $.c.variant({})), and a declaration of one of them now names only itself, as a declared primitive already did. Every use renders the shape. Before, the alias of that shape whose name sorted first named every use of it: beside type Bytes = blob and type Unit = record {}, a method returning (default, Bytes, Unit) read [number, Bytes, Unit]. It now reads:

tscrates/candid-core-ts/tests/goldens/shadowing.ts
export type Bytes = Uint8Array;
// …
export type Actor = {
  all: ((arg0: Uses) => Promise<[number, Uint8Array, Record<string, never>]>) & $.WithMode<"query">;
  // …
};

The value types, the wire bytes and every export name are unchanged, so code that imports the generated names compiles as before, the alias names included:

ts
import type { Actor, Bytes, Unit } from "./shadowing.ts";

type Equal<A, B> =
  (<T>() => T extends A ? 1 : 2) extends <T>() => T extends B ? 1 : 2 ? true : false;

// 0.3.0 wrote this result as [number, Bytes, Unit]; the types are the same.
const same: Equal<Awaited<ReturnType<Actor["all"]>>, [number, Bytes, Unit]> = true;
void same;

Committed modules are not unchanged: a module with two declarations of one of these shapes, or one declaration and a plain use, changes text, and gen --check reports it as drifted. Measured on the repository's 40 compiling .did files, 3 modules change and 37 are byte-identical.

schemaFromContract follows the same rule in what it omits. A declaration of one of these shapes under a reserved name (type actor = blob) is left out on its own, and omitted lists it alone, as the generator's list does; 0.4.0 also left out each declaration and method that held a plain blob, empty record or empty variant beside it. Every other Contract loads and omits as before.

Order of upgrade#

Move both packages in one step. The CLI peers its schema release exactly, and npm refuses a mismatched pair (ERESOLVE could not resolve): @candid-core/cli 0.3.0 peers 0.4.0, and 0.4.0 peers 0.5.0. The plain lines select both from latest:

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

Then run candid-core-cli gen over every committed module, and fix what the compiler reports, which for code that imports the generated names is nothing, and where code decodes is the casts above. Every module either CLI writes for the repository's .did files compiles against either schema release, so the order inside that one step does not matter to tsc.