@tanstack/db
Version:
A reactive client store for building super fast apps on sync
216 lines (167 loc) • 5.98 kB
Markdown
# Schema Patterns Reference
## StandardSchema Integration
TanStack DB accepts any [StandardSchema](https://standardschema.dev)-compatible library via the `schema` option.
### Supported Libraries
- [Zod](https://zod.dev), [Valibot](https://valibot.dev), [ArkType](https://arktype.io), [Effect Schema](https://effect.website/docs/schema/introduction/)
## TInput vs TOutput
- **TInput** -- type accepted by `insert()` and `update()`
- **TOutput** -- type stored in collection and returned from queries
When no transforms exist, TInput === TOutput.
```typescript
const schema = z.object({
id: z.string(),
created_at: z.string().transform((val) => new Date(val)),
})
// TInput: { id: string, created_at: string }
// TOutput: { id: string, created_at: Date }
```
## Union Pattern for Transforms (Required)
When a schema transforms A to B, TInput **must** accept both A and B. During `update()`, the draft contains TOutput data.
```typescript
// WRONG -- update() fails because draft.created_at is Date but schema expects string
z.string().transform((val) => new Date(val))
// CORRECT
z.union([z.string(), z.date()]).transform((val) =>
typeof val === 'string' ? new Date(val) : val,
)
// TInput: string | Date, TOutput: Date
```
## Defaults
```typescript
const schema = z.object({
id: z.string(),
text: z.string(),
completed: z.boolean().default(false),
priority: z.number().default(0),
tags: z.array(z.string()).default([]),
created_at: z.date().default(() => new Date()),
})
// insert({ id: "1", text: "Task" }) -- missing fields auto-filled
```
## Computed Fields
```typescript
const schema = z
.object({
id: z.string(),
first_name: z.string(),
last_name: z.string(),
})
.transform((data) => ({
...data,
full_name: `${data.first_name} ${data.last_name}`,
}))
```
## Combining Defaults with Transforms
```typescript
const schema = z.object({
created_at: z
.string()
.default(() => new Date().toISOString())
.transform((val) => new Date(val)),
})
```
## Validation Examples
```typescript
// Basic constraints
z.string().min(3).max(100)
z.string().email()
z.number().int().positive()
z.enum(['active', 'inactive'])
z.array(z.string()).min(1)
// Optional/nullable
z.string().optional() // can be omitted
z.string().nullable() // can be null
// Cross-field
z.object({ start: z.string(), end: z.string() }).refine(
(d) => new Date(d.end) > new Date(d.start),
'End must be after start',
)
// Custom
z.string().refine((v) => /^[a-zA-Z0-9_]+$/.test(v), 'Alphanumeric only')
```
## SchemaValidationError
```typescript
import { SchemaValidationError } from '@tanstack/db'
try {
collection.insert({ id: '1', email: 'bad', age: -5 })
} catch (error) {
if (error instanceof SchemaValidationError) {
error.type // "insert" or "update"
error.message // "Validation failed with 2 issues"
error.issues // [{ path: ["email"], message: "Invalid email" }, ...]
}
}
```
## Scope: Schema vs Sync — Two Separate Paths
**Schemas validate client mutations only** (`insert()`, `update()`). Synced data from backends (Electric, PowerSync, etc.) bypasses the schema entirely.
This means for types that need transformation (e.g., `timestamptz`):
- **Sync path**: handled by the adapter's parser (e.g., Electric's `shapeOptions.parser`)
- **Mutation path**: handled by the Zod schema
You need BOTH configured for full type safety. See electric-adapter.md for the dual-path pattern.
### Simpler date coercion (Zod-specific)
With Zod, `z.coerce.date()` is simpler than the `z.union([z.string(), z.date()]).transform(...)` pattern:
```typescript
// Zod-specific: z.coerce.date() accepts string, number, or Date as input
const schema = z.object({
created_at: z.coerce.date(),
})
// TInput: { created_at: string | number | Date } (coerce accepts many types)
// TOutput: { created_at: Date }
```
This satisfies the TInput-superset-of-TOutput requirement automatically. Other StandardSchema libraries have their own coercion patterns — consult library docs.
### Important
- Validation is synchronous, runs on every mutation
- Keep transforms simple for performance
## Where TOutput Appears
- Data stored in collection and returned from queries
- `PendingMutation.modified`
- Mutation handler `transaction.mutations[].modified`
## Performance
Keep transforms simple -- validation runs synchronously on every mutation.
## Complete Example
```typescript
import { z } from 'zod'
import { createCollection, SchemaValidationError } from '@tanstack/react-db'
import { queryCollectionOptions } from '@tanstack/query-db-collection'
const todoSchema = z.object({
id: z.string(),
text: z.string().min(1, 'Text is required'),
completed: z.boolean().default(false),
priority: z.enum(['low', 'medium', 'high']).default('medium'),
created_at: z
.union([z.string(), z.date()])
.transform((val) => (typeof val === 'string' ? new Date(val) : val))
.default(() => new Date()),
})
const todosCollection = createCollection(
queryCollectionOptions({
queryKey: ['todos'],
queryFn: async () => fetch('/api/todos').then((r) => r.json()),
queryClient,
getKey: (item) => item.id,
schema: todoSchema,
onInsert: async ({ transaction }) => {
const todo = transaction.mutations[0].modified
await api.todos.create({
...todo,
created_at: todo.created_at.toISOString(),
})
},
}),
)
// Defaults and transforms applied
todosCollection.insert({ id: '1', text: 'Buy groceries' })
// => { id: "1", text: "Buy groceries", completed: false, priority: "medium", created_at: Date }
// Update works -- draft contains TOutput, schema accepts via union
todosCollection.update('1', (draft) => {
draft.completed = true
})
// Error handling
try {
todosCollection.insert({ id: '2', text: '' })
} catch (e) {
if (e instanceof SchemaValidationError) {
console.log(e.issues) // [{ path: ["text"], message: "Text is required" }]
}
}
```