The fastest decorator-based DTO validation library for TypeScript. baker generates optimized validation and serialization code once at seal time, then reuses the sealed executors on every call.
bun add @zipbul/bakerZero reflect-metadata. Zero runtime dependencies. Sealed codegen.
- Bun ≥ 1.3.13. baker relies on TC39 decorator metadata (
Symbol.metadata), which Node does not populate — it is Bun-only. - ESM only. baker ships no CommonJS build.
- TypeScript ≥ 5.2 with native (TC39, Stage 3) decorators. Bun runs TypeScript directly, so your DTOs need no separate build step.
import { Baker, Field, isBakerIssueSet } from '@zipbul/baker';
import { isString, isNumber, isEmail, min, minLength } from '@zipbul/baker/rules';
const baker = new Baker();
@baker.Recipe
class UserDto {
@Field(isString, minLength(2)) name!: string;
@Field(isNumber(), min(0)) age!: number;
@Field(isString, isEmail()) email!: string;
}
// Call once at startup, after this baker's DTOs are defined.
baker.seal();
// All rules here are sync. `deserializeSync` returns `UserDto | BakerIssueSet` — a precise,
// non-Promise type you can narrow and read directly.
const result = baker.deserializeSync(UserDto, {
name: 'Alice',
age: 30,
email: 'alice@test.com',
});
if (isBakerIssueSet(result)) {
// Reached only for invalid input, e.g. [{ path: 'email', code: 'isEmail' }]
console.log(result.errors);
} else {
console.log(result.name); // 'Alice' — typed as UserDto
}deserializeSync returns either your typed instance or a BakerIssueSet; narrow between them with isBakerIssueSet. If any rule or transformer on the DTO is async, use deserializeAsync and await it instead (see Runtime API).
Which variant? The bare
deserializereturns the honest sync-or-async unionT | BakerIssueSet | Promise<T | BakerIssueSet>, so reading a property off its result does not typecheck — TypeScript cannot rule out thePromisearm. UsedeserializeSync(throws if the DTO is async) ordeserializeAsync(always aPromise) for a directly-usable type. The same applies tovalidate/serialize.
| Concept | What it does |
|---|---|
new Baker(config?) |
An isolated registration + seal scope. Multiple bakers never mix. Use @app.Recipe and app.seal(). |
@app.Recipe |
Marks a class as a DTO of that baker. Only @Field properties are part of the contract. |
@Field(...rules) |
Declares a validated field. Global — works with any baker. |
app.seal() |
Compiles that baker's DTOs into executor functions. Call once, at startup. |
app.deserialize / app.validate / app.serialize |
Run that baker's compiled executors: parse+validate, validate-only, or emit a plain object. |
Examples below assume a
const baker = new Baker()in scope and a singlebaker.seal()after the DTOs are defined.
baker generates optimized JavaScript functions once on first seal, then executes them on every call — no per-call rule interpretation.
| Feature | baker | class-validator | Zod |
|---|---|---|---|
| Approach | AOT code generation | Runtime interpretation | Schema method chain |
| Decorators | @Field (unified) |
30+ individual | N/A |
| Dependencies | None (zero runtime) | validator, libphonenumber-js |
None |
| Sync DTO return | Direct value | Promise | Direct value |
Benchmarked against multiple libraries on simple, nested, array, and error-collection scenarios. Exact numbers vary by machine and runtime — see bench/ for the suite and to measure on your machine.
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.
Every rule carries the value type it validates, so @Field rejects a rule applied to a field of the wrong type at compile time — a field that could never validate no longer type-checks:
@baker.Recipe
class UserDto {
@Field(isString) name!: string; // ok
@Field(isString) age!: number; // ✗ compile error: 'number' is not assignable to 'string'
@Field(isString, min(5)) code!: string; // ✗ compile error: mixed rule domains (string + number)
}optional/nullablefields work:@Field(isString) name!: string | nullcompiles (the type check covers the rule's domain, not presence —nullis still rejected at runtime unless{ nullable: true }).equals/isInkeep the check via literal widening —@Field(isIn(['a', 'b'] as const)) status!: stringcompiles.arrayOf(...)constrains the element type:@Field(arrayOf(isString)) tags!: string[](alsoSet<string>/Map— any key type, the element/value type is what's checked); a wrong element rule fails to compile.isEmpty/isNotEmptyare universal and apply to any field.- Some rules have a union domain:
isLatitude/isLongitudeaccept astringor anumberfield. - Need a dynamically-built, unchecked rule list? Pass it via options:
@Field({ rules: buildRules() })is intentionally not domain-checked.
This uses native (TC39) decorator field types — no schema duplication; the field's own : T annotation is the single source of truth.
Most fields need only rules. The options below cover nested, conditional, collection, and key-mapping cases — reach for them as needed.
| Option | Type | Description |
|---|---|---|
type |
() => Dto | [Dto] | Set | Map |
Nested DTO. [Dto] for arrays; Set/Map for collections |
discriminator |
{ property, subTypes } |
Polymorphic dispatch (requires type) |
keepDiscriminatorProperty |
boolean |
Keep the discriminator key in the result |
rules |
(EmittableRule | ArrayOfMarker)[] |
Dynamic rule list; bypasses the compile-time domain check |
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 |
@baker.Recipe
class UserDto {
@Field(isString) name!: string;
// Validated & exposed only when a matching group is requested at runtime.
@Field(isString, { groups: ['admin'] }) ssn!: string;
// Rules apply only when the predicate returns true for the input object.
@Field(isString, isEmail(), { when: obj => obj.contactable === true })
email!: string;
// Override the default error message for this field's failures.
@Field(isString, minLength(2), { message: 'Name must be at least 2 characters' })
displayName!: string;
}
baker.deserialize(UserDto, input); // `ssn` is skipped
baker.deserialize(UserDto, input, { groups: ['admin'] }); // `ssn` is includedA field with no groups is always included; a field tagged with groups participates only when a matching group is passed via runtime options. See RuntimeOptions for the call-site shape.
114 built-in validation rules.
The sections below are curated highlights, not an exhaustive listing — the full set is the export surface of
@zipbul/baker/rules.
Constants vs factories: rules listed without
()are pre-built constants — use them bare (@Field(isString)). Rules shown with()are factories you must call (@Field(isNumber())). Passing a factory without calling it throwsBakerError.
isString, isInt, isBoolean, isDate, isArray, isObject, isRegExp, isFunction, isStatelessRegExp — constants, no () needed.
isNumber(options?), isEnum(entity) — factories, require ().
min(n), max(n), isPositive, isNegative, isDivisibleBy(n)
minLength(n), maxLength(n), length(min, max), contains(seed), notContains(seed), matches(regex)
isEmail(), isURL(), isUUID(version?), isIP(version?), isISO8601(), isJSON, isJWT, isCreditCard, isIBAN(), isFQDN(), isMACAddress(), isBase64(), isHexColor, isSemVer, isMongoId, isPhoneNumber, isStrongPassword(), isULID(), isCUID2(), isHttpToken
arrayMinSize(n), arrayMaxSize(n), arrayUnique(), arrayNotEmpty, arrayContains(values), arrayNotContains(values)
arrayOf(...rules)validates each element of an array against the given rules. It is imported from the main entry (@zipbul/baker), not@zipbul/baker/rules.
equals(val), notEquals(val), isIn(values), isNotIn(values), isEmpty, isNotEmpty
minDate(date), maxDate(date)
isMobilePhone(locale), isPostalCode(locale), isIdentityCard(locale), isPassportNumber(locale)
isNotEmptyObject(options?), isInstance(Ctor) — factories, require ().
isUint8Array — constant, no () needed.
isByteSize(min, max?) — factory, requires ().
oneOf(...rules) — value matches at least one of the given rules (first matching branch wins).
arrayEvery(...rules) — value is an array and every element passes all of the given rules.
Both are factories — call with the rules to combine — and are exported from @zipbul/baker/rules.
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),
};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 |
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"luxonTransformer and momentTransformer require their respective libraries as optional peer dependencies — install whichever you use. Their options objects are exported as the LuxonTransformerOptions / MomentTransformerOptions types. Calling either transformer without its peer installed throws BakerError at await (the load happens inside the async constructor).
// bun add luxon
import { luxonTransformer } from '@zipbul/baker/transformers';
const luxon = await luxonTransformer({ zone: 'Asia/Seoul' });
@baker.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).
@baker.Recipe
class AddressDto {
@Field(isString) city!: string;
}
@baker.Recipe
class UserDto {
@Field({ type: () => AddressDto }) address!: AddressDto;
@Field({ type: () => [AddressDto] }) addresses!: AddressDto[];
}@baker.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.
@baker.Recipe
class PetOwner {
@Field({
type: () => CatDto,
discriminator: {
property: 'kind',
subTypes: [
{ value: CatDto, name: 'cat' },
{ value: DogDto, name: 'dog' },
],
},
})
pet!: CatDto | DogDto;
}@baker.Recipe
class BaseDto {
@Field(isString) id!: string;
}
@baker.Recipe
class UserDto extends BaseDto {
@Field(isString) name!: string;
// inherits 'id' field with isString rule
}A Baker is an isolated registration + seal scope. Construct one per app/library; multiple bakers in one process never mix.
@app.Recipe— class decorator; registers the class as one of this baker's DTOs.app.seal()— required. Compiles the baker's DTOs (and any nested DTOs they reach) into executor functions. Call once at startup, after the baker's DTOs are defined. Idempotent.- Config is passed to the constructor:
const app = new Baker({
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
debug: false, // include field-exclusion reasons as comments in generated code
});app.deserialize / app.serialize / app.validate run that baker's compiled executors and throw BakerError if the class was not sealed by this baker.
Isolation: each baker compiles its own executor per class into its own map, so the same class sealed by two bakers behaves per each baker's config — apps never mix. (An undecorated subclass resolves to its nearest sealed ancestor within that baker.)
Three entry points share the same sync/async shape. If the DTO has any async rule or transformer on the relevant side, the call returns a Promise; otherwise it returns the value directly.
| Function | Signature | Returns (sync) | Notes |
|---|---|---|---|
deserialize |
(Class, input, options?) |
T | BakerIssueSet |
Parse + validate. Never throws on validation failure. |
validate |
(Class, input, options?) |
true | BakerIssueSet |
Validate only. |
serialize |
(instance, options?) |
Record<string, unknown> |
Emit a plain object. No validation. |
Async returns are wrapped: Promise<T \| BakerIssueSet>, Promise<true \| BakerIssueSet>, and Promise<Record<string, unknown>> respectively. The deserialize and serialize sides are independent — a DTO can be async on deserialize but sync on serialize, and vice versa.
To validate a single primitive without a DTO, call the rule directly: isEmail()(value).
Each function has *Sync and *Async variants for unambiguous types:
deserializeSync/serializeSync/validateSync— throwBakerErrorif the DTO is async on that side.deserializeAsync/serializeAsync/validateAsync— always return aPromise(sync DTOs are wrapped viaPromise.resolve).
deserialize, serialize, and validate accept an optional trailing options argument:
interface RuntimeOptions {
groups?: string[]; // per-request group selection — see @Field `groups`
}Groups are passed at call time (not on @Field) because the active set typically varies per request.
Custom validation rule. Two forms — a (name, validate) shorthand or an options object:
const koreanPhone = createRule('koreanPhone', v => /^01[016789]/.test(v as string));import { RequiredType } from '@zipbul/baker';
const isEven = createRule({
name: 'isEven',
validate: v => typeof v === 'number' && v % 2 === 0,
requiresType: RequiredType.Number,
});Type guard. Narrows a result to BakerIssueSet, whose errors array holds { path, code, message?, context?, constraints? } issues. constraints carries the failing rule's parameters when it has any (e.g. { min: 5 } for min(5)); type-check rules and structural failures omit it.
baker separates two failure modes:
-
BakerError(thrown) — a programming mistake, not a data problem. Thrown for, e.g.:- using a DTO before
app.seal(), or calling a strict*Syncvariant on an async DTO new Baker()given a config object with an unknown key, or a non-plain-object config- a seal-time metadata invariant failing — an invalid discriminator shape, a wrong Map key type, a reserved/banned field name, or a DTO class that
extends Array(rejected because a successful instance would otherwise be indistinguishable from a validation-failure array) - an unsupported key in per-call
options @Fieldreceiving a non-rule value, or a rule/transformer factory that was never called- a
@Fieldtype/collection thunk that throws (wrapped, with the original error ascause) - a missing optional peer dependency (
luxonormoment)
Fix the code; don't catch it in request handlers.
- using a DTO before
-
BakerIssueSet(returned) — a validation failure.deserializeandvalidatereturn it instead of throwing. Guard withisBakerIssueSetand read.errors.
const result = baker.deserializeSync(UserDto, input);
if (isBakerIssueSet(result)) {
for (const issue of result.errors) {
console.log(`${issue.path}: ${issue.code}`); // e.g. "email: isEmail"
// issue.constraints holds the rule's parameters when present, e.g. { min: 5 } for min(5).
}
} else {
// result is a typed UserDto
}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 ships with zero runtime dependencies. Run bench/ to measure the exact difference on your machine.
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. Like Zod, baker is type-checked: a rule applied to the wrong field type is a compile error (see Type-checked fields) — the difference is baker takes the field's own : T annotation as the source of truth rather than inferring the type from a schema. Choose Zod if you need schema-first design or Node support; choose baker if you need class-based DTOs on Bun with maximum performance.
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.
Yes. baker's @Field decorator works alongside NestJS pipes. Use app.deserialize() (your Baker instance) in a custom validation pipe.
Calling app.seal() once at startup walks the baker's DTOs (and their nested DTOs), analyzes field metadata, generates optimized JavaScript executor functions, and stores them in that baker's map. Subsequent app.deserialize / app.serialize / app.validate calls run the pre-compiled functions directly. There is no auto-seal — using a DTO before app.seal() raises BakerError.
baker builds its executors with
new Function(). Under a strict Content-Security-Policy this requires'unsafe-eval'; baker will not run in environments that forbid runtime code generation.
import {
Baker, // .deserialize / .validate / .serialize (+ *Sync / *Async) live on the instance
createRule,
Field,
arrayOf,
isBakerIssueSet,
BakerError,
RequiredType,
ExcludeMode,
} from '@zipbul/baker';
import type {
Transformer,
TransformParams,
BakerIssue,
BakerIssueSet,
FieldOptions,
ArrayOfMarker,
EmittableRule,
RuntimeOptions,
BakerConfig,
} from '@zipbul/baker';
import { isString, isEmail, isULID, isCUID2 /* …114 rules */ } from '@zipbul/baker/rules';
import { trimTransformer, jsonTransformer /* …and more */ } from '@zipbul/baker/transformers';Decorators are also available from the @zipbul/baker/decorators subpath.
Advanced: @zipbul/baker/symbols exposes the Symbol.metadata polyfill (self-installing) and the internal RAW metadata symbol — most apps never import this directly.
MIT