18 KiB
Typemap Architecture Research
Overview
@alkdev/typemap is a translation system that converts between four schema formats: TypeBox, Valibot, Zod, and Syntax (a TypeBox DSL string format). It implements a N-way translation matrix where each target can translate from any source, including itself (identity).
The architecture has three key properties we want to understand and potentially reuse:
- Module structure enabling tree-shaking
- Translation target isolation
- Guard/detection layer for runtime type dispatch
1. Module Structure for Tree-Shaking
Directory Layout
src/
index.ts # Public API barrel export
guard.ts # Runtime type detection
options.ts # Shared option types
static.ts # Static type inference utility
syntax/
syntax.ts # Dispatcher (Syntax function)
syntax-from-syntax.ts # Identity: syntax -> syntax
syntax-from-typebox.ts # TypeBox -> Syntax
syntax-from-valibot.ts # Valibot -> Syntax (via TypeBox)
syntax-from-zod.ts # Zod -> Syntax (via TypeBox)
typebox/
typebox.ts # Dispatcher (TypeBox function)
typebox-from-syntax.ts # Syntax -> TypeBox
typebox-from-typebox.ts # Identity: TypeBox -> TypeBox
typebox-from-valibot.ts # Valibot -> TypeBox
typebox-from-zod.ts # Zod -> TypeBox
valibot/
valibot.ts # Dispatcher (Valibot function)
valibot-from-syntax.ts # Syntax -> Valibot (via TypeBox)
valibot-from-typebox.ts # TypeBox -> Valibot
valibot-from-valibot.ts # Identity: Valibot -> Valibot
valibot-from-zod.ts # Zod -> Valibot (via TypeBox)
common.ts # Shared Valibot type aliases
zod/
zod.ts # Dispatcher (Zod function)
zod-from-syntax.ts # Syntax -> Zod (via TypeBox)
zod-from-typebox.ts # TypeBox -> Zod
zod-from-valibot.ts # Valibot -> Zod (via TypeBox)
zod-from-zod.ts # Identity: Zod -> Zod
compile/
compile.ts # High-level compile API
validator.ts # Standard-schema wrapper for TypeCompiler
environment.ts # Detects eval() support
path.ts # JSON Pointer -> Standard Schema path conversion
standard.ts # Standard Schema V1 interface definition
How Tree-Shaking Works
Each from-* file is a self-contained translation unit that only imports:
- The source library (e.g.,
valibot) for type definitions and runtime introspection - The
@alkdev/typeboxruntime utilities (ValueGuard) for structural checks ../guard.tsin the dispatcher files only
The index.ts re-exports everything from every from-* file:
export * from './typebox/typebox-from-syntax'
export * from './typebox/typebox-from-typebox'
export * from './typebox/typebox-from-valibot'
export * from './typebox/typebox-from-zod'
export { type TTypeBox, TypeBox } from './typebox/typebox'
// ... same pattern for valibot/, zod/, syntax/
Tree-shaking mechanism: When a bundler processes import { TypeBox } from '@alkdev/typemap', it:
- Parses
index.tsand sees the re-exports - Follows into
typebox/typebox.tswhich imports allfrom-*files - The
TypeBox()dispatcher function conditionally callsTypeBoxFromSyntax,TypeBoxFromTypeBox,TypeBoxFromValibot, orTypeBoxFromZodbased on guards
Critical limitation: Because the dispatchers (TypeBox(), Valibot(), Zod(), Syntax()) use runtime guards to decide which path to take, bundlers cannot eliminate the unused branches. If you call TypeBox(zodSchema), all four from-* modules are still included because the bundler cannot know at build time which guard branch will execute.
The real tree-shaking opportunity exists at the named export level. A consumer who only imports TypeBoxFromZod directly (not via the TypeBox dispatcher) can avoid pulling in valibot/typebox-from-syntax/etc:
import { TypeBoxFromZod } from '@alkdev/typemap'
However, the current package.json exports map has a single entry point ("."), which means there's no sub-path exports for individual translation units. The tree-shaking effectiveness depends entirely on the bundler's ability to eliminate unused export * re-exports.
Build System
The build.mjs produces two output formats:
- CJS:
tscwith--module Node16->target/build/cjs/ - ESM:
tscwith--module ESNext->target/build/esm/, then mutates.js->.mjsand rewrites import specifiers
The generated package.json for the published package:
{
"types": "./build/cjs/index.d.ts",
"main": "./build/cjs/index.js",
"module": "./build/esm/index.mjs",
"esm.sh": { "bundle": false },
"exports": {
".": {
"require": { "types": "./build/cjs/index.d.ts", "default": "./build/cjs/index.js" },
"import": { "types": "./build/esm/index.d.mts", "default": "./build/esm/index.mjs" }
}
}
}
The "esm.sh": { "bundle": false } directive tells esm.sh CDN not to bundle peer dependencies, which is important for keeping external libraries external.
No sub-path exports are defined, meaning consumers can't do granular imports like @alkdev/typemap/typebox-from-zod. This is a missed tree-shaking opportunity.
2. Translation Target Isolation
The Dispatcher Pattern
Each target directory has a dispatcher.ts file (e.g., typebox/typebox.ts) that:
- Imports all
from-*modules in the same target directory - Imports
guard.tsfor runtime type detection - Exposes a single overloaded function with conditional dispatch
Example from typebox/typebox.ts:
import { TypeBoxFromSyntax } from './typebox-from-syntax'
import { TypeBoxFromTypeBox } from './typebox-from-typebox'
import { TypeBoxFromValibot } from './typebox-from-valibot'
import { TypeBoxFromZod } from './typebox-from-zod'
import * as g from '../guard'
export function TypeBox(...args: any[]): never {
const [parameter, type, options] = g.Signature(args)
return (
g.IsSyntax(type) ? TypeBoxFromSyntax(ContextFromParameter(parameter), type, options) :
g.IsTypeBox(type) ? TypeBoxFromTypeBox(type) :
g.IsValibot(type) ? TypeBoxFromValibot(type) :
g.IsZod(type) ? TypeBoxFromZod(type) :
t.Never()
) as never
}
Key observations:
- The function uses rest args (
...args: any[]) andg.Signature()to normalize the overloaded call signatures - Runtime dispatch via ternary chain over guard functions
- The same pattern is duplicated for
Valibot(),Zod(), andSyntax()
The from-* File Pattern
Each from-* file is a pure translation module:
{name}-from-{source}.ts
Where {name} is the target and {source} is the input format. These files:
- Import only the source library types and the target library types
- Implement the full translation mapping (every type node in source -> target)
- Are self-contained: no cross-dependencies on other
from-*files except:
The Two-Hop Translation Pattern
Some translations don't have a direct path. Instead, they go through TypeBox as an intermediate:
// syntax-from-valibot.ts - Valibot -> Syntax (via TypeBox)
export function SyntaxFromValibot<Type extends v.BaseSchema<any, any, any>>(type: Type): TSyntaxFromValibot<Type> {
const typebox = TypeBoxFromValibot(type) // Valibot -> TypeBox
const result = SyntaxFromTypeBox(typebox) // TypeBox -> Syntax
return result as never
}
// syntax-from-zod.ts - Zod -> Syntax (via TypeBox)
export function SyntaxFromZod<Type extends z.ZodTypeAny | z.ZodEffects<any>>(type: Type): TSyntaxFromZod<Type> {
const typebox = TypeBoxFromZod(type) // Zod -> TypeBox
const result = SyntaxFromTypeBox(typebox) // TypeBox -> Syntax
return result as never
}
This means TypeBox acts as a hub/intermediate representation (IR). The translation graph looks like:
Syntax
/ \
SyntaxFrom SyntaxFrom
| |
TypeBoxFrom TypeBoxFrom
/ \ / \
/ \ / \
Valibot Zod Valibot Zod
TypeBox is the canonical IR. All translations between non-TypeBox formats go through TypeBox first, then to the target. This reduces the N^2 translation problem to 2N translations (source->IR, IR->target).
3. Guard/Detection Layer
The guard.ts module provides two mechanisms:
Type-Level Guards
export type SyntaxType = string
export type TypeBoxType = t.TSchema
export type ValibotType = v.BaseSchema<any, any, v.BaseIssue<any>>
export type ZodType = z.ZodTypeAny | z.ZodEffects<any>
These are used in the conditional type system of each dispatcher:
export type TTypeBox<Parameter extends TParameter, Type extends object | string, Result = (
Type extends g.SyntaxType ? TTypeBoxFromSyntax<...> :
Type extends g.TypeBoxType ? TTypeBoxFromTypeBox<Type> :
Type extends g.ValibotType ? TTypeBoxFromValibot<Type> :
Type extends g.ZodType ? TTypeBoxFromZod<Type> :
t.TNever
)> = Result
Runtime Guards
export function IsSyntax(value: unknown): value is string {
return t.ValueGuard.IsString(value)
}
export function IsTypeBox(type: unknown): type is t.TSchema {
return t.KindGuard.IsSchema(type) // checks for [Symbol.for('@alkdev/typebox/Kind')]
}
export function IsValibot(type: unknown): type is v.AnySchema {
return (
t.ValueGuard.IsObject(type) &&
t.ValueGuard.HasPropertyKey(type, '~standard') &&
t.ValueGuard.IsObject(type['~standard']) &&
t.ValueGuard.HasPropertyKey(type['~standard'], 'vendor') &&
type['~standard'].vendor === 'valibot'
)
}
export function IsZod(type: unknown): type is z.ZodTypeAny {
return (
t.ValueGuard.IsObject(type) &&
t.ValueGuard.HasPropertyKey(type, '~standard') &&
t.ValueGuard.IsObject(type['~standard']) &&
t.ValueGuard.HasPropertyKey(type['~standard'], 'vendor') &&
type['~standard'].vendor === 'zod'
)
}
Key design decisions:
- TypeBox detection uses the internal
[Kind]symbol (viaKindGuard.IsSchema) - Valibot and Zod detection use the
~standardproperty from the Standard Schema spec - Syntax detection just checks
typeof === 'string' - All guards use
@alkdev/typebox'sValueGuardutility functions rather than raw JS, ensuring consistency
Signature Resolution
The Signature() function normalizes overloaded arguments:
// (parameter, syntax, options) -> [parameter, type, options]
// (syntax, options) -> [{}, type, options]
// (parameter, options) -> [parameter, type, {}]
// (syntax | type) -> [{}, type, {}]
This allows the API to accept multiple calling conventions:
TypeBox({ Users: UsersSchema }, '{ id: number, name: string }', options) // with context
TypeBox('{ id: number, name: string }', options) // syntax with options
TypeBox(zodSchema) // just a schema
4. Compile Directory
The compile/ directory provides the high-level Compile() function that:
- Accepts any schema type (Syntax string, TypeBox, Valibot, Zod)
- Converts it to TypeBox via the
TypeBox()dispatcher - Compiles the TypeBox schema into a
TypeCheckvalidator using@alkdev/typebox/compiler - Wraps it in a
Validatorclass that implements the Standard Schema V1 interface
This is the consumer-facing API that ties everything together. The Compile function uses the same guard/signature pattern:
export function Compile(...args: any[]): never {
const [parameter, type, options] = g.Signature(args)
const schema = t.ValueGuard.IsString(type) ? TypeBox(parameter, type, options) : TypeBox(type)
const check = ResolveTypeCheck(schema)
return new Validator(check) as never
}
The Validator class (compile/validator.ts) implements StandardSchemaV1 with ~standard property, providing:
.Check(value)- validation.Parse(value)- decode/transform.Errors(value)- error iterator.Code()- generated validation code string
The environment.ts module detects if eval() is available (for JIT compilation) and falls back to dynamic validation if not.
5. Adaptation for DrizzleBox (Database Dialect Targets)
Mapping the Pattern
| Typemap Concept | DrizzleBox Equivalent |
|---|---|
| TypeBox | Drizzle IR (dialect-agnostic schema representation) |
| Valibot | SQLite dialect |
| Zod | PostgreSQL dialect |
| Syntax string | no direct equivalent (or: SQL string templates) |
| TypeBox->Valibot | DrizzleIR->SQLite DDL |
| TypeBox->Zod | DrizzleIR->PostgreSQL DDL |
| Valibot->TypeBox | SQLite introspection->DrizzleIR |
| Guard system | Dialect detection from schema objects |
Proposed Module Structure
src/
index.ts
guard.ts # Detect drizzle dialect type (sqlite/postgres/mysql)
options.ts # Shared dialect options
ir/
ir.ts # DrizzleIR dispatcher (IR function)
ir-from-sqlite.ts # SQLite schema -> DrizzleIR
ir-from-postgres.ts # PostgreSQL schema -> DrizzleIR
ir-from-mysql.ts # MySQL schema -> DrizzleIR
ir-from-ir.ts # Identity
sqlite/
sqlite.ts # SQLite dispatcher
sqlite-from-ir.ts # DrizzleIR -> SQLite DDL/types
sqlite-from-sqlite.ts # Identity
sqlite-from-postgres.ts # Postgres -> SQLite (via IR)
sqlite-from-mysql.ts # MySQL -> SQLite (via IR)
postgres/
postgres.ts # PostgreSQL dispatcher
postgres-from-ir.ts # DrizzleIR -> PostgreSQL DDL/types
postgres-from-postgres.ts # Identity
postgres-from-sqlite.ts # SQLite -> PostgreSQL (via IR)
postgres-from-mysql.ts # MySQL -> PostgreSQL (via IR)
mysql/
mysql.ts # MySQL dispatcher
mysql-from-ir.ts # DrizzleIR -> MySQL DDL/types
mysql-from-mysql.ts # Identity
mysql-from-sqlite.ts # SQLite -> MySQL (via IR)
mysql-from-postgres.ts # PostgreSQL -> MySQL (via IR)
Guard Adaptation
// guard.ts
import { sqliteTable } from 'drizzle-orm/sqlite-core'
import { pgTable } from 'drizzle-orm/pg-core'
import { mysqlTable } from 'drizzle-orm/mysql-core'
export type SqliteType = ReturnType<typeof sqliteTable>
export type PostgresType = ReturnType<typeof pgTable>
export type MysqlType = ReturnType<typeof mysqlTable>
export function IsSqlite(schema: unknown): schema is SqliteType {
// Detect via Drizzle's internal dialect markers or symbol properties
return typeof schema === 'function' && /* check dialect symbol */
}
export function IsPostgres(schema: unknown): schema is PostgresType {
// Similar detection
}
export function IsMysql(schema: unknown): schema is MysqlType {
// Similar detection
}
Improving Tree-Shaking Over Typemap
Typemap's current architecture has a tree-shaking weakness: the dispatcher functions pull in all translation paths. For DrizzleBox, we can improve this with sub-path exports:
{
"exports": {
".": {
"import": "./build/esm/index.mjs",
"require": "./build/cjs/index.js"
},
"./sqlite": {
"import": "./build/esm/sqlite/sqlite.mjs",
"require": "./build/cjs/sqlite/sqlite.js"
},
"./postgres": {
"import": "./build/esm/postgres/postgres.mjs",
"require": "./build/cjs/postgres/postgres.js"
},
"/mysql": {
"import": "./build/esm/mysql/mysql.mjs",
"require": "./build/cjs/mysql/mysql.js"
}
}
}
This allows consumers to import only what they need:
// Only pulls in sqlite + ir code
import { Sqlite } from '@alkdev/drizzlebox/sqlite'
Rather than the single-entry-point approach typemap uses, where the entire translation matrix is always imported.
IR-as-Hub Pattern
Following typemap's TypeBox-as-IR pattern, DrizzleBox should use a dialect-agnostic intermediate representation as the hub:
SQLite DDL
/ \
sqlite-from from-sqlite
| |
IR (Drizzle Intermediate Representation)
| |
postgres-from from-postgres
\ /
PostgreSQL DDL
This means we only need to write:
- 4 translation modules per dialect:
dialect-from-ir(generate),from-dialect(parse),dialect-from-dialect(identity), plus cross-dialect shortcuts that go through IR - Cross-dialect translations (e.g., SQLite->PostgreSQL) are automatically composed:
PostgreSQL(IR(SQLite(schema)))
Key Differences from Typemap
-
Type safety is the output, not the input: Typemap's schemas are validation schemas. DrizzleBox's schemas are database table definitions. The "translation" is generating column types, constraints, and DDL.
-
Dialect-specific features need escape hatches: PostgreSQL has
JSONB, MySQL hasENUM, SQLite has limitedALTER TABLE. The IR needs a way to express "dialect-specific" types that don't translate losslessly. This is similar to how typemap handles Valibot-specific types (likeBlob,Custom) by creating custom TypeBox kinds. -
Peer dependency handling: Typemap uses
peerDependenciesfor valibot/zod - users only install what they use. DrizzleBox should do the same withdrizzle-orm/sqlite-core,drizzle-orm/pg-core,drizzle-orm/mysql-core. -
No identity bypass needed: In typemap,
TypeBoxFromTypeBoxjust clones. In DrizzleBox, a dialect-to-same-dialect translation might normalize/validate rather than clone.
Recommended Architecture
// Each dialect module exports:
// 1. A dispatcher function (like TypeBox()) that auto-detects input
// 2. Direct from-* functions for explicit, tree-shakeable usage
// 3. Type-only exports for the generated types
// postgres/postgres.ts
export function Pg<Schema>(schema: Schema): TPg<Schema> { /* dispatch */ }
export { PgFromIR } from './postgres-from-ir'
export { PgFromSqlite } from './postgres-from-sqlite'
export { PgFromMysql } from './postgres-from-mysql'
export { PgFromPg } from './postgres-from-pg'
This gives consumers two usage modes:
- Convenience:
Pg(schema)- auto-detects, pulls in everything - Tree-shakeable:
PgFromIR(irSchema)- explicit, minimal imports