Zero-dependency schema validation for Bun — TypeScript-first, Zod-like API.
import { s, Infer } from "@nds-stack/bun-schema";
const UserSchema = s.object({
name: s.string().min(2).max(100),
email: s.string().email(),
age: s.number().int().min(0).optional(),
});
type User = Infer<typeof UserSchema>;
const user = UserSchema.parse({ name: "Alice", email: "alice@example.com", age: 30 });Bun has no built-in validation library, and existing options (Zod, Valibot, TypeBox) bundle Node.js polyfills that Bun doesn't need. bun-schema is:
- Zero dependencies — pure TypeScript, uses only Bun built-in APIs
- Bun-optimized — no polyfills, no Node.js compatibility layer
- TypeScript-first — full type inference from schemas
- Familiar API — Zod-like chaining with
.parse(),.safeParse(), type inference - Tree-shakeable — import only the types you need
bun add @nds-stack/bun-schemaSchema<T> is the abstract base class. Each schema type (StringSchema, NumberSchema, etc.) extends it and implements _safeParse() which recursively validates data and returns a Result<T> discriminated union:
{ success: true, value: T }on valid input{ success: false, error: ValidationError }on invalid input — with anissuesarray tracking each failure's field path and message
Validation is lazy: checks are accumulated via method chaining (.min(), .email(), etc.) and executed in order when .parse() or .safeParse() is called. This enables tree-shaking — only the checks you use are included in your bundle.
The s builder object is the entry point: s.string(), s.number(), s.object(), etc. Each call creates a new schema instance.
TypeScript types are inferred via the Infer<T> utility type, reading the generic parameter from Schema<T>. Object schemas map each key's schema type to the corresponding property type.
// Flow:
s.string().min(3).email()
→ new StringSchema()
→ adds "min(3)" check to internal list
→ adds "email" check to internal list
→ when .parse() called, runs all checks in order| Method | Description |
|---|---|
.min(n) |
Minimum string length |
.max(n) |
Maximum string length |
.length(n) |
Exact string length |
.email() |
Valid email format |
.url() |
Valid URL format |
.regex(pattern) |
Match regular expression |
.includes(substr) |
Must include substring |
.startsWith(prefix) |
Must start with prefix |
.endsWith(suffix) |
Must end with suffix |
.coerce() |
Coerce input to string |
| Method | Description |
|---|---|
.min(n) |
Minimum value |
.max(n) |
Maximum value |
.int() |
Must be integer |
.positive() |
Must be > 0 |
.negative() |
Must be < 0 |
.multipleOf(n) |
Must be divisible by n |
.finite() |
Must be finite |
.coerce() |
Coerce input to number |
| Method | Description |
|---|---|
.coerce() |
Coerce "true"/"false" strings |
| Method | Description |
|---|---|
.strict() |
Reject unknown keys |
.passthrough() |
Allow unknown keys |
| Method | Description |
|---|---|
.min(n) |
Minimum array length |
.max(n) |
Maximum array length |
.length(n) |
Exact array length |
| Method | Description |
|---|---|
.optional() |
Allow undefined |
.nullable() |
Allow null |
.pipe(schema) |
Chain validation |
.parse(value) |
Validate and return or throw |
.safeParse(value) |
Validate and return Result<T> |
Tries each schema in order, returns first match.
import { s, Infer } from "@nds-stack/bun-schema";
const schema = s.object({ name: s.string(), age: s.number() });
type T = Infer<typeof schema>; // { name: string; age: number }.parse() throws ValidationError on failure.
.safeParse() returns Result<T> — a discriminated union:
const result = schema.safeParse(data);
if (result.success) {
console.log(result.value); // T
} else {
console.log(result.error.issues); // ValidationIssue[]
// [{ path: ["email"], message: "Invalid email" }]
}- No
s.date()support yet - No
s.record()key-value map support - No
s.tuple()fixed-length tuple support - No
s.enum()string literal union support - No
s.instanceOf()class instance check - No lazy/recursive schema support
- No Standard Schema V1 compatibility yet
Each Schema instance is stateless and immutable (once built). Schemas are safe to use across workers, serverless invocations, and concurrent contexts:
import { s } from "@nds-stack/bun-schema";
const schema = s.object({ name: s.string() });
// Safe in multiple workers
new Worker(new URL("./worker.ts", import.meta.url));
// worker.ts
import { s } from "@nds-stack/bun-schema";
const localSchema = s.object({ name: s.string() }); // independent instanceconst PositiveInt = s.number().int().pipe(s.number().positive());import { s, Infer } from "@nds-stack/bun-schema";
class ValidatedUser {
readonly name: string;
readonly email: string;
constructor(data: unknown) {
const parsed = UserSchema.parse(data);
this.name = parsed.name;
this.email = parsed.email;
}
}| Aspect | @nds-stack/bun-schema |
zod |
valibot |
typebox |
|---|---|---|---|---|
| Dependencies | 0 | 1.5KB | 0 | 0 |
| Bun-native | ✅ | ❌ Polyfills | ❌ Polyfills | ❌ Polyfills |
| Bundle size | ~3KB | ~12KB | ~2KB (minimal) | ~6KB |
| Type inference | ✅ | ✅ | ✅ | ✅ |
| Tree-shakeable | ✅ | ✅ | ❌ | |
| Zod-like API | ✅ | Native | Similar | ❌ JSON Schema |
| Coercion | ✅ | ✅ | ✅ | ❌ |
bun run benchBenchmark: @nds-stack/bun-schema (10,000 iterations each)
==================================================================================
Operation Throughput
==================================================================================
native: typeof x === 'string' 2.512.373 ops/s
native: typeof x === 'number' 5.564.830 ops/s
s.string().parse 1.479.421 ops/s
s.number().parse 1.109.004 ops/s
s.boolean().parse 2.118.061 ops/s
s.object().parse (valid 3 fields) 299.093 ops/s
s.array().parse (5 items) 516.657 ops/s
s.union().parse 262.655 ops/s
==================================================================================
| Operation | Throughput | Baseline | Overhead |
|---|---|---|---|
| typeof string (native) | 2,512,373 ops/s | — | — |
| s.string().parse | 1,479,421 ops/s | 2.5M ops/s | ~41% |
| typeof number (native) | 5,564,830 ops/s | — | — |
| s.number().parse | 1,109,004 ops/s | 5.6M ops/s | ~80% |
| s.boolean().parse | 2,118,061 ops/s | — | — |
| s.object().parse (3 fields) | 299,093 ops/s | — | — |
| s.array().parse (5 items) | 516,657 ops/s | — | — |
| s.union().parse | 262,655 ops/s | — | — |
Note: Overhead is expected — schema validation includes type checking, constraint validation, error collection, and path tracking. Results vary by hardware — run bun run bench on your machine.
API Input Validation:
import { s, Infer, ValidationError } from "@nds-stack/bun-schema";
const CreateUserSchema = s.object({
username: s.string().min(3).max(50).regex(/^[a-zA-Z0-9_]+$/),
email: s.string().email(),
age: s.number().int().min(13).max(150).optional(),
});
type CreateUserInput = Infer<typeof CreateUserSchema>;
async function handleRequest(request: Request): Promise<Response> {
try {
const body = await request.json();
const input: CreateUserInput = CreateUserSchema.parse(body);
// input is fully typed here
return new Response(JSON.stringify({ ok: true }), { status: 201 });
} catch (e) {
if (e instanceof ValidationError) {
return new Response(JSON.stringify({ error: e.issues }), { status: 400 });
}
return new Response("Internal error", { status: 500 });
}
}MIT