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 changed | What it breaks | Where |
|---|---|---|
decode and decodeArgs type their values by their schemas | A cast or annotation from a decoded value to a type its schema does not describe; one variable reassigned from decodeArgs calls with different schemas | Decoded values |
Envelopes name meant 0.1.0 as their producer | Committed envelopes, which gen --check now reports as drifted | Envelopes |
A declared blob, record {} or variant {} names only itself | Committed modules that use such a declaration, which gen --check now reports as drifted; code that parses generated source | Fixed shapes |
schemaFromContract omits a reserved-name blob, record {} or variant {} declaration on its own | Nothing that loaded before: the other uses of that shape now load too | Fixed shapes |
The CLI's peer is exactly @candid-core/schema 0.5.0 | Upgrading one package without the other | Order of upgrade |
didToDocAttributes, and each entry's identities in gen --json | Nothing: they are new | Doc attributes |
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":
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:
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:
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[]:
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:
"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:
cargo install meant --version 0.1.0 --lockedFixed 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:
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:
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:
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.