Schema Adapters
Utilities for aligning validation library types with TypeScript types, solving the common type mismatch problem with coercion.
Import
import {
ereoSchema,
isEreoSchema,
schemaBuilder,
createPaginationParser,
createSortParser,
createFilterParser,
parseBoolean,
parseStringArray,
parseDate,
parseEnum,
} from '@ereo/data'
import type {
ValidationSchema,
EreoSchema,
InferSchemaOutput,
InferSchemaInput,
PaginationParams,
SortParams,
FilterParams,
} from '@ereo/data'The Problem
When using Zod with coercion, TypeScript sees a type mismatch:
// Problem: TypeScript thinks input is string, but output is number
const schema = z.object({
count: z.coerce.number()
})
// In your loader, TypeScript may incorrectly infer types
export const loader = async ({ searchParams }) => {
// Is searchParams.count a string or number? Type confusion!
}ereoSchema
Wrap a Zod (or compatible) schema to properly align input and output types.
Signature
function ereoSchema<TOutput>(
schema: ZodLikeSchema<TOutput>
): EreoSchema<TOutput>Basic Usage
import { z } from 'zod'
import { ereoSchema } from '@ereo/data'
// Without ereoSchema - type confusion with coerce
const rawSchema = z.object({
count: z.coerce.number(),
active: z.coerce.boolean(),
})
// With ereoSchema - types align correctly
const schema = ereoSchema(z.object({
count: z.coerce.number(),
active: z.coerce.boolean(),
}))
// TypeScript correctly sees: { count: number; active: boolean }With defineRoute
import { defineRoute, ereoSchema } from '@ereo/data'
import { z } from 'zod'
export const searchParams = ereoSchema(z.object({
page: z.coerce.number().default(1),
limit: z.coerce.number().default(10),
sort: z.enum(['newest', 'oldest']).default('newest'),
}))
export const route = defineRoute('/posts')
.searchParams(searchParams)
.loader(async ({ searchParams }) => {
// searchParams.page is number, not string!
// searchParams.limit is number, not string!
return db.posts.findMany({
skip: (searchParams.page - 1) * searchParams.limit,
take: searchParams.limit,
})
})
.build()Methods
parse(data: unknown): TOutput
Parse and validate input, throwing on error.
const schema = ereoSchema(z.object({ count: z.coerce.number() }))
schema.parse({ count: '42' }) // { count: 42 }
schema.parse({ count: 'invalid' }) // throwssafeParse(data: unknown): Result
Parse and validate input, returning a result object.
const result = schema.safeParse({ count: '42' })
if (result.success) {
console.log(result.data.count) // 42
} else {
console.log(result.error.errors) // Validation errors
}schemaBuilder
Build schemas without Zod dependency using a fluent API.
Signature
function schemaBuilder(): SchemaBuilder<{}>Example
import { schemaBuilder } from '@ereo/data'
const searchSchema = schemaBuilder()
.string('q')
.number('page', { default: 1, min: 1 })
.number('limit', { default: 10, max: 100 })
.boolean('includeInactive', { default: false })
.enum('status', ['active', 'inactive', 'pending'] as const)
.array('tags')
.build()
// Use in routes
export const route = defineRoute('/items')
.searchParams(searchSchema)
.loader(async ({ searchParams }) => {
// All types are inferred correctly
})
.build()Builder Methods
string(key, options?)
.string('name', { default: '', optional: true })number(key, options?)
.number('page', { default: 1, min: 1, max: 100 })boolean(key, options?)
.boolean('active', { default: true })enum(key, values, options?)
.enum('status', ['draft', 'published', 'archived'] as const, {
default: 'draft'
})array(key, options?)
.array('tags', { of: 'string' })
.array('ids', { of: 'number' })Built-in Parsers
createPaginationParser
Create a pagination parameter parser with sensible defaults.
function createPaginationParser(
options?: PaginationSchemaOptions
): ValidationSchema<unknown, PaginationParams>
interface PaginationParams {
page?: number
limit?: number
offset?: number
}
interface PaginationSchemaOptions {
defaultPage?: number // default: 1
defaultLimit?: number // default: 10
maxLimit?: number // default: 100
}Example:
import { createPaginationParser } from '@ereo/data'
const paginationSchema = createPaginationParser({
defaultLimit: 20,
maxLimit: 50,
})
export const route = defineRoute('/items')
.searchParams(paginationSchema)
.loader(async ({ searchParams }) => {
const { page, limit, offset } = searchParams
// page defaults to 1, limit capped at 50
})
.build()createSortParser
Create a sort parameter parser with allowed fields.
function createSortParser<T extends string>(
allowedFields: T[],
defaultField?: T,
defaultOrder?: 'asc' | 'desc'
): ValidationSchema<unknown, SortParams<T>>
interface SortParams<T extends string = string> {
sortBy?: T
sortOrder?: 'asc' | 'desc'
}Example:
import { createSortParser } from '@ereo/data'
const sortSchema = createSortParser(
['name', 'createdAt', 'updatedAt', 'price'],
'createdAt',
'desc'
)
export const route = defineRoute('/products')
.searchParams(sortSchema)
.loader(async ({ searchParams }) => {
const { sortBy, sortOrder } = searchParams
// sortBy is typed as 'name' | 'createdAt' | 'updatedAt' | 'price'
// sortOrder is 'asc' | 'desc'
})
.build()createFilterParser
Create a filter parameter parser with allowed values per field.
function createFilterParser<T extends Record<string, string[]>>(
allowedFilters: T
): ValidationSchema<unknown, { [K in keyof T]?: T[K][number] | T[K][number][] }>Example:
import { createFilterParser } from '@ereo/data'
const filterSchema = createFilterParser({
status: ['active', 'inactive', 'pending'],
category: ['electronics', 'clothing', 'food'],
color: ['red', 'blue', 'green', 'black', 'white'],
})
export const route = defineRoute('/products')
.searchParams(filterSchema)
.loader(async ({ searchParams }) => {
const { status, category, color } = searchParams
// status is typed as 'active' | 'inactive' | 'pending' | undefined
// Handles both single values and arrays
})
.build()Coercion Utilities
Standalone parsing functions for manual use.
parseBoolean
function parseBoolean(value: unknown, fallback?: boolean): booleanparseBoolean('true') // true
parseBoolean('1') // true
parseBoolean('yes') // true
parseBoolean('false') // false
parseBoolean('0') // false
parseBoolean('no') // false
parseBoolean(undefined) // false
parseBoolean(undefined, true) // true (custom fallback)parseStringArray
function parseStringArray(value: unknown): string[]parseStringArray(['a', 'b']) // ['a', 'b']
parseStringArray('a,b,c') // ['a', 'b', 'c']
parseStringArray('single') // ['single']
parseStringArray(undefined) // []parseDate
function parseDate(value: unknown, fallback?: Date): Date | undefinedparseDate('2024-01-15') // Date object
parseDate('invalid') // undefined
parseDate('invalid', new Date()) // fallback Date
parseDate(new Date()) // same Date objectparseEnum
function parseEnum<T extends string>(
value: unknown,
allowedValues: readonly T[],
fallback?: T
): T | undefinedconst status = ['active', 'inactive'] as const
parseEnum('active', status) // 'active'
parseEnum('invalid', status) // undefined
parseEnum('invalid', status, 'active') // 'active' (fallback)Type Utilities
InferSchemaOutput
Extract the output type from a schema.
type InferSchemaOutput<T> = ...
// Usage
const schema = ereoSchema(z.object({ count: z.number() }))
type Output = InferSchemaOutput<typeof schema>
// { count: number }InferSchemaInput
Extract the input type from a schema (before coercion).
type InferSchemaInput<T> = ...isEreoSchema
Check if a value is an EreoSchema instance.
function isEreoSchema<T>(value: unknown): value is EreoSchema<T>Combining Parsers
Combine multiple parsers for complex search parameters:
import {
ereoSchema,
createPaginationParser,
createSortParser,
createFilterParser,
} from '@ereo/data'
import { z } from 'zod'
// Combine parsers manually
const combinedSchema = {
parse: (data: unknown) => {
const input = data as Record<string, unknown>
return {
...createPaginationParser().parse(input),
...createSortParser(['name', 'createdAt']).parse(input),
...createFilterParser({ status: ['active', 'inactive'] }).parse(input),
q: typeof input.q === 'string' ? input.q : undefined,
}
},
safeParse: (data: unknown) => {
try {
return { success: true as const, data: combinedSchema.parse(data) }
} catch (error) {
return {
success: false as const,
error: { errors: [{ path: [], message: 'Validation failed' }] }
}
}
},
}
// Or use Zod for complex schemas
const searchSchema = ereoSchema(z.object({
// Pagination
page: z.coerce.number().default(1),
limit: z.coerce.number().default(10).refine(n => n <= 100),
// Sorting
sortBy: z.enum(['name', 'createdAt', 'price']).default('createdAt'),
sortOrder: z.enum(['asc', 'desc']).default('desc'),
// Filtering
status: z.enum(['active', 'inactive', 'all']).default('active'),
category: z.string().optional(),
// Search
q: z.string().optional(),
// Date range
from: z.coerce.date().optional(),
to: z.coerce.date().optional(),
}))Custom Validators
Create custom validators that work with the schema system:
import type { ValidationSchema } from '@ereo/data'
// Custom UUID validator
function createUuidParser(): ValidationSchema<unknown, string> {
const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i
return {
parse: (data: unknown): string => {
const str = String(data)
if (!uuidRegex.test(str)) {
throw new Error('Invalid UUID format')
}
return str.toLowerCase()
},
safeParse: (data: unknown) => {
try {
return { success: true, data: createUuidParser().parse(data) }
} catch (error) {
return {
success: false,
error: {
errors: [{ path: [], message: 'Invalid UUID format' }]
}
}
}
}
}
}
// Custom price range validator
interface PriceRange {
minPrice?: number
maxPrice?: number
}
function createPriceRangeParser(): ValidationSchema<unknown, PriceRange> {
return {
parse: (data: unknown): PriceRange => {
const input = data as Record<string, unknown>
const minPrice = input.minPrice ? Number(input.minPrice) : undefined
const maxPrice = input.maxPrice ? Number(input.maxPrice) : undefined
if (minPrice !== undefined && maxPrice !== undefined && minPrice > maxPrice) {
throw new Error('minPrice cannot be greater than maxPrice')
}
return {
minPrice: minPrice && !isNaN(minPrice) ? minPrice : undefined,
maxPrice: maxPrice && !isNaN(maxPrice) ? maxPrice : undefined,
}
},
safeParse: (data: unknown) => {
try {
return { success: true, data: createPriceRangeParser().parse(data) }
} catch (error) {
return {
success: false,
error: {
errors: [{ path: [], message: error instanceof Error ? error.message : 'Invalid price range' }]
}
}
}
}
}
}Related
- defineRoute Builder - Route definitions
- Type-Safe Routing - Overview
- Typed Navigation - Navigation