@nds-stack/bun-schema
v0.1.0-alpha.0
Published
Zero-dependency schema validation for Bun — TypeScript-first, Zod-like API
Maintainers
Readme
@nds-stack/bun-schema
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: "[email protected]", age: 30 });Why bun-schema
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
Installation
bun add @nds-stack/bun-schemaHow It Works
Schema<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 orderAPI
s.string()
| 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 |
s.number()
| 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 |
s.boolean()
| Method | Description |
|--------|-------------|
| .coerce() | Coerce "true"/"false" strings |
s.object(shape)
| Method | Description |
|--------|-------------|
| .strict() | Reject unknown keys |
| .passthrough() | Allow unknown keys |
s.array(item)
| Method | Description |
|--------|-------------|
| .min(n) | Minimum array length |
| .max(n) | Maximum array length |
| .length(n) | Exact array length |
Modifiers
| 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> |
s.union(schemas)
Tries each schema in order, returns first match.
Type Inference
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 }Error Handling
.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" }]
}Limitations
- 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
Multi-Instance / Cross-Boundary
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 instanceCustomization Guide
Custom Validation with .pipe()
const PositiveInt = s.number().int().pipe(s.number().positive());Wrap in your own class
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;
}
}Comparison Table
| 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 | ✅ | ⚠️ Partial | ✅ | ❌ |
| Zod-like API | ✅ | Native | Similar | ❌ JSON Schema |
| Coercion | ✅ | ✅ | ✅ | ❌ |
Benchmarks
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.
Real-World Example
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 });
}
}License
MIT
