apps/docs/content/sdk-features/validation.mdx
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.
Validation runs whenever a record is written to the store:
meta via the meta field on their schema configIf validation fails, the write throws and nothing is stored.
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.| 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.
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')
})
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.
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.
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.
.check() and .refine().