Validation
Structural validation with path-addressed issues, bounded traversal, and no exceptions for control flow.
This page describes the surface the repository builds, published as
@candid-core/schema 0.3.0-beta.1 under the npm beta dist-tag. latest is still
0.2.0, which has the old one: it
accepts any object with toText() as a principal, and an options object with a
misspelled or non-integer limit does not throw.
Migrating from 0.2.0 lists every difference with before and
after code, and each is recorded in the package changelog under 0.3.0-beta.1.
@candid-core/schema
keeps a Candid type alive at runtime as a plain data object.
The builders construct it; the ./validate subpath
walks it. Given a schema and any JavaScript value, validate answers whether that
value is one the schema describes — and when it is not, exactly which parts are wrong and where
they sit.
Two decisions shape the whole module. It never throws for a bad value: a failure is a return value, so validation is not control flow through exceptions. And every walk is bounded, so a value that arrived from a canister reply, a form submission or an attacker terminates whatever shape it has. Nothing else in the package is pulled in — importing the validator does not drag a codec or an agent with it.
import { c } from "@candid-core/schema";
import { validate } from "@candid-core/schema/validate";The signature and the result#
export function validate<T>(
schema: Schema<T>,
value: unknown,
options: ValidateOptions = {},
): ValidateResult;export type ValidateResult =
{ readonly ok: true } | { readonly ok: false; readonly issues: readonly ValidationIssue[] };
value is unknown on purpose. You hand over what you actually have — a
parsed JSON body, a decoded reply, whatever a form produced — without first writing a cast that
asserts the very thing you are asking about. ok discriminates the result, and a
failure always carries at least one issue, so on the branch where issues is readable
it is never empty.
The third argument bounds the walk. Each field has an exported default constant. Each limit is a
non-negative safe integer or absent (undefined means the default), and
0 is a valid limit that fails closed. Anything else — an unknown key such as a
misspelled maxDeph, or a limit that is NaN, negative, fractional, a
string, null or Infinity — throws TypeError before the value
is read. A misspelled key used to leave the default in force without a word, and a
NaN limit switched its bound off: a depth-300 value passed under
maxDepth: NaN.
| Option | Default | Bounds |
|---|---|---|
maxDepth |
DEFAULT_MAX_DEPTH = 256 |
Schema traversal depth. Every step consumes one — combinator descent and rec unwrapping alike. It mirrors the Rust crate's max_value_depth. |
maxElements |
DEFAULT_MAX_ELEMENTS = 1 000 000 |
Total traversal budget across the whole value, every branch included. Examined record keys are charged too, so a value with a million junk keys stops. |
maxIssues |
DEFAULT_MAX_ISSUES = 100 |
How many failures one walk collects before it stops looking. The result is still not-ok. |
The issue shape#
export interface ResourceLimitInfo {
readonly resource: "value_depth" | "value_elements" | "stack";
readonly limit: number;
readonly observed: number;
}
export interface ValidationIssue {
readonly code: ValidationCode;
/** `$`-rooted path to the offending value, candid-core style. */
readonly path: string;
readonly message: string;
readonly resource_limit?: ResourceLimitInfo;
}
The snake_case resource_limit in an otherwise camelCase file is not a slip. This is
deliberately the shape a candid-core validation violation serializes to —
{ code, path, message }, plus resource_limit when a bound was hit — so a
consumer that already reads those reads these without a second parser. The
diagnostics reference covers the shared shape, and
limits and diagnostics covers the Rust side of the same
idea.
The eleven codes#
ValidationCode is a closed union. Adding a member is an API change, so you can switch
on it exhaustively.
| Code | Fires when |
|---|---|
invalid_type | The value is the wrong JavaScript type for the node: a number where nat wants a bigint, a non-array for vec, a non-Uint8Array for blob, a non-object where a record or variant is declared, a non-string variant tag. |
not_integer | A fixed-width integer (nat8…int32) got a fractional number. |
out_of_range | A negative nat, a nat64/int64 outside its bigint range, or a fixed width outside its declared bounds. 255 passes nat8; 256 does not. |
missing_field | A declared record field is absent, a variant value has no tag, a payload arm has no value, or a func reference is missing principal or method. |
unexpected_field | A key the schema does not declare: an unknown record field, any key at all on c.unit(), anything besides tag on a bare-tag arm, anything besides tag/value on a variant, anything besides principal/method on a func reference. |
unknown_tag | The variant's tag names no arm of this variant. |
invalid_length | A tuple array has the wrong number of elements. |
uninhabited_type | c.empty was reached. No value passes, so every value fails here. |
unsupported_schema | The schema object is not one this package builds: an unknown kind, an unknown primitive name, or a rec thunk that returned something other than a schema. |
unreadable_value | The value threw while being inspected — an own accessor that raises, a hostile or revoked Proxy. |
resource_limit_exceeded | A bound tripped. This is the only code that carries resource_limit. |
The path grammar#
Paths are $-rooted. An ASCII-identifier-shaped key renders as .name;
anything else renders bracketed and JSON-quoted, ["has space"]; an index renders
[3]. A variant contributes .tag and .value, a func
reference .principal and .method. So a bad amount two records down reads
$.amount.e8s, and a bad element of a vector of records reads
$.transactions[4].memo.
This is the same grammar the Rust crate uses for $.declarations[3].name, so a path
means the same thing on both sides of the project.
Three worked calls#
All three use the same two schemas, hand-written here so the shapes are visible.
import { c, principal } from "@candid-core/schema";
import { validate } from "@candid-core/schema/validate";
const Tokens = c.record({ e8s: c.nat64 });
const Account = c.record({ owner: c.principal, subaccount: c.opt(c.blob()) });
const TransferArg = c.record({ to: Account, amount: Tokens, memo: c.opt(c.blob()) });A value the schema describes:
validate(TransferArg, {
to: { owner: principal("aaaaa-aa"), subaccount: null },
amount: { e8s: 100_000n },
memo: null,
});
// { ok: true }
Note the two nulls. opt T renders as T | null with the
property present, so memo: null is how you say "no memo". Omitting the key
is a different value, and it fails.
Two failures at once, each addressed to its own path:
const result = validate(TransferArg, {
to: { owner: "AAAAA-AA", subaccount: null },
amount: { e8s: -1n },
memo: null,
});
// {
// ok: false,
// issues: [
// {
// code: "invalid_type",
// path: "$.to.owner",
// message: "expected a Principal, got a string that is not canonical principal text"
// },
// {
// code: "out_of_range",
// path: "$.amount.e8s",
// message: "nat64 must be in [0, 2^64-1], got -1n"
// }
// ]
// }
The first is the principal check. A principal is its canonical text, typed as the branded
Principal, so c.principal is satisfied by exactly a string holding
canonical text — lowercase, dash-grouped by five, with a matching checksum. Upper case is not
canonical, and neither is an object with a toText method (an
@icp-sdk/core Principal included): both are invalid_type,
and encode refuses them with the same code at the same path. Convert once with
principal(value), which throws TypeError on non-canonical text rather
than repairing it.
And the omitted-versus-null case, plus an unknown key:
validate(TransferArg, {
to: { owner: principal("aaaaa-aa"), subaccount: null },
amount: { e8s: 1n },
reference: "abc",
});
// issues: [
// { code: "missing_field", path: "$.memo", message: "required field is missing" },
// { code: "unexpected_field", path: "$.reference", message: "field is not part of this record" }
// ]Issue order is deterministic: declared fields in the schema's own enumeration order first, then unexpected value keys in the value's enumeration order. Two runs over the same value give the same list, which is what makes an issue list safe to snapshot in a test.
A field counts as present only if it is an own enumerable property — the projection
JSON.stringify, object spread and structured clone all see. An inherited
toString does not satisfy a field named toString, and a
non-enumerable own property neither satisfies a required field nor counts as an unknown one. The
consequence is the useful part: a value that validates never serializes to something the schema
would reject.
Bounds, and what you observe when one trips#
A bound failure is terminal. The walk stops, and the result carries a
resource_limit_exceeded issue whose resource_limit names what ran out,
the configured limit, and the observed value that crossed it. This is
the case worth stating precisely: a truncated walk never reports ok.
A completely valid value comes back not-ok if the budget stopped the examination early, because
reporting success would claim an inspection that never happened.
const big = new Array(1000).fill(0);
const limited = validate(c.vec(c.nat8), big, { maxElements: 10 });
// {
// ok: false,
// issues: [
// {
// code: "resource_limit_exceeded",
// path: "$[9]",
// message: "value_elements limit 10 exceeded (observed 11)",
// resource_limit: { resource: "value_elements", limit: 10, observed: 11 }
// }
// ]
// }
Depth behaves the same way with resource: "value_depth". Because depth counts schema
traversal steps — rec unwrapping included — a linked-list-shaped value consumes depth
per element, so a hundred-thousand-node list stops at the default 256 rather than overflowing the
JavaScript stack. A value that points at itself terminates by the same route, and so does a
hand-built rec chain that never resolves — a schema no validated Contract produces,
because the compiler refuses a degenerate cycle such as type A = A. The bound is what
makes a hand-assembled one end.
The walk keeps its work on an explicit stack rather than the JavaScript call stack, so
maxDepth is the only depth bound and the answer does not depend on the engine: raise
it, and a 100,000-level list validates the same way on every call.
resource: "stack" is the odd one out: it is not a bound you configure. It means the
host's own call stack ran out mid-walk, which no depth of value or schema causes; what does is
code the walk calls — a getter or Proxy trap on the value, a rec thunk — recursing
too deeply by itself. The walk reports { resource: "stack", limit, observed } with
limit the maxDepth you set and observed the depth it had
reached. It is not a statement about the value's shape.
Branch on result.ok. Treating an empty issue list as success is the mistake the
terminal-bound rule exists to prevent: a walk that halted early has issues but has not seen the
whole value, and a walk that succeeded has no issue list at all.
Values that fight inspection#
The no-throw guarantee holds against values built to break walkers. Every read of the value
happens behind one choke point, and any exception it raises becomes a terminal
unreadable_value issue at the path being examined:
const schema = c.record({ a: c.record({ b: c.bool }) });
const value = { a: { get b(): boolean { throw new Error("boom"); } } };
validate(schema, value as never);
// issues: [{ code: "unreadable_value", path: "$.a.b", message: "the value threw while being inspected" }]
validate never throws for a value, and a malformed schema object comes back
as an unsupported_schema issue rather than an exception. The helpers that
read a schema are stricter. resolveSchema, isResultSchema,
and unwrapResult throw TypeError on a foreign object, an unknown
kind, or a rec chain running past 256 hops, and
unwrapResult throws for a schema it reads without trouble that is not a result
variant. The classic case all of this catches is passing the decoded value where its
schema belongs. Both validate and unwrapResult also throw
TypeError for an options object they cannot honour, as above.
Reading ok/err results#
variant { ok : T; err : E } is the near-universal canister result convention, and the
usual way to unwrap one generically is to probe the decoded value for ok or
err keys. That guess misfires on any record that legitimately carries those field
names, and it cannot type the error payload at all, because a value knows nothing about the arm it
does not inhabit. Whether a reply is a result, and what each arm carries, is a fact about
the schema — so the two helpers read it off the schema.
export function isResultSchema(schema: AnyFieldSchema): boolean
True when the schema resolves — through the rec indirections every generated
declaration arrives wrapped in — to a variant whose arms are exactly an ok arm
and an err arm. Two spellings are recognised as pairs: ok/err, which
Motoko's Result.Result produces, and Ok/Err, which
Rust's candid derive produces. Either order. A record carrying those field names is not a
result; neither is a variant with a third arm, nor one that mixes the two spellings.
export function unwrapResult<S extends AnyFieldSchema>(
schema: S,
value: unknown,
options: ValidateOptions = {},
): UnwrapResult<ResultOk<S>, ResultErr<S>>
Validates the value against the schema, then reads one arm. Three outcomes:
{ ok: true, value }, { ok: false, error }, or
{ ok: false, issues } — that last one carrying the issues the failed
validate call produced. An err arm is a value, not an exception; nothing
throws for one. options is forwarded, so a bounded caller stays bounded. A schema
that is not a result variant is a programmer error rather than an outcome: it throws
TypeError eagerly, before the value is touched.
import { isResultSchema, unwrapResult } from "@candid-core/schema/validate";
const TransferError = c.variant({
bad_fee: c.record({ expected_fee: c.nat }),
too_old: c.null,
});
const TransferResult = c.variant({ ok: c.nat, err: TransferError });
isResultSchema(TransferResult); // true
isResultSchema(c.record({ ok: c.bool, err: c.text })); // false: a record is not a variant
declare const reply: unknown; // whatever the call decoded to
const outcome = unwrapResult(TransferResult, reply);
if (outcome.issues) {
// Not a value of this schema at all.
console.error(outcome.issues[0].path, outcome.issues[0].message);
} else if (outcome.ok) {
const blockIndex: bigint = outcome.value;
} else {
const failure = outcome.error; // typed from the err arm, not from the value
}
Every member of UnwrapResult declares all three keys, the ones it does not hold as
optional undefined, so ok, error and issues
can each be read and narrowed on directly without a type guard. The companion aliases
ResultOk<S> and ResultErr<S> name each arm's payload type on
its own. A bare-tag arm — variant { ok; err : text } — unwraps to null,
the single value of the Candid null it declares.