TypeBox Generator
Generates TypeBox schemas per table (insert/update/select) and an index barrel.
{ kind: 'typebox', path: 'src/validators/typebox' }TypeBox is JSON Schema, so what the column declares becomes a schema keyword rather than a chained call or an opaque predicate. That makes the output the most directly readable of the four validators, and it means the schemas can be handed to anything that speaks JSON Schema.
Example output
import { Type } from '@sinclair/typebox';
import type { Static } from '@sinclair/typebox';
export const SelectpeopleSchema = Type.Object({
id: Type.Integer({ minimum: -2147483648, maximum: 2147483647 }),
age: Type.Integer({ minimum: 18, maximum: 2147483647 }),
score: Type.Union([Type.Integer({ minimum: 0, maximum: 100 }), Type.Null()]),
tier: Type.Literal('gold'),
bio: Type.Union([Type.String(), Type.Null()]),
});
export type SelectpeopleOutput = Static<typeof SelectpeopleSchema>;age, score and tier there are all CHECK constraints from the schema, folded into the type.
Column constraints
| Column | Emitted |
|---|---|
varchar(255) | Type.Intersect([Type.String(), <code-point cap>]), see below |
uuid() | Type.String({ pattern: '...' }) |
smallint() | Type.Integer({ minimum: -32768, maximum: 32767 }) |
real() | Type.Number({ minimum: -3.4028235677973366e38, maximum: 3.4028235677973366e38 }), written out in full |
doublePrecision() | Type.Number(), with no magnitude bound |
The two float rows are the database's answer rather than drizzle-orm/typebox's. Postgres accepts every double up to 3.4028235677973366e38 in a real and answers out of range for type real to the next one, and it stored Number.MAX_VALUE in a double precision and handed it back unchanged. On MySQL a float() is bounded lower, at 3.4028234663852886e38, because a real MySQL 8.4 refuses the next double after that one. See Zod → What the column declares.
Why uuid is a pattern and not a format
TypeBox does not validate format unless the consuming project has registered it on FormatRegistry first. In a project that has not, Type.String({ format: 'uuid' }) rejects every valid uuid. A pattern needs no setup and behaves the same everywhere, so that is what is emitted.
CHECK constraints
No official Drizzle validator module enforces these, in any library. TypeBox states them declaratively:
| Constraint | Emitted |
|---|---|
CHECK (age >= 18) | minimum: 18 |
CHECK (n > 0) | exclusiveMinimum: 0 |
CHECK (score BETWEEN 0 AND 100) | minimum: 0, maximum: 100 |
CHECK (tier = 'gold') | Type.Literal("gold") |
CHECK (cardinality(tags) >= 2) | minItems: 2 |
CHECK (status IN ('a', 'b')) | Type.Union([...literals]) |
A bound replaces the end of the declared range it narrows rather than sitting beside it: a CHECK can only narrow, never widen, since the range is the column's type.
What goes on the object
Two constraints have no keyword at all, and both become branches of an intersection carrying a registered kind:
export const SelecttSchema = Type.Intersect([
Type.Object({ ... }),
Type.Unsafe<unknown>({
[Kind]: 'DrzlRowCheck',
description: 'order: lo < hi',
assert: (o: any) => o == null || o['lo'] == null || o['hi'] == null || o['lo'] < o['hi'],
}),
]);CHECK (lo < hi)compares two columns, so no field can decide it. Both sides are null-guarded, matching SQL.CHECK (length(name) >= 3)counts characters, andminLengthcounts UTF-16 code units.
Intersecting is what keeps the properties checked. Setting the kind on the object itself parses, enforces the predicate, and silently stops validating the fields, so { lo: 'x' } passes. Both Value.Check and TypeCompiler honour the intersection.
Serialising to JSON Schema stays valid: the branch carries no keywords, so it renders as a schema that accepts everything, keeping its description so a reader still learns the rule.
An equality becomes Type.Literal, not a const option. TypeBox accepts a const option on Type.String and Type.Integer and then ignores it: Type.String({ const: 'gold' }) validates 'silver' quite happily. Only Type.Literal actually enforces.
Nullability wraps the constrained type, so Type.Union([Type.Integer({ minimum: 0 }), Type.Null()]) lets null through. That matches SQL, where a CHECK passes when it evaluates to TRUE or NULL.
Only unambiguous comparisons are translated; see Zod → CHECK constraints for what is skipped and why.
Arrays and structured columns
A column declared with .array() becomes Type.Array of the element, with the element's own constraints intact:
// varchar(50).array(): the cap limits each entry, so it is intersected onto the element.
tags: Type.Array(Type.Intersect([Type.String(), /* at most 50 characters */])),
scores: Type.Array(Type.Integer({ minimum: -32768, maximum: 32767 })),| Column | Emitted |
|---|---|
point(), geometry() | Type.Tuple([Type.Number(), Type.Number()]) |
line() | Type.Tuple([...three...]) |
vector({ dimensions: 3 }) | Type.Array(Type.Number(), { minItems: 3, maxItems: 3 }) |
bit({ dimensions: 3 }) | Type.String({ pattern: '^[01]*$', minLength: 3, maxLength: 3 }) |
bytea() | Type.Uint8Array() |
json(), jsonb() | a Type.Recursive DrzlJsonValue, declared once per file |
A bigint column carries its range as bigint literals, Type.BigInt({ minimum: -9223372036854775808n, maximum: 9223372036854775807n }). Written as plain numbers the bound would be wrong, since 9223372036854775807 rounds up the moment it becomes a JS number.
typedJson
{ kind: 'typebox', path: 'src/validators/typebox', typedJson: true }Types json and jsonb columns from your schema using Type.Unsafe<T>, TypeBox's own escape hatch for a static type it cannot narrow at runtime:
prefs: Type.Unsafe<(typeof settings.$inferSelect)["prefs"]>(Type.Unknown()),See Zod → typedJson for why referencing Drizzle's inference works where rebuilding the type does not.
Why it cannot back an oRPC router
validation.library on an orpc generator takes zod, valibot or arktype, and not typebox. oRPC types .input() and .output() as a Standard Schema, and neither @sinclair/typebox nor the newer typebox package implements that spec, while the other three all do. A router handed a TypeBox schema would compile and then fail at runtime, so the config rejects it rather than emitting one.
This is a limit of TypeBox, not of drizzle-orm: its own drizzle-orm/typebox-legacy module works fine with @sinclair/typebox, and DRZL's output is measured against it on every CI run.
The generator itself is unaffected: TypeBox schemas are the right choice wherever you consume them yourself, or hand them to something that speaks JSON Schema. Pair it with a zod generator if you also want oRPC routers in the same project.
Character limits count characters
A varchar(n) limit is n characters, in both Postgres and MySQL. TypeBox's maxLength counts UTF-16 code units, which is a different measurement: a varchar(10) column accepts ten emoji and maxLength: 10 refuses eight of them. JSON Schema defines the keyword in code points, so this is TypeBox's implementation rather than the spec, but the effect is the same.
That is the direction that breaks working code, so the keyword is not used. The cap is intersected onto the field as a registered kind, which counts code points:
name: Type.Intersect([
Type.String(),
Type.Unsafe<unknown>({
[Kind]: 'DrzlRowCheck',
description: 'at most 255 characters',
assert: (v: any) => v == null || [...v].length <= 255,
}),
]),For an array column it goes on the element, since varchar(50).array() limits each entry rather than the list.
The trade is that this cap does not survive JSON.stringify into a JSON Schema, where a bare maxLength would. Emitting a number that means something else in a form that serialises is not a better trade. If you want the document, the JSON Schema generator emits one directly.
MySQL's TEXT family is a byte budget rather than a character count, and gets a branch counting encoded bytes. Both are honoured by Value.Check and by TypeCompiler, and all four generators agree on every one of them, checked against Postgres, SQLite and MySQL on every commit.
See Zod, character limits for the measurements against the databases.
applyDefaults
Drizzle knows what a column defaults to, and drizzle-orm reproduces none of them.
{ kind: 'typebox', path: 'src/validators/typebox', applyDefaults: true }country: Type.Optional(Type.String({ default: 'GB' })),Only literal defaults. defaultNow(), defaultRandom() and any sql default are evaluated by the database, and $defaultFn is called by Drizzle at insert time, so those stay optional: a schema guessing at them would produce a different value than the one actually stored.
Insert only, and off by default, because it changes what parsing returns rather than only what it accepts.
Value.Check deliberately does not materialise a default, only Value.Parse and Value.Default do: TypeBox separates validating from defaulting where zod and valibot fold the two together.
typedColumns
.$type<T>() is a compile-time cast on any column, so text().$type<'admin' | 'member'>() is an ordinary string to anything reading it at runtime and the narrowing is lost.
{ kind: 'typebox', path: 'src/validators/typebox', typedColumns: true }role: Type.Unsafe<(typeof users.$inferSelect)['role']>(
Type.Intersect([Type.String(), /* at most 50 characters */])
),Type.Unsafe<T> wraps the existing schema, so every check it carried still runs and only the inferred type is replaced. Implies typedJson. Off by default.
Peer dependency
@sinclair/typebox >= 0.32, which your project provides.
duplicateFinder
Uniqueness is the one constraint a per-row validator structurally cannot check: whether a value is unique is a fact about the table, not about the row. No first-party validator attempts it, and neither does a schema here.
What needs no database is whether a batch collides with itself, and that is the half you can fix before sending anything. It matters for a bulk insert, where a thousand rows fail whole on one collision and the error names a constraint rather than a row.
{ kind: 'typebox', path: 'src/validators/typebox', duplicateFinder: true }emits, for a table with unique constraints:
export function findDuplicateusers(
rows: readonly InsertusersInput[]
): Array<{ index: number; constraint: string; firstIndex: number }> { ... }findDuplicateusers([
{ email: 'a@b.co', org: 'x', handle: 'h' },
{ email: 'a@b.co', org: 'y', handle: 'h' },
]);
// [{ index: 1, constraint: 'email', firstIndex: 0 }]Two details it follows:
- Null is not equal to null. A constraint is skipped for any row where one of its columns is null or absent, because a unique index accepts any number of NULLs. Reporting those would send you chasing rows the database is perfectly happy with.
- Composite keys compare by value. The key is JSON, so
[1, '2']never collides with['1', 2], which a separator-joined key would.
A batch that passes can still collide with rows already stored. This checks the half that needs no round trip.
Off by default: generated code ships in your bundle.

