Some checks failed
Deploy analytics worker / Deploy analytics worker to ${{ (github.ref == 'refs/heads/production' && 'production') || (github.ref == 'refs/heads/main' && 'staging') || 'preview' }} (push) Failing after 1s
i18n upload strings / Upload latest strings (push) Failing after 1s
Deploy bemo / Deploy bemo to ${{ (github.ref == 'refs/heads/bemo-production' && 'production') || (github.ref == 'refs/heads/main' && 'staging') || 'preview' }} (push) Failing after 1s
Deploy grafana resources / ${{ github.event_name == 'push' && 'Deploy grafana resources' || 'Validate grafana resources (dry run)' }} (push) Failing after 0s
End to end tests (dotcom) / Check for relevant changes (push) Failing after 0s
End to end tests (examples) / Check for relevant changes (push) Failing after 0s
End to end tests (mermaid readability) / Check for relevant changes (push) Failing after 1s
End to end tests (perf) / Check for relevant changes (push) Failing after 0s
Publish editor extensions / Publish editor extensions (push) Failing after 1s
Publish SDK packages / Remove the publish-packages label (push) Has been skipped
Checks / Tests & checks (push) Has been cancelled
Checks / Build all projects (push) Has been cancelled
Deploy templates / Deploy templates (push) Has been cancelled
End to end tests (dotcom) / End to end tests (dotcom) (push) Has been cancelled
End to end tests (mermaid readability) / End to end tests (mermaid readability) (push) Has been cancelled
End to end tests (perf) / End to end tests (perf) (push) Has been cancelled
Deploy .com / Deploy dotcom to ${{ (github.ref == 'refs/heads/production' && 'production') || (github.ref == 'refs/heads/main' && 'staging') || 'preview' }} (push) Has been cancelled
End to end tests (examples) / End to end tests (examples) (push) Has been cancelled
Publish SDK packages / Publish SDK packages (push) Has been cancelled
Publish SDK packages / Tell user released version (push) Has been cancelled
Publish SDK packages / Publishes code templates to separate repositories (push) Has been cancelled
Publish SDK packages / Deploys the template demos with the new SDK version (push) Has been cancelled
17 KiB
17 KiB
@tldraw/validate behavior specification
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.
1. Model and vocabulary
- A validator is any object implementing
Validatable<T>:validate(value)either returns the value typed asTor throws aValidationError. - A known-good validation is the optional fast path
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". - A plain validator returns exactly the value it was given. A transforming validator (built with
refine, or anything derived from it likecheckandindexKey) may return a different value. - A validator's path locates a failure inside a nested structure: object keys, array indices, dict keys, union variants, check names, and model names each contribute a segment.
2. Validation errors and paths (E)
- E1
ValidationErrorextendsError, hasname: 'ValidationError', and exposes the unprefixedrawMessageand thepatharray it was constructed with (default[]). - E2 The error
messageisAt <formattedPath>: <rawMessage>when the path is non-empty, and just the raw message when the path is empty. - E3 Path formatting: string segments are joined with
., numeric segments appear as.<index>, and the leading dot is stripped (users.0.email). - E4 Parenthesized segments — union variants
(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). - E5
id = …content is stripped from formatted paths so errors group well in Sentry. A union discriminated onidtherefore formats its variant segment as(). - E6 Multi-line raw messages are indented: every line after the first gets two leading spaces in
message. - E7 Nested validators prefix their segment onto errors from inner validators, preserving
rawMessageand accumulatingpathoutside-in. A non-ValidationErrorexception thrown inside a validator is wrapped into aValidationErrorwhose raw message is the exception'stoString(), with the path segment applied.
3. The validator core (V)
- V1 A plain validator's
validate(value)returns exactly the value it was passed — same reference, no clone, no mutation, no freezing. - V2 (dev) If a validator function returns a value that is not
Object.is-equal to its input and was not created through a transforming API,validatethrowsValidator functions must return the same value they were passed. - V3
isValid(value)returnstruewhenvalidatesucceeds andfalsewhen it throws; it never throws. - V4
validateUsingKnownGoodVersion(knownGoodValue, newValue)returnsknownGoodValueimmediately — without running any validation — whenObject.is(knownGoodValue, newValue). - V5 When the references differ and the validator has no known-good implementation,
validateUsingKnownGoodVersionfalls back to a fullvalidate(newValue).
4. Refinement: refine and check (RC)
- RC1
refine(fn)validates with the base validator, then passes the result throughfn; the refined validator returnsfn's result, which may have a different type or value. AValidationErrorthrown byfnpropagates with path context from the surrounding structure. - RC2 A refined validator's known-good validation skips the refinement entirely when the base validator reports "no change" (returns the known-good value): the previous output is returned. When the base reports a change,
fnruns on the new validated value. - RC3 Because of V4, a refined validator's known-good validation accepts any
newValuethat isObject.is-equal to the previous output, even if that value would not passvalidatefrom scratch (e.g. a number passed to a string-to-number refinement). - RC4
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. - RC5
check(name, fn)is the same but prefixes failures with a(check <name>)path segment.
5. optional and nullable (NO)
- NO1
nullable(v)(andv.nullable()) acceptsnulland returns it without consulting the inner validator; all other values go to the inner validator, soundefinedis rejected when the inner validator rejects it. - NO2
optional(v)(andv.optional()) acceptsundefinedthe same way;nullgoes to the inner validator. - NO3 Known-good validation: a
null/undefinednew value short-circuits as in NO1/NO2; anull/undefinedknown-good value forces a full innervalidate; otherwise the inner validator's known-good path is used, preserving identity for unchanged values. - NO4 Wrapping a transforming validator in
optional/nullablekeeps it exempt from the dev same-value check (V2):T.string.refine(...).optional()may return transformed values.
6. Primitives (P)
- P1
string,boolean, andbigintvalidate bytypeofand reject everything else withExpected <type>, got <description>. - P2 The
<description>in type-mismatch messages is:nullfor null,an arrayfor arrays,undefinedfor undefined,an objectfor other objects, anda <typeof>for the remaining primitives (a string,a number,a boolean,a bigint,a function,a symbol). - P3
unknownandanyaccept every value — includingundefined— and return it as-is. - P4
literal(expected)accepts exactlyexpected(strict equality) and rejects everything else withExpected <expected>, got <JSON.stringify(actual)>. - P5
arrayaccepts any array without validating its items, and rejects non-arrays withExpected an array, got <description>. - P6
unknownObjectaccepts any non-null value withtypeof 'object'and rejectsnulland primitives withExpected object, got <description>. Arrays pass this check (despite the description machinery knowing them asan array) —T.unknownObject.validate([1, 2, 3])returns the array.
7. Numbers (N)
- N1
numberaccepts finite numbers, rejecting non-numbers (Expected number, got <description>),NaN(Expected a number, got NaN), andInfinity/-Infinity(Expected a finite number, got <value>). - N2
positiveNumberaccepts finite numbers>= 0— zero included, despite the name. Negative values, including-Infinity, reportExpected a positive number, got <value>; the NaN and non-number messages match N1. - N3
nonZeroNumberaccepts finite numbers> 0. Zero and negative values, including-Infinity, reportExpected a non-zero positive number, got <value>. - N4
nonZeroFiniteNumberaccepts finite numbers that are not zero — negatives allowed. Zero reportsExpected a non-zero number, got 0. - N5
unitIntervalaccepts finite numbers in[0, 1]. All other numbers — out-of-range values and the infinities alike — reportExpected a number between 0 and 1, got <value>. - N6
integeraccepts whole finite numbers (negatives included) and rejects fractional numbers withExpected an integer, got <value>; NaN and the infinities report as in N1. - N7
positiveIntegeraccepts integers>= 0(zero included). Any negative number — integer or fractional,-1.5included — reportsExpected a positive integer, got <value>; non-negative fractional numbers reportExpected an integer, got <value>. - N8
nonZeroIntegeraccepts integers> 0. Zero and negative numbers reportExpected a non-zero positive integer, got <value>; positive fractional numbers reportExpected an integer, got <value>. - N9 Negative zero is
>= 0and an integer:number,integer,positiveNumber, andpositiveIntegeraccept-0;nonZeroNumberandnonZeroFiniteNumberreject it as zero (got 0in the message).
8. Enums (EN)
- EN1
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. - EN2
literalEnum(...values)issetEnum(new Set(values)).
9. Arrays of validated items (A)
- A1
arrayOf(item)requires an array and validates every element withitem, prefixing the failing element's index onto the path. - A2
.nonEmpty()additionally rejects[](Expected a non-empty array);.lengthGreaterThan1()rejects arrays of length<= 1(Expected an array with length greater than 1). - A3 Known-good validation returns the known-good array exactly when the lengths match and every element is unchanged: elements that are
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. - A4 When the length differs or any element changed, known-good validation returns the new array; appended elements are fully validated, and elements past the new length are simply dropped with no validation.
- A5 If the item validator has no known-good implementation, the array's known-good validation falls back to a full
validateof the new array — which then returns the new array even when it is structurally equal to the known-good one.
10. Objects (O)
- O1
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 asundefined(so required properties fail withExpected <type>, got undefined). - O2 Properties not present in the config are rejected:
At <key>: Unexpected property. - O3
.allowUnknownProperties()returns a new validator that accepts extra properties without validating them; the original validator is unchanged. - O4 The object check is
typeof value === 'object' && value !== null: arrays pass it. An empty array satisfiesT.object({}); an array with elements then fails O2 on its indices (At 0: Unexpected property). - O5
.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. - O6 Known-good validation returns the known-good object exactly when every configured property is unchanged (
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. - O7 Known-good validation detects removed keys: a key present on the known-good object but absent from the new one makes the new object the result, even if all configured properties are unchanged.
- O8 With
allowUnknownProperties, known-good validation also compares unknown properties byObject.is: adding, removing, or changing an unknown property makes the new object the result.
11. Dictionaries (DI)
- DI1
dict(keyValidator, valueValidator)requires a non-null object (arrays pass, as in O4) and validates every own enumerable key withkeyValidatorand every value withvalueValidator, prefixing the key on failure. - DI2
jsonDict()isdict(string, jsonValue). - DI3 Known-good validation returns the known-good object exactly when the same keys are present and every value is unchanged (
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.
12. Discriminated unions (U)
- U1
union(key, config)reads the discriminator propertykey, selects the matching variant validator, and validates the whole object with it, returning the input on success. - U2 Non-object inputs are rejected with
Expected an object, got <description>. - U3 For string-keyed unions, a missing or non-string discriminator is rejected with
Expected a string for key "<key>", got <description>. - U4 A discriminator that matches no variant is rejected with
Expected one of <variants> or …, got <JSON.stringify(variant)>at path[key]. - U5 Variant validation failures are prefixed with the
(key = variant)path segment. - U6
validateUnknownVariants(handler)returns a new union that callshandler(value, variant)for unmatched variants and uses its return value as the result. (dev) On thevalidatepath the same-value check (V2) applies: a handler that returns a new object throwsValidator 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. - U7 Known-good validation with an unchanged discriminator uses the variant's known-good path (preserving identity for unchanged values); a changed discriminator falls back to a full validation against the new variant; unmatched variants go to the unknown-variant handler; a non-object on either side throws per U2.
- U8
numberUnion(key, config)requires the discriminator to coerce to a finite number:Infinity,-Infinity,NaN, and non-numeric strings are rejected withExpected a number for key "<key>", got "<value>". - U9
numberUnionlooks the variant up by the discriminator's own string coercion, so a string-numeric discriminator like'1'selects variant1(and then typically fails that variant'sliteral), while an unmatched finite number like1.5is rejected per U4.
13. JSON values (J)
- J1
jsonValueacceptsnull, booleans, finite and non-finite numbers, strings, and arrays and plain objects of accepted values, recursively. - J2 It rejects
undefined, functions, bigints, symbols, and class instances — anywhere in the structure, including inside arrays and object values. Sparse array holes read asundefined, so sparse arrays are rejected. - J3 "Plain object" means the prototype is
Object.prototype,null, or the structured-clone prototype:Object.create(null)andstructuredCloneresults validate. - J4 The full-validation failure message reports the
typeofof the root value (Expected json serializable value, got object), even when the offending value is nested. - J5 Known-good validation is incremental when both values are arrays or both are plain objects: unchanged (
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. - J6 When the two values' shapes differ (array vs object vs scalar), known-good validation falls back to a full validation of the new value.
14. Models (M)
- M1
model(name, validator)validates with the inner validator and prefixesnameas the leading path segment on failure (At user.email: …). - M2 Its known-good validation delegates to the inner validator's (with the same prefixing), preserving identity for unchanged values.
15. URL validators (UR)
- UR1
linkUrlaccepts the empty string and absolute URLs withhttp:,https:, ormailto:protocols; other protocols are rejected with(invalid protocol)and unparseable strings withExpected a valid url, got <string>. - UR2
srcUrlis the same with the allowed protocolshttp:,https:,data:, andasset:. - UR3
httpUrlis the same with onlyhttp:andhttps:allowed. - UR4 Strings starting with
/or./are parsed against a dummyhttp:origin and therefore validate against all three; other relative forms (../x,foo) are rejected. - UR5 Protocol matching is case-insensitive:
HTTP://example.comvalidates,JAVASCRIPT:alert(1)does not. - UR6 All three are string validators first: non-strings fail with the P1 message.
16. Index keys (IK)
- IK1
indexKeyaccepts strings that are valid fractional index keys ('a0','a1J') and rejects invalid ones ('a','a00','') withExpected an index key, got <JSON.stringify(key)>.
17. Either-of (OR)
- OR1
or(v1, v2)returnsv1.validate(value)when it succeeds; whenv1throws — for any reason — the value goes tov2andv2's result is returned. - OR2 When both fail,
v2's error propagates;v1's error is swallowed.