15 KiB
TypeDef Kind-Based Extension Pattern
Overview
The @alkdev/typebox TypeDef example (example/typedef/typedef.ts) demonstrates how to build a fully custom type system on top of TypeBox's Kind-based extensibility. It replaces JSON Schema semantics with a flat, binary-protocol-oriented type vocabulary while reusing TypeBox's infrastructure (Kind symbol, TypeRegistry, schema interfaces).
This document analyzes the pattern in detail and maps it to a future DbType system for drizzlebox.
1. The Kind Symbol — Core Extension Mechanism
Location: @alkdev/typebox/src/type/symbols/symbols.ts:38
export const Kind = Symbol.for('TypeBox.Kind')
Kind is a global Symbol (Symbol.for) used as a property key on every schema object. Its value is a string that identifies the type's identity, e.g. 'String', 'TypeDef:String', 'Object'.
Every TypeBox schema interface extends TSchema, which extends TKind:
// schema.ts:49-57
export interface TKind {
[Kind]: string
}
export interface TSchema extends TKind, SchemaOptions {
[ReadonlyKind]?: string
[OptionalKind]?: string
[Hint]?: string
params: unknown[]
static: unknown
}
Key insight: The [Kind] property is the single dispatch key for the entire type system. Validation, type guarding, compilation — everything dispatches on schema[Kind].
Namespacing convention: TypeDef uses a colon-separated namespace: 'TypeDef:String', 'TypeDef:Int8', 'TypeDef:Struct'. This avoids collisions with TypeBox's built-in kinds ('String', 'Number', 'Object').
2. Defining Custom Kind Interfaces
Each custom type declares a TypeScript interface extending Types.TSchema (imported as Types from '@alkdev/typebox/type'):
export interface TString extends Types.TSchema {
[Types.Kind]: 'TypeDef:String'
type: 'string'
static: string
}
export interface TInt8 extends Types.TSchema {
[Types.Kind]: 'TypeDef:Int8'
type: 'int8'
static: number
}
export interface TStruct<T extends TFields = TFields> extends Types.TSchema, StructMetadata {
[Types.Kind]: 'TypeDef:Struct'
static: StructStatic<T, this['params']>
optionalProperties: { [K in Assert<OptionalKeys<T>, keyof T>]: T[K] }
properties: { [K in Assert<RequiredKeys<T>, keyof T>]: T[K] }
}
Pattern anatomy:
[Types.Kind]— literal string type for dispatch identitystatic— mapped TypeScript type forStatic<T>inference- Domain-specific properties (
type,elements,properties,optionalProperties,discriminator,mapping,enum,additionalProperties) - Generic type parameters for compositional types (
TStruct<T>,TArray<T>,TRecord<T>)
For DbType: We would define interfaces like:
export interface TDbVarChar<TInner extends TSchema = TSchema> extends TSchema {
[Kind]: 'DbType:VarChar'
static: Static<TInner>
inner: TInner // the validation schema (e.g. TString with maxLength)
maxLength: number // database metadata
}
export interface TDbSerial<TInner extends TSchema = TSchema> extends TSchema {
[Kind]: 'DbType:Serial'
static: Static<TInner>
inner: TInner
dataType: 'integer'
}
The inner field carries the validation schema. The database-specific metadata (maxLength, dataType, precision, etc.) lives alongside it.
3. TypeDefBuilder.Create() — Metadata Attachment
Location: typedef.ts:522-525
export class TypeDefBuilder {
protected Create(schema: Record<PropertyKey, any>, metadata: Record<keyof any, any>): any {
const keys = globalThis.Object.getOwnPropertyNames(metadata)
return keys.length > 0 ? { ...schema, metadata: { ...metadata } } : { ...schema }
}
// ...
}
Each builder method calls this.Create(...) with:
- The schema object — containing
[Kind]and structural properties - A metadata bag — arbitrary key-value pairs
If metadata is non-empty, it's spread into a metadata sub-object. Otherwise, just the schema is returned plain.
Example builder methods:
public Int8(metadata: Metadata = {}): TInt8 {
return this.Create({ [Types.Kind]: 'TypeDef:Int8', type: 'int8' }, metadata)
}
public Struct<T extends TFields>(fields: T, metadata: StructMetadata = {}): TStruct<T> {
// ... computes optionalProperties and properties ...
return this.Create({ [Types.Kind]: 'TypeDef:Struct', ...requiredObject, ...optionalObject }, metadata)
}
For DbType: The Create() pattern works well, but we'd modify it to:
- Flatten database metadata into the schema directly (rather than nesting in a
metadatasub-object) since tools like drizzle-kit need to inspect these properties - Or keep the
metadatapattern but make it type-safe with aDbMetadatainterface
4. TypeRegistry.Set — Validation Function Registration
Location: @alkdev/typebox/src/type/registry/type.ts
export type TypeRegistryValidationFunction<TSchema> = (schema: TSchema, value: unknown) => boolean
const map = new Map<string, TypeRegistryValidationFunction<any>>()
export function Set<TSchema = unknown>(kind: string, func: TypeRegistryValidationFunction<TSchema>) {
map.set(kind, func)
}
export function Get(kind: string) {
return map.get(kind)
}
export function Has(kind: string) {
return map.has(kind)
}
TypeDef registers all its custom kinds at module level:
Types.TypeRegistry.Set<TInt8>('TypeDef:Int8', (schema, value) => ValueCheck.Check(schema, value))
Types.TypeRegistry.Set<TString>('TypeDef:String', (schema, value) => ValueCheck.Check(schema, value))
Types.TypeRegistry.Set<TStruct>('TypeDef:Struct', (schema, value) => ValueCheck.Check(schema, value))
// ... etc
How TypeBox's built-in ValueCheck uses this (value/check/check.ts:423-500):
function FromKind(schema: TSchema, references: TSchema[], value: unknown): boolean {
if (!TypeRegistry.Has(schema[Kind])) return false
const func = TypeRegistry.Get(schema[Kind])!
return func(schema, value)
}
function Visit(schema, references, value) {
switch (schema[Kind]) {
case 'String': return FromString(...)
case 'Number': return FromNumber(...)
// ... all built-in kinds
default:
if (!TypeRegistry.Has(schema[Kind])) throw new ValueCheckUnknownTypeError(schema)
return FromKind(schema, references, value)
}
}
Key observation: TypeBox's own Visit() handles all built-in kinds in a switch statement. Custom kinds hit the default branch and dispatch through TypeRegistry. This means TypeDef provides its own ValueCheck.Visit() that dispatches on TypeDef:* kinds — it does NOT rely on TypeBox's Visit() at all. TypeDef's ValueCheck is a separate, independent validation path.
For DbType: There are two approaches:
- TypeDef-style: Build a completely separate
DbValueCheck.Visit()with its own switch statement. Full control but duplicates infrastructure. - Registry-style: Register
DbType:*validation functions withTypeRegistry.Set()and let TypeBox's existingValueCheckdispatch throughFromKind. This is simpler and integrates with TypeBox's existing validation pipeline.
Option 2 is preferable for DbType since we want to compose with existing TypeBox types, not replace them.
5. TypeGuard — Schema Structure Validation
Location: typedef.ts:376-477
TypeDef implements its own TypeGuard namespace that validates the structure of schema objects (not values, but the schemas themselves):
export namespace TypeGuard {
export function TInt8(schema: unknown): schema is TInt8 {
return IsObject(schema) && schema[Types.Kind] === 'TypeDef:Int8' && schema['type'] === 'int8'
}
export function TStruct(schema: unknown): schema is TStruct {
if(!(IsObject(schema) && schema[Types.Kind] === 'TypeDef:Struct' && IsOptionalBoolean(schema['additionalProperties']))) return false
// ... validate properties and optionalProperties
}
export function TSchema(schema: unknown): schema is Types.TSchema {
return (
TArray(schema) ||
TBoolean(schema) ||
// ... all TypeDef kinds ...
TStruct(schema) ||
TTimestamp(schema) ||
(TKind(schema) && Types.TypeRegistry.Has(schema[Types.Kind])) // fallback to registry
)
}
}
Fallback to registry: The last clause (TKind(schema) && Types.TypeRegistry.Has(schema[Types.Kind])) is crucial — it allows kinds registered with TypeRegistry but not known to the static TSchema() guard to still pass. This enables extensibility.
For DbType: We need a DbGuard namespace that:
- Validates
DbType:*schema shapes (checks thatinneris a valid schema, thatmaxLengthis a number, etc.) - Falls back to TypeBox's built-in
TypeGuard.TSchema()for non-DbType schemas - Provides a unified
isDbSchema()function
6. TypeSystem.Type() — The "Simple" Custom Type API
Location: @alkdev/typebox/src/system/system.ts:55-58
export namespace TypeSystem {
export function Type<Type, Options = Record<PropertyKey, unknown>>(
kind: string,
check: (options: Options, value: unknown) => boolean
): TypeFactoryFunction<Type, Options> {
if (TypeRegistry.Has(kind)) throw new TypeSystemDuplicateTypeKind(kind)
TypeRegistry.Set(kind, check)
return (options: Partial<Options> = {}) => Unsafe<Type>({ ...options, [Kind]: kind })
}
}
This is TypeBox's built-in shortcut for simple custom types:
- Registers a validation function with
TypeRegistry - Returns a factory that creates
TUnsafe<Type>schemas with the custom[Kind]
The TUnsafe type (unsafe.ts):
export interface TUnsafe<T> extends TSchema {
[Kind]: string
static: T
}
export function Unsafe<T>(options: UnsafeOptions = {}): TUnsafe<T> {
return CreateType({ [Kind]: options[Kind] ?? 'Unsafe' }, options) as never
}
Limitation for DbType: TUnsafe provides static type inference but no structural guarantees. A DbType:VarChar created via TypeSystem.Type() would have static: string but the schema object wouldn't encode inner, maxLength, etc. in a type-safe way. TypeDef's pattern of explicit interfaces is strictly better for complex types.
7. Relationship Between TypeDef Types and TypeBox Built-in Types
TypeDef replaces all JSON Schema types. It does not compose with them:
- TypeBox
Stringhaskind: 'String', supportsminLength,maxLength,pattern,format - TypeDef
Stringhaskind: 'TypeDef:String', supports onlytype: 'string'and metadata
TypeDef's ValueCheck is entirely separate from TypeBox's ValueCheck. They dispatch on disjoint Kind namespaces ('TypeDef:*' vs '*').
TypeDef's TypeGuard.TSchema() does reference Types.TypeRegistry.Has() as a fallback, allowing registered types to pass. But the structural validation of TypeDef schemas is wholly custom.
For DbType: We want composition, not replacement. A DbType:VarChar should wrap a TypeBox TString (with maxLength) and add dB: { kind: 'varchar', maxLength: 255 }. This means DbType schemas should carry a reference to the inner TypeBox schema, not reimplement validation logic.
8. What the Current drizzlebox/src Approach Takes From This Pattern
Looking at column.ts:66-67:
TypeRegistry.Set('Buffer', (_, value) => value instanceof Buffer);
export const bufferSchema: BufferSchema = { [Kind]: 'Buffer', type: 'buffer' } as any;
This is a minimal application of the Kind/TypeRegistry pattern — a single custom type for Buffer validation. It uses the TypeSystem.Type()-style approach: register a check function, create a schema object with [Kind].
Similarly, utils.ts:18-27 defines JsonSchema and BufferSchema as interfaces extending TSchema:
export interface JsonSchema extends TSchema {
[Kind]: 'Union'
static: Json
anyOf: Json
}
export interface BufferSchema extends TSchema {
[Kind]: 'Buffer'
static: Buffer
type: 'buffer'
}
The rest of column.ts maps drizzle column types to standard TypeBox types (t.String(), t.Integer(), t.Number(), etc.) — meaning all the database type information (that something is a PgInteger vs a MySqlInt) is lost. The schema only preserves validation semantics.
9. Proposed DbType Pattern — Key Design Decisions
9.1 Wrap, Don't Replace
Each DbType schema should carry an inner TypeBox schema AND database metadata:
export interface TDbInteger<TInner extends TSchema = TSchema> extends TSchema {
[Kind]: 'DbType:Integer'
static: Static<TInner>
inner: TInner
db: {
dataType: 'integer'
columnType: string // 'PgInteger' | 'MySqlInt' | etc.
unsigned?: boolean
hasDefault?: boolean
notNull?: boolean
}
}
This preserves both validation semantics AND database semantics in one schema object.
9.2 Register with TypeRegistry for Composition
TypeRegistry.Set<TDbInteger>('DbType:Integer', (schema, value) => {
return Value.Check(schema.inner, value) // delegate to inner schema
})
This lets DbType schemas compose with TypeBox's existing validation pipeline.
9.3 Separate TypeGuard for DbType Schemas
export namespace DbGuard {
export function TDbInteger(schema: unknown): schema is TDbInteger {
return IsObject(schema)
&& schema[Kind] === 'DbType:Integer'
&& IsObject(schema['db'])
&& TypeGuard.TSchema(schema['inner'])
}
// ... etc
}
9.4 Builder Pattern with Column Metadata
export class DbBuilder {
protected Create<T extends TSchema>(schema: Record<PropertyKey, any>, inner: T, db: DbColumnMeta): any {
return { ...schema, inner, db }
}
public Integer(column: Column, inner?: TSchema): TDbInteger {
const defaultInner = inner ?? t.Integer({ minimum: ..., maximum: ... })
return this.Create(
{ [Kind]: 'DbType:Integer' },
defaultInner,
{ dataType: 'integer', columnType: column.columnType, ... }
)
}
}
9.5 What This Enables
- Validation:
Value.Check(dbSchema, value)works via TypeRegistry delegation - Schema introspection:
dbSchema.innerfor validation-only,dbSchema.dbfor database metadata - Type extraction:
Static<typeof dbSchema>correctly resolves throughinner - Migration generation: Walk
dbSchema.dbto produce DDL - Drizzle integration: Replace
columnToSchema()withDbType.fromColumn()that producesDbType schemas
10. Key Differences from TypeDef's Approach
| Aspect | TypeDef | Proposed DbType |
|---|---|---|
| Relation to TypeBox | Replacement | Composition (wrapping) |
| Validation | Custom ValueCheck entirely | Delegate to inner via TypeRegistry |
| TypeGuard | Fully custom | Compose with TypeBox's TypeGuard |
| Metadata | Flat metadata: {} bag |
Structured db: DbColumnMeta |
| Kind namespace | TypeDef:* |
DbType:* |
| Inner schema reference | None (TypeDef IS the schema) | inner: TSchema field |
| Purpose | Binary protocol types | Database column types with validation |
The composition approach is essential because DbType must preserve TypeBox's rich validation capabilities (format, pattern, minimum/maximum, etc.) while layering database semantics on top. TypeDef's flat replacement approach works because binary protocol types have simpler validation needs (int8 range checks, ISO timestamp formatting, etc.).