Validation
The @tldraw/validate package handles validation across tldraw's schemas, record types, and shape props. Validators enforce runtime type safety and provide structured errors when data is malformed. The T namespace is re-exported from tldraw, so import { T } from 'tldraw' works too.
Where validation runs
Validation runs whenever a record is written to the store:
- Shape and binding props via
RecordProps - Shape, binding, and user
metavia themetafield on their schema config - Custom record types via
CustomRecordInfo - Any record type in a store via
createRecordTypeandStoreSchema
If validation fails, the write throws and nothing is stored.
Core validators
Use T validators to describe data shapes and validate unknown input:
import { T } from '@tldraw/validate'
const userValidator = T.object({
id: T.string,
name: T.string.optional(),
age: T.number.optional(),
})
const user = userValidator.validate(input)Every validator has three key methods:
validate(value)validates unknown input and returns a typed resultisValid(value)returns true if valid, false otherwise (useful as a type guard)validateUsingKnownGoodVersion(knownGood, newValue)reuses previously validated data to skip unchanged parts. The store calls this automatically when updating an existing record.
Validator catalog
| Category | Validators |
|---|---|
| Primitives | T.unknown, T.any, T.string, T.number, T.boolean, T.bigint |
| Numbers | T.positiveNumber, T.nonZeroNumber, T.nonZeroFiniteNumber, T.unitInterval, T.integer, T.positiveInteger, T.nonZeroInteger |
| Collections | T.array, T.arrayOf, T.object, T.unknownObject, T.dict, T.jsonDict, T.jsonValue |
| Unions | T.literal, T.literalEnum, T.setEnum, T.union, T.numberUnion, T.or |
| URLs and IDs | T.linkUrl, T.srcUrl, T.httpUrl, T.indexKey |
| Modifiers | .optional(), .nullable(), .refine(), .check(), T.optional(), T.nullable(), T.model() |
Object validators also have .extend() to add fields and .allowUnknownProperties() to tolerate extra keys.
Common validator patterns
import { T } from '@tldraw/validate'
const configValidator = T.object({
id: T.string,
mode: T.literalEnum('view', 'edit'),
tags: T.arrayOf(T.string).optional(),
meta: T.object({ note: T.string }).nullable(),
})
const evenNumber = T.number.check('even', (value) => {
if (value % 2 !== 0) throw new T.ValidationError('Expected even number')
})Record props validation
Shapes and bindings use RecordProps to validate their props at runtime. Each key maps to a validator, and the store rejects any write whose props don't pass:
import { DefaultColorStyle, RecordProps, T, TLBaseShape, TLDefaultColorStyle } from 'tldraw'
type CardShape = TLBaseShape<'card', { color: TLDefaultColorStyle; text: string }>
const cardShapeProps: RecordProps<CardShape> = {
color: DefaultColorStyle,
text: T.string,
}Assign this object to static override props on your ShapeUtil. See the custom shape example for the full util.
Store validation and recovery
The store validates records on write. When you build your own StoreSchema you can pass onValidationFailure to recover or sanitize data instead of throwing:
import { BaseRecord, RecordId, StoreSchema, createRecordType } from '@tldraw/store'
import { T, idValidator } from 'tldraw'
interface Book extends BaseRecord<'book', RecordId<Book>> {
title: string
}
const Book = createRecordType<Book>('book', {
scope: 'document',
validator: T.object({
id: idValidator<RecordId<Book>>('book'),
typeName: T.literal('book'),
title: T.string,
}),
})
const schema = StoreSchema.create(
{ book: Book },
{
onValidationFailure: (failure) => failure.record,
}
)The handler must return a valid record, or rethrow to abort the write. The failure object is a StoreValidationFailure:
| Property | Description |
|---|---|
error | The error that was thrown |
store | The store instance where validation failed |
record | The invalid record |
phase | When validation failed: 'initialize', 'createRecord', 'updateRecord', or 'tests' |
recordBefore | The previous record state (null for new records) |
tldraw's own schema from createTLSchema (and so createTLStore and the Tldraw component) uses a built-in handler that reports the error and rethrows. You can't override it there; if you need recovery, build the StoreSchema yourself.
Error handling
T.ValidationError carries structured information about what went wrong:
import { T } from '@tldraw/validate'
const userValidator = T.object({
name: T.string,
settings: T.object({ theme: T.literalEnum('light', 'dark') }),
})
try {
userValidator.validate({ name: 'Alice', settings: { theme: 'invalid' } })
} catch (error) {
if (error instanceof T.ValidationError) {
console.log(error.message) // 'At settings.theme: Expected "light" or "dark", got invalid'
console.log(error.rawMessage) // 'Expected "light" or "dark", got invalid'
console.log(error.path) // ['settings', 'theme']
}
}rawMessage is the message without path information, and path is an array showing where in the data structure validation failed (for example ['items', 0, 'name']). The full message combines them.
Validators must be pure and must not mutate input values.
Related examples
- Custom shape - Define shape props with validators using RecordProps.
- Custom validators for shape props - Add constraints with
.check()and.refine().