@zipbul/baker
v3.4.1
Published
Bun-only AOT decorator-based DTO validation & serialization. class-validator DX, sealed code generation, zero reflect-metadata.
Maintainers
Readme
@zipbul/baker
The fastest decorator-based DTO validation library for TypeScript. Generates optimized validation and serialization code on first seal, then reuses the sealed executors on every call.
bun add @zipbul/bakerZero reflect-metadata. Sealed codegen. 99%+ line coverage.
Requires Bun ≥ 1.3.13. baker relies on TC39 decorator metadata (
Symbol.metadata), which Node does not populate — it is Bun-only.
Quick Start
import { deserialize, isBakerIssueSet, Field, Recipe, seal } from '@zipbul/baker';
import { isString, isNumber, isEmail, min, minLength } from '@zipbul/baker/rules';
@Recipe
class UserDto {
@Field(isString, minLength(2)) name!: string;
@Field(isNumber(), min(0)) age!: number;
@Field(isString, isEmail()) email!: string;
}
// Call once at app startup, after all DTOs are loaded.
seal();
const result = await deserialize(UserDto, {
name: 'Alice',
age: 30,
email: '[email protected]',
});
if (isBakerIssueSet(result)) {
console.log(result.errors); // [{ path: 'email', code: 'isEmail' }]
} else {
console.log(result.name); // 'Alice' — typed as UserDto
}Why Baker?
Baker generates optimized JavaScript functions once on first seal, then executes them on every call.
| Feature | baker | class-validator | Zod |
| ----------------------- | -------------------- | ---------------------- | ------------------- |
| Valid path (5 fields) | fast sealed path | slower | slower |
| Invalid path (5 fields) | fast sealed path | slower | slower |
| Approach | AOT code generation | Runtime interpretation | Schema method chain |
| Decorators | @Field (unified) | 30+ individual | N/A |
| reflect-metadata | Not needed | Required | N/A |
| Sync DTO return | Direct value | Promise | Direct value |
Performance
Benchmarked against multiple libraries on simple, nested, array, and error-collection scenarios. Exact numbers vary by machine and runtime.
See bench/ for the current benchmark suite and exact scenarios.
API
seal(...classes?)
Required. Call once at app startup, after every DTO module has been imported. With no arguments, seals every class registered via @Field so far. With class arguments, seals only those (and any nested DTOs they reach). Idempotent.
deserialize / serialize / validate throw BakerError if the DTO is not sealed. Tests that need to mutate decorator metadata should call seal() after each configure(...) reconfiguration.
deserialize<T>(Class, input, options?)
Returns T | BakerIssueSet for sync DTOs, Promise<T | BakerIssueSet> for async DTOs. Never throws on validation failure.
If the DTO has any async rule or transformer, deserialize returns a Promise. Otherwise it returns the value directly. For full type safety pick a strict variant (see below).
deserializeSync<T> / deserializeAsync<T>
Strict variants. deserializeSync throws BakerError if the DTO is async on the deserialize side. deserializeAsync always returns Promise (sync DTOs are wrapped via Promise.resolve).
serialize<T>(instance, options?)
Returns Record<string, unknown> for sync DTOs, Promise<Record<string, unknown>> for async DTOs. No validation. Async asymmetry: _isSerializeAsync is independent of _isAsync — a DTO can be async on deserialize but sync on serialize, and vice versa.
serializeSync<T> / serializeAsync<T>
Strict variants. serializeSync throws BakerError if the DTO is async on the serialize side.
validate(Class, input, options?)
Validates input against a decorated class's schema. Returns true | BakerIssueSet for sync paths, Promise<true | BakerIssueSet> for async paths. To validate a single primitive without a DTO, call the rule directly (e.g. isEmail()(value)).
validateSync / validateAsync
Strict variants. validateSync throws BakerError if the DTO is async; validateAsync always returns Promise.
isBakerIssueSet(value)
Type guard. Narrows result to BakerIssueSet containing { path, code, message?, context? }[].
configure(config)
Global configuration. Must be called before seal(). After seal, configure(...) throws BakerError; reconfiguring requires unseal() (test-only helper) + configure(...) + seal() again.
configure({
autoConvert: true, // coerce "123" → 123
allowClassDefaults: true, // use class field initializers for missing keys
stopAtFirstError: true, // return on first validation failure
forbidUnknown: true, // reject undeclared fields
});createRule(name, validate) / createRule(options)
Custom validation rule. Two forms — a (name, validate) shorthand or an options object:
const koreanPhone = createRule('koreanPhone', v => /^01[016789]/.test(v as string));const isEven = createRule({
name: 'isEven',
validate: v => typeof v === 'number' && v % 2 === 0,
requiresType: 'number',
});@Field Decorator
One decorator for everything — replaces 30+ individual decorators from class-validator.
Only fields decorated with @Field participate in validation, deserialization, and serialization. Undecorated fields are silently absent from results — they are not part of the DTO contract.
@Field(...rules)
@Field(...rules, options)
@Field(options)
@Field() // marker-only (no rules)Each rule must be an emittable rule object created via createRule() or one of the built-in rule factories. Passing a raw function (e.g. @Field(isNumber) instead of @Field(isNumber())) throws BakerError at decorator-evaluation time.
Options
| Option | Type | Description |
| ----------------- | ------------------------------------------------- | ------------------------------ |
| type | () => Dto \| [Dto] | Nested DTO. [Dto] for arrays |
| discriminator | { property, subTypes } | Polymorphic dispatch |
| keepDiscriminatorProperty | boolean | Keep the discriminator key in the result |
| optional | boolean | Allow undefined |
| nullable | boolean | Allow null |
| name | string | Bidirectional key mapping |
| deserializeName | string | Input key mapping |
| serializeName | string | Output key mapping |
| exclude | boolean \| 'deserializeOnly' \| 'serializeOnly' | Field exclusion |
| groups | string[] | Conditional visibility |
| when | (obj) => boolean | Conditional validation |
| transform | Transformer \| Transformer[] | Value transformer |
| message | string \| (args) => string | Error message override |
| context | unknown | Error context |
| mapValue | () => Dto | Map value DTO |
| setValue | () => Dto | Set element DTO |
Transformers
Bidirectional value transformers with separate deserialize and serialize methods.
import type { Transformer } from '@zipbul/baker';
const centsTransformer: Transformer = {
deserialize: ({ value }) => (typeof value === 'number' ? value * 100 : value),
serialize: ({ value }) => (typeof value === 'number' ? value / 100 : value),
};Built-in Transformers
import {
trimTransformer,
toLowerCaseTransformer,
toUpperCaseTransformer,
roundTransformer,
unixSecondsTransformer,
unixMillisTransformer,
isoStringTransformer,
csvTransformer,
jsonTransformer,
} from '@zipbul/baker/transformers';| Transformer | deserialize | serialize |
| ------------------------ | -------------------------- | -------------------------- |
| trimTransformer | trim string | trim string |
| toLowerCaseTransformer | lowercase | lowercase |
| toUpperCaseTransformer | uppercase | uppercase |
| roundTransformer(n?) | round to n decimals | round to n decimals |
| unixSecondsTransformer | unix seconds → Date | Date → unix seconds |
| unixMillisTransformer | unix ms → Date | Date → unix ms |
| isoStringTransformer | ISO string → Date | Date → ISO string |
| csvTransformer(sep?) | "a,b" → ["a","b"] | ["a","b"] → "a,b" |
| jsonTransformer | JSON string → object | object → JSON string |
Transform Array Order
Multiple transformers apply as a codec stack:
- Deserialize: left to right —
[A, B, C]applies A, then B, then C - Serialize: right to left —
[A, B, C]applies C, then B, then A
@Field(isString, { transform: [trimTransformer, toLowerCaseTransformer] })
email!: string;
// deserialize " HELLO " → trim → toLowerCase → "hello"
// serialize "hello" → toLowerCase → trim → "hello"Optional Peer Transformers
// bun add luxon
import { luxonTransformer } from '@zipbul/baker/transformers';
const luxon = await luxonTransformer({ zone: 'Asia/Seoul' });
@Recipe
class EventDto {
@Field({ transform: luxon }) startAt!: DateTime;
}// bun add moment
import { momentTransformer } from '@zipbul/baker/transformers';
const mt = await momentTransformer({ format: 'YYYY-MM-DD' });Note on
format: Theformatoption inluxonTransformer/momentTransformercontrols the serialize-side output only. On deserialize, both transformers parse the input with the library's default parser (ISO-first for Luxon, lenient parser for Moment). Using a lossy format like'YYYY-MM-DD'makes the transformer one-way —serialize → deserializewill not recover the original time of day. If you need a lossless roundtrip, omitformat(defaults to ISO 8601).
Rules
105 built-in validation rules.
Type Checkers
isString, isInt, isBoolean, isDate, isArray, isObject — constants, no () needed.
isNumber(options?), isEnum(entity) — factories, require ().
Numbers
min(n), max(n), isPositive, isNegative, isDivisibleBy(n)
Strings
minLength(n), maxLength(n), length(min, max), contains(seed), notContains(seed), matches(regex)
Formats
isEmail(), isURL(), isUUID(version?), isIP(version?), isISO8601(), isJSON, isJWT, isCreditCard, isIBAN(), isFQDN(), isMACAddress(), isBase64(), isHexColor, isSemVer, isMongoId, isPhoneNumber(), isStrongPassword(), isULID(), isCUID2(), isHttpToken
Arrays
arrayMinSize(n), arrayMaxSize(n), arrayUnique(), arrayNotEmpty, arrayContains(values), arrayOf(...rules)
Common
equals(val), notEquals(val), isIn(values), isNotIn(values), isEmpty, isNotEmpty
Date
minDate(date), maxDate(date)
Locale
isMobilePhone(locale), isPostalCode(locale), isIdentityCard(locale), isPassportNumber(locale)
Nested DTOs
@Recipe
class AddressDto {
@Field(isString) city!: string;
}
@Recipe
class UserDto {
@Field({ type: () => AddressDto }) address!: AddressDto;
@Field({ type: () => [AddressDto] }) addresses!: AddressDto[];
}Collections
@Recipe
class UserDto {
@Field({ type: () => Set, setValue: () => TagDto }) tags!: Set<TagDto>;
@Field({ type: () => Map, mapValue: () => PriceDto }) prices!: Map<string, PriceDto>;
}Deserialize input shape: a
Setfield accepts a JSON array, aMapfield accepts a plain object keyed by string. Serialize emits the same shapes.
Discriminator
@Recipe
class PetOwner {
@Field({
type: () => CatDto,
discriminator: {
property: 'kind',
subTypes: [
{ value: CatDto, name: 'cat' },
{ value: DogDto, name: 'dog' },
],
},
})
pet!: CatDto | DogDto;
}Inheritance
@Recipe
class BaseDto {
@Field(isString) id!: string;
}
@Recipe
class UserDto extends BaseDto {
@Field(isString) name!: string;
// inherits 'id' field with isString rule
}FAQ
When should I use baker instead of class-validator?
When performance matters. baker generates optimized validation/serialization code at seal time instead of interpreting rules on every call, so it is substantially faster than class-validator on both valid and invalid input while providing the same decorator-based DX. baker also eliminates the reflect-metadata dependency. Run bench/ to measure the exact difference on your machine.
How does baker compare to Zod?
Zod uses schema method chains (z.string().email()), baker uses decorators (@Field(isString, isEmail())). baker generates optimized code at definition time instead of interpreting schemas at runtime. Choose Zod if you need schema-first design or Node support; choose baker if you need class-based DTOs on Bun with maximum performance.
Does baker support async validation?
Yes. If any rule or transformer is async, baker automatically detects it at seal time and generates an async executor. Sync DTOs return values directly without Promise wrapping.
Can I use baker with NestJS?
Yes. baker's @Field decorator works alongside NestJS pipes. Use deserialize() in a custom validation pipe.
How does the AOT code generation work?
Calling seal() once at app startup walks every registered DTO, analyzes field metadata, generates optimized JavaScript validation functions via new Function(), and caches them. Subsequent deserialize/serialize/validate calls execute the pre-compiled functions directly. There is no auto-seal — forgetting to call seal() raises BakerError on first use.
Exports
import {
seal,
deserialize, deserializeSync, deserializeAsync,
validate, validateSync, validateAsync,
serialize, serializeSync, serializeAsync,
configure, createRule, Field, arrayOf, isBakerIssueSet, BakerError,
} from '@zipbul/baker';
import type { Transformer, TransformParams, BakerError, BakerIssueSet, FieldOptions, EmittableRule, RuntimeOptions } from '@zipbul/baker';
import { isString, isEmail, isULID, isCUID2, ... } from '@zipbul/baker/rules';
import { trimTransformer, jsonTransformer, ... } from '@zipbul/baker/transformers';License
MIT
