packages/validate/SPEC.md
This document states the rules that @tldraw/validate implements. It is written to drive testing: each rule has a stable ID (e.g. N3, U5), each rule is independently observable through the public API, and the unit tests should be an expression of these rules. When a test and this document disagree, one of them is wrong — figure out which and fix it.
Rules marked dev describe assertions that run only in development builds (NODE_ENV !== 'production'). The test suite runs in development mode, so these rules are observable there; production builds skip them.
Validatable<T>: validate(value) either returns the value typed as T or throws a ValidationError.validateUsingKnownGoodVersion(knownGoodValue, newValue): given a previously validated value, it validates only what changed and returns the known-good value itself when nothing did, so callers can use reference equality to detect "no change".refine, or anything derived from it like check and indexKey) may return a different value.ValidationError extends Error, has name: 'ValidationError', and exposes the unprefixed rawMessage and the path array it was constructed with (default []).message is At <formattedPath>: <rawMessage> when the path is non-empty, and just the raw message when the path is empty.., numeric segments appear as .<index>, and the leading dot is stripped (users.0.email).(type = cat) and named checks (check foo) — are appended without a dot, and consecutive parenthesized segments merge into one group: a named check on a union variant formats as (type = cat, check foo).id = … content is stripped from formatted paths so errors group well in Sentry. A union discriminated on id therefore formats its variant segment as ().message.rawMessage and accumulating path outside-in. A non-ValidationError exception thrown inside a validator is wrapped into a ValidationError whose raw message is the exception's toString(), with the path segment applied.validate(value) returns exactly the value it was passed — same reference, no clone, no mutation, no freezing.Object.is-equal to its input and was not created through a transforming API, validate throws Validator functions must return the same value they were passed.isValid(value) returns true when validate succeeds and false when it throws; it never throws.validateUsingKnownGoodVersion(knownGoodValue, newValue) returns knownGoodValue immediately — without running any validation — when Object.is(knownGoodValue, newValue).validateUsingKnownGoodVersion falls back to a full validate(newValue).refine(fn) validates with the base validator, then passes the result through fn; the refined validator returns fn's result, which may have a different type or value. A ValidationError thrown by fn propagates with path context from the surrounding structure.fn runs on the new validated value.newValue that is Object.is-equal to the previous output, even if that value would not pass validate from scratch (e.g. a number passed to a string-to-number refinement).check(fn) adds an assertion without changing the value: fn's return value is ignored, the input passes through. A failure carries no extra path segment.check(name, fn) is the same but prefixes failures with a (check <name>) path segment.nullable(v) (and v.nullable()) accepts null and returns it without consulting the inner validator; all other values go to the inner validator, so undefined is rejected when the inner validator rejects it.optional(v) (and v.optional()) accepts undefined the same way; null goes to the inner validator.null/undefined new value short-circuits as in NO1/NO2; a null/undefined known-good value forces a full inner validate; otherwise the inner validator's known-good path is used, preserving identity for unchanged values.optional/nullable keeps it exempt from the dev same-value check (V2): T.string.refine(...).optional() may return transformed values.string, boolean, and bigint validate by typeof and reject everything else with Expected <type>, got <description>.<description> in type-mismatch messages is: null for null, an array for arrays, undefined for undefined, an object for other objects, and a <typeof> for the remaining primitives (a string, a number, a boolean, a bigint, a function, a symbol).unknown and any accept every value — including undefined — and return it as-is.literal(expected) accepts exactly expected (strict equality) and rejects everything else with Expected <expected>, got <JSON.stringify(actual)>.array accepts any array without validating its items, and rejects non-arrays with Expected an array, got <description>.unknownObject accepts any non-null value with typeof 'object' and rejects null and primitives with Expected object, got <description>. Arrays pass this check (despite the description machinery knowing them as an array) — T.unknownObject.validate([1, 2, 3]) returns the array.number accepts finite numbers, rejecting non-numbers (Expected number, got <description>), NaN (Expected a number, got NaN), and Infinity/-Infinity (Expected a finite number, got <value>).positiveNumber accepts finite numbers >= 0 — zero included, despite the name. Negative values, including -Infinity, report Expected a positive number, got <value>; the NaN and non-number messages match N1.nonZeroNumber accepts finite numbers > 0. Zero and negative values, including -Infinity, report Expected a non-zero positive number, got <value>.nonZeroFiniteNumber accepts finite numbers that are not zero — negatives allowed. Zero reports Expected a non-zero number, got 0.unitInterval accepts finite numbers in [0, 1]. All other numbers — out-of-range values and the infinities alike — report Expected a number between 0 and 1, got <value>.integer accepts whole finite numbers (negatives included) and rejects fractional numbers with Expected an integer, got <value>; NaN and the infinities report as in N1.positiveInteger accepts integers >= 0 (zero included). Any negative number — integer or fractional, -1.5 included — reports Expected a positive integer, got <value>; non-negative fractional numbers report Expected an integer, got <value>.nonZeroInteger accepts integers > 0. Zero and negative numbers report Expected a non-zero positive integer, got <value>; positive fractional numbers report Expected an integer, got <value>.>= 0 and an integer: number, integer, positiveNumber, and positiveInteger accept -0; nonZeroNumber and nonZeroFiniteNumber reject it as zero (got 0 in the message).setEnum(set) accepts exactly the set's members. The failure message lists the allowed values JSON-stringified and the actual value string-interpolated: Expected "a" or "b", got c.literalEnum(...values) is setEnum(new Set(values)).arrayOf(item) requires an array and validates every element with item, prefixing the failing element's index onto the path..nonEmpty() additionally rejects [] (Expected a non-empty array); .lengthGreaterThan1() rejects arrays of length <= 1 (Expected an array with length greater than 1).Object.is-equal to their counterparts are skipped outright, and the rest are revalidated with the item validator's known-good path, counting as unchanged when it returns the known-good element.validate of the new array — which then returns the new array even when it is structurally equal to the known-good one.object(config) requires a non-null object and validates each configured property with its validator, prefixing the property name on failure. A missing property is validated as undefined (so required properties fail with Expected <type>, got undefined).At <key>: Unexpected property..allowUnknownProperties() returns a new validator that accepts extra properties without validating them; the original validator is unchanged.typeof value === 'object' && value !== null: arrays pass it. An empty array satisfies T.object({}); an array with elements then fails O2 on its indices (At 0: Unexpected property)..extend(extension) returns a new object validator over the merged config; extension keys override same-named config keys. The result rejects unknown properties regardless of the receiver's setting.Object.is-equal or known-good-equal per the property's validator) and no keys were added or removed; otherwise it returns the new object. Only changed properties are revalidated.allowUnknownProperties, known-good validation also compares unknown properties by Object.is: adding, removing, or changing an unknown property makes the new object the result.dict(keyValidator, valueValidator) requires a non-null object (arrays pass, as in O4) and validates every own enumerable key with keyValidator and every value with valueValidator, prefixing the key on failure.jsonDict() is dict(string, jsonValue).Object.is-equal or known-good-equal). Added keys are fully validated and make the new object the result; removed keys are detected by comparing key counts; changed values are revalidated incrementally.union(key, config) reads the discriminator property key, selects the matching variant validator, and validates the whole object with it, returning the input on success.Expected an object, got <description>.Expected a string for key "<key>", got <description>.Expected one of <variants> or …, got <JSON.stringify(variant)> at path [key].(key = variant) path segment.validateUnknownVariants(handler) returns a new union that calls handler(value, variant) for unmatched variants and uses its return value as the result. (dev) On the validate path the same-value check (V2) applies: a handler that returns a new object throws Validator functions must return the same value they were passed, so handlers must return the input itself. The known-good path does not run that check.numberUnion(key, config) requires the discriminator to coerce to a finite number: Infinity, -Infinity, NaN, and non-numeric strings are rejected with Expected a number for key "<key>", got "<value>".numberUnion looks the variant up by the discriminator's own string coercion, so a string-numeric discriminator like '1' selects variant 1 (and then typically fails that variant's literal), while an unmatched finite number like 1.5 is rejected per U4.jsonValue accepts null, booleans, finite and non-finite numbers, strings, and arrays and plain objects of accepted values, recursively.undefined, functions, bigints, symbols, and class instances — anywhere in the structure, including inside arrays and object values. Sparse array holes read as undefined, so sparse arrays are rejected.Object.prototype, null, or the structured-clone prototype: Object.create(null) and structuredClone results validate.typeof of the root value (Expected json serializable value, got object), even when the offending value is nested.Object.is-equal) entries are skipped, added/changed entries are validated recursively, and the known-good value is returned exactly when nothing changed — length changes and removed keys make the new value the result.model(name, validator) validates with the inner validator and prefixes name as the leading path segment on failure (At user.email: …).linkUrl accepts the empty string and absolute URLs with http:, https:, or mailto: protocols; other protocols are rejected with (invalid protocol) and unparseable strings with Expected a valid url, got <string>.srcUrl is the same with the allowed protocols http:, https:, data:, and asset:.httpUrl is the same with only http: and https: allowed./ or ./ are parsed against a dummy http: origin and therefore validate against all three; other relative forms (../x, foo) are rejected.HTTP://example.com validates, JAVASCRIPT:alert(1) does not.indexKey accepts strings that are valid fractional index keys ('a0', 'a1J') and rejects invalid ones ('a', 'a00', '') with Expected an index key, got <JSON.stringify(key)>.or(v1, v2) returns v1.validate(value) when it succeeds; when v1 throws — for any reason — the value goes to v2 and v2's result is returned.v2's error propagates; v1's error is swallowed.