Validation

Structural validation with path-addressed issues, bounded traversal, and no exceptions for control flow.

Published as a beta

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.

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

The signature and the result#

tscrates/candid-core-ts/ts/validate.ts
export function validate<T>(
  schema: Schema<T>,
  value: unknown,
  options: ValidateOptions = {},
): ValidateResult;
tscrates/candid-core-ts/ts/validate.ts
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.

OptionDefaultBounds
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#

tscrates/candid-core-ts/ts/validate.ts
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.

CodeFires when
invalid_typeThe 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_integerA fixed-width integer (nat8…int32) got a fractional number.
out_of_rangeA negative nat, a nat64/int64 outside its bigint range, or a fixed width outside its declared bounds. 255 passes nat8; 256 does not.
missing_fieldA 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_fieldA 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_tagThe variant's tag names no arm of this variant.
invalid_lengthA tuple array has the wrong number of elements.
uninhabited_typec.empty was reached. No value passes, so every value fails here.
unsupported_schemaThe 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_valueThe value threw while being inspected — an own accessor that raises, a hostile or revoked Proxy.
resource_limit_exceededA 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.

ts
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:

ts
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:

ts
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:

ts
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.

Presence means own and enumerable

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.

ts
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.

Read ok, not issues.length

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:

ts
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" }]
What does throw is always a programmer error

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.

isResultSchema ./validate
tscrates/candid-core-ts/ts/validate.ts
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.

unwrapResult ./validate
tscrates/candid-core-ts/ts/validate.ts
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.

ts
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.

Where to go next#