@graftjs/zod
v0.7.0
Published
Type-safe bidirectional schema mapping - Zod adapter
Maintainers
Readme
@graftjs/zod
Type-safe bidirectional schema mapping for Zod
High-level Zod integration for Graft. Map between two Zod schemas with automatic field detection, runtime validation, and compile-time type safety.
Why Graft?
When building anti-corruption layers or integrating with external APIs, you need to map between different schemas (e.g., your database models ↔ API responses). Graft makes this:
- Type-safe: Compile-time errors if you wire incompatible types
- Bidirectional: One config gives you both directions
- Fast: Precompiled mapping specs for hot paths
- Safe: Built-in prototype pollution prevention
- Ergonomic: Auto-mapping reduces boilerplate
Installation
npm install @graftjs/zod zod
# or
pnpm add @graftjs/zod zod
# or
yarn add @graftjs/zod zodRequirements:
- Node.js >= 18
- TypeScript >= 5.0
- Zod >= 4.0
Quick Start
import { z } from 'zod';
import { createGraft } from '@graftjs/zod';
// Define your schemas
const UserDB = z.object({
user_id: z.string(),
email_address: z.string(),
created_at: z.string()
});
const UserAPI = z.object({
id: z.string(),
email: z.string(),
createdAt: z.string()
});
// Create a graft with callback syntax (full autocomplete!)
const userGraft = createGraft(UserDB, UserAPI, fields => ({
id: fields.user_id, // Full autocomplete on `fields.`
email: fields.email_address,
createdAt: fields.created_at
})).named('db', 'api');
// Convert DB → API
const apiUser = userGraft.toApi({
user_id: '123',
email_address: '[email protected]',
created_at: '2024-01-01T00:00:00Z'
});
// { id: '123', email: '[email protected]', createdAt: '2024-01-01T00:00:00Z' }
// Convert API → DB
const dbUser = userGraft.toDb({
id: '123',
email: '[email protected]',
createdAt: '2024-01-01T00:00:00Z'
});
// { user_id: '123', email_address: '[email protected]', created_at: '2024-01-01T00:00:00Z' }Core Concepts
Auto-Mapping
Fields with matching names and compatible types are automatically mapped:
const UserDB = z.object({
id: z.string(),
email: z.string(),
age: z.number()
});
const UserAPI = z.object({
id: z.string(), // ✅ Auto-mapped (same name, compatible type)
email: z.string(), // ✅ Auto-mapped
age: z.number(), // ✅ Auto-mapped
name: z.string() // ❌ Not in UserDB - must map manually
});
// No config needed for id, email, age!
const graft = createGraft(UserDB, UserAPI, fields => ({
name: fields.username // Only map what's different
}));Auto-mapping rules:
- Exact key name match required
- Types must be compatible (after unwrapping optional/nullable)
- Conservative: different types never auto-map
Bidirectional Conversion
One configuration automatically gives you both directions:
const graft = createGraft(UserDB, UserAPI, fields => ({
id: fields.user_id
})).named('db', 'api');
// Forward: Left → Right (DB → API)
const apiUser = graft.toApi(dbUser);
// Backward: Right → Left (API → DB)
const dbUser = graft.toDb(apiUser);
// Round-trip safety
assert.deepEqual(dbUser, graft.toDb(graft.toApi(dbUser)));Without .named() - use generic toRight/toLeft:
const graft = createGraft(UserDB, UserAPI, fields => ({
id: fields.user_id
}));
graft.toRight(dbUser); // Left → Right
graft.toLeft(apiUser); // Right → LeftValidation Modes
Control when validation occurs:
const graft = createGraft(UserDB, UserAPI, {}, {
validate: 'both' // Default: validate input AND output
});
// Other modes:
validate: 'from' // Only validate source schema
validate: 'to' // Only validate target schema
validate: 'none' // Skip all validation (fastest, least safe)Strict Mode
Ensure all required fields are mapped:
// ❌ Type error at compile time!
const graft = createGraft(UserDB, UserAPI, {
// Missing mapping for required field 'name'
});
// Type: { __error: "Missing required mappings"; __missing: "name" }
// ✅ Valid
const graft = createGraft(UserDB, UserAPI, {
name: 'username' // All required fields covered
});Strict modes:
'to'(default): All required target fields must be mapped'from': All required source fields must be reproducible'both': Both directions enforced'none': No enforcement (permissive)
Nested Grafts
Compose smaller grafts for nested object mapping:
// Create a graft for addresses
const AddressDB = z.object({
street_name: z.string(),
city_name: z.string(),
zip_code: z.string()
});
const AddressAPI = z.object({
street: z.string(),
city: z.string(),
zip: z.string()
});
const addressGraft = createGraft(AddressDB, AddressAPI, fields => ({
street: fields.street_name,
city: fields.city_name,
zip: fields.zip_code
}));
// Use the address graft in a parent graft
const UserDB = z.object({
user_id: z.string(),
user_name: z.string(),
address: AddressDB
});
const UserAPI = z.object({
id: z.string(),
name: z.string(),
address: AddressAPI
});
const userGraft = createGraft(UserDB, UserAPI, fields => ({
id: fields.user_id,
name: fields.user_name,
address: {
from: 'address', // source path
graft: addressGraft // nested graft to apply
}
})).named('db', 'api');
// Both directions work automatically!
const apiUser = userGraft.toApi(dbUser); // Address transformed via addressGraft
const dbUser = userGraft.toDb(apiUser); // Inverse also worksNested graft features:
- Deep source paths:
{ from: 'user.data.address', graft: addressGraft } - Optional nested objects handled correctly (undefined skipped, null passed)
- Deeply nested grafts (3+ levels) supported
- Works alongside transforms and auto-mapping
- Clear error context via
GraftNestedError
Transforms
Apply functions to values during mapping:
import { dateString, centsToPrice, pipe, map } from '@graftjs/zod';
const graft = createGraft(ProductDB, ProductAPI, {
id: 'product_id',
createdAt: {
from: 'created_at',
...dateString() // ISO string ↔ Date
},
price: {
from: 'price_cents',
...centsToPrice() // cents ↔ dollars
}
});Built-in transforms: dateString, timestamp, timestampMs, centsToPrice, csvToArray, jsonString, base64, uppercase, lowercase, trim, boolToInt
API Reference
createGraft(leftSchema, rightSchema, configOrCallback?, options?)
Create a bidirectional schema mapper.
Type Signature:
function createGraft<LeftSchema, RightSchema, Config>(
leftSchema: z.ZodObject<any>,
rightSchema: z.ZodObject<any>,
configOrCallback?: Config | ((fields: KeyProxy<z.infer<LeftSchema>>) => Config),
options?: GraftOptions
): Graft<z.infer<LeftSchema>, z.infer<RightSchema>>Parameters:
leftSchema: Left (source) Zod object schemarightSchema: Right (target) Zod object schemaconfigOrCallback: Mapping configuration - object or callback (optional if fully auto-mappable)options: Graft options (optional)
Returns: Graft<Left, Right> - Frozen graft instance
Example:
// Callback syntax (recommended - full autocomplete!)
const userGraft = createGraft(
UserDB,
UserAPI,
fields => ({ id: fields.user_id, email: fields.email_address }),
{ strict: 'to', validate: 'both' }
).named('db', 'api');
// Object syntax (backwards compatible)
const userGraft = createGraft(
UserDB,
UserAPI,
{ id: 'user_id', email: 'email_address' }
);defineGraft(fromSchema, toSchema)
Curried version for better type inference.
const defineUserGraft = defineGraft(UserDB, UserAPI);
// Better type inference for complex configs
const userGraft = defineUserGraft({
id: 'user_id',
email: 'email_address'
}, {
strict: 'both'
});Graft Instance Methods
toRight(input: unknown): Right
Convert from left to right schema (Left → Right).
Process:
- Parse and validate input with
leftSchema - Execute mapping
- Parse and validate output with
rightSchema
Throws: GraftValidationError if validation fails
const apiUser = userGraft.toRight({
user_id: '123',
email_address: '[email protected]'
});toLeft(input: unknown): Left
Convert from right to left schema (Right → Left, inverse).
const dbUser = userGraft.toLeft({
id: '123',
email: '[email protected]'
});mapRight(input: Left): Right
Fast path - skip input validation.
Use when you already have a validated Left object:
// Assuming dbUser is already validated
const apiUser = userGraft.mapRight(dbUser);Performance benefit: Skips source schema parsing
mapLeft(input: Right): Left
Fast path - skip input validation.
// Assuming apiUser is already validated
const dbUser = userGraft.mapLeft(apiUser);.named(leftName, rightName) or .named({ left, right })
Create custom-named method variants:
const graft = createGraft(UserDB, UserAPI, fields => ({
id: fields.user_id
})).named('db', 'api');
// Now you have semantic methods:
graft.toApi(dbUser); // Left → Right
graft.toDb(apiUser); // Right → Left
graft.mapApi(dbUser); // Fast path Left → Right
graft.mapDb(apiUser); // Fast path Right → Leftspec: CompiledSpec
Access the compiled mapping specification (for debugging).
console.log(userGraft.spec.forward);
// [{ from: 'user_id', to: 'id' }, ...]Examples
Example 1: Database ↔ API
const ProductDB = z.object({
product_id: z.string(),
product_name: z.string(),
price_cents: z.number(),
created_at: z.string()
});
const ProductAPI = z.object({
id: z.string(),
name: z.string(),
price: z.number(),
createdAt: z.string()
});
const productGraft = createGraft(ProductDB, ProductAPI, fields => ({
id: fields.product_id,
name: fields.product_name,
price: fields.price_cents,
createdAt: fields.created_at
})).named('db', 'api');
// Use in route handler
app.get('/products/:id', async (req, res) => {
const dbProduct = await db.products.findOne(req.params.id);
const apiProduct = productGraft.toApi(dbProduct);
res.json(apiProduct);
});
app.post('/products', async (req, res) => {
const dbProduct = productGraft.toDb(req.body);
await db.products.create(dbProduct);
res.json({ success: true });
});Example 2: Third-Party Integration
const StripeCustomer = z.object({
id: z.string(),
email: z.string(),
name: z.string().nullable(),
metadata: z.record(z.string())
});
const InternalUser = z.object({
stripeId: z.string(),
email: z.string(),
fullName: z.string().optional(),
metadata: z.record(z.string())
});
const stripeGraft = createGraft(StripeCustomer, InternalUser, fields => ({
stripeId: fields.id,
fullName: fields.name
// email and metadata auto-mapped (same names)
})).named('stripe', 'internal');
// Sync from Stripe webhook
async function handleStripeWebhook(event: any) {
const stripeCustomer = event.data.object;
const internalUser = stripeGraft.toInternal(stripeCustomer);
await saveUser(internalUser);
}Example 3: Mixed Auto + Manual Mapping
const UserDB = z.object({
user_id: z.string(),
email: z.string(), // Same name
age: z.number(), // Same name
created_at: z.string()
});
const UserAPI = z.object({
id: z.string(),
email: z.string(), // Auto-mapped
age: z.number(), // Auto-mapped
createdAt: z.string()
});
// Only map what's different - email and age auto-mapped!
const graft = createGraft(UserDB, UserAPI, fields => ({
id: fields.user_id,
createdAt: fields.created_at
})).named('db', 'api');Example 4: Optional Fields
const UserDB = z.object({
user_id: z.string(),
middle_name: z.string().optional(),
nickname: z.string().nullable()
});
const UserAPI = z.object({
id: z.string(),
middleName: z.string().optional(),
nickname: z.string().nullable()
});
const graft = createGraft(UserDB, UserAPI, fields => ({
id: fields.user_id,
middleName: fields.middle_name
// nickname auto-mapped (same name)
}));
// Optional fields handled correctly
const user = graft.toRight({ user_id: '123' });
// { id: '123' } - optional fields omitted when undefinedError Handling
Validation Errors
import { GraftValidationError } from '@graftjs/zod';
try {
const apiUser = userGraft.toRight(invalidData);
} catch (error) {
if (error instanceof GraftValidationError) {
console.error(`Validation failed during ${error.direction} conversion`);
console.error(error.zodError.issues); // Original Zod errors
}
}Configuration Errors
import { GraftCollisionError, GraftSecurityError } from '@graftjs/zod';
try {
const graft = createGraft(UserDB, UserAPI, {
id: 'user_id',
id: 'other_field' // ❌ Collision!
});
} catch (error) {
if (error instanceof GraftCollisionError) {
console.error(`Collision on target: ${error.targetKey}`);
console.error(`Conflicting sources: ${error.sourceKeys.join(', ')}`);
}
}
try {
const graft = createGraft(UserDB, UserAPI, {
'__proto__': 'evil' // ❌ Security violation!
});
} catch (error) {
if (error instanceof GraftSecurityError) {
console.error(`Dangerous key blocked: ${error.dangerousKey}`);
}
}Best Practices
1. Create Grafts Once
// ✅ Good - create once, reuse many times
export const userGraft = createGraft(UserDB, UserAPI, fields => ({
id: fields.user_id
})).named('db', 'api');
// Use in multiple places
app.get('/users/:id', async (req, res) => {
const dbUser = await getUser(req.params.id);
res.json(userGraft.toApi(dbUser));
});
// ❌ Bad - recreating on every request
app.get('/users/:id', async (req, res) => {
const graft = createGraft(UserDB, UserAPI, fields => ({ id: fields.user_id }));
res.json(graft.toRight(dbUser)); // Wasteful!
});2. Use Type Inference
// ✅ Good - let TypeScript infer types
const userGraft = createGraft(UserDB, UserAPI, fields => ({ id: fields.user_id }));
type ApiUser = ReturnType<typeof userGraft.toRight>;
// ❌ Less good - manually typing everything
const userGraft: Graft<DbUser, ApiUser> = createGraft(...);3. Validate at Boundaries
// ✅ Good - validate external data
const apiUser = userGraft.toRight(untrustedInput);
// ✅ Also good - skip validation for internal data
const apiUser = userGraft.mapRight(alreadyValidatedDbUser);4. Explicit is Better Than Implicit
// ✅ Good - explicit mappings are self-documenting
const graft = createGraft(UserDB, UserAPI, fields => ({
id: fields.user_id,
email: fields.email_address,
createdAt: fields.created_at
}));
// ⚠️ Less clear - relies on auto-mapping
const graft = createGraft(UserDB, UserAPI);Type Safety Features
Compile-Time Type Checking
const UserDB = z.object({
user_id: z.string(),
age: z.number()
});
const UserAPI = z.object({
id: z.string(),
age: z.string() // Different type!
});
// ❌ Type error - age is number in DB but string in API
const graft = createGraft(UserDB, UserAPI, {
id: 'user_id',
age: 'age' // Type incompatibility caught at compile time!
});Required Field Coverage
const UserAPI = z.object({
id: z.string(),
email: z.string(),
name: z.string() // Required field
});
// ❌ Type error - missing mapping for 'name'
const graft = createGraft(UserDB, UserAPI, {
id: 'user_id',
email: 'email_address'
// Where's name?
});
// Hover over the error to see:
// Type: { __error: "Missing required mappings"; __missing: "name" }Performance Optimization
Graft is designed for performance:
- Precompiled Specs: Mapping logic compiled once at startup
- Tight Loops: Fast for-loops instead of map/reduce
- Null Prototype Objects:
Object.create(null)for outputs - Skip Validation: Use
mapRight/mapLeftwhen data is already validated
Benchmark (rough estimates):
- Compilation: ~1-2ms (one-time cost)
- Execution: ~0.1-0.5ms per object (thousands per second)
- Zod validation: Usually the bottleneck, not mapping
Current Capabilities (v0.5)
Supported features:
- ✅ Deep path mapping (
user.profile.name) - ✅ Transforms with forward/backward functions
- ✅ Built-in transforms (dates, currency, JSON, etc.)
- ✅ Nested graft composition
- ✅ Auto-mapping for matching fields
Planned features:
- Array element mapping (apply grafts to array items)
- Union type support (discriminated unions)
See the roadmap for upcoming features.
TypeScript Configuration
For best results, use strict mode:
{
"compilerOptions": {
"strict": true,
"noUncheckedIndexedAccess": true
}
}Migration Guide
From Manual Mapping
Before:
function dbToApi(db: UserDB): UserAPI {
return {
id: db.user_id,
email: db.email_address,
createdAt: db.created_at
};
}
function apiToDb(api: UserAPI): UserDB {
return {
user_id: api.id,
email_address: api.email,
created_at: api.createdAt
};
}After:
const userGraft = createGraft(UserDB, UserAPI, fields => ({
id: fields.user_id,
email: fields.email_address,
createdAt: fields.created_at
})).named('db', 'api');
// Use anywhere
const apiUser = userGraft.toApi(dbUser);
const dbUser = userGraft.toDb(apiUser);License
MIT
See Also
@graftjs/core- Low-level API for building custom adapters- Main Documentation - Overview and roadmap
- Zod Documentation - Schema validation library
