@panmdaa/validate
v0.1.0
Published
Zero-dependency compiled TypeScript validation library — chain-style schemas, lazy compilation, tree-shaking, abort-early
Maintainers
Readme
@panmdaa/validate
Zero-dependency TypeScript validation library — chain-style schemas, lazy compilation, abort-early parsing.
@panmdaa/validate is a from-scratch validation library built for TypeScript. Schemas are plain, chainable values that double as type providers: Infer, Input, and Output derive precise types from the schema tree, so one definition drives both runtime checks and compile-time types. Each schema compiles itself once: it first attempts a dedicated generated function and falls back to the interpreter when needed. Pure ESM, tree-shakeable, zero runtime dependencies.
npm install @panmdaa/validateQuick look
import { Validator, type Infer } from "@panmdaa/validate";
const user = Validator.object({
id: Validator.number().int().positive(),
name: Validator.string().minLength(1).maxLength(100),
email: Validator.string().email(),
role: Validator.enum(["admin", "user"]),
tags: Validator.array(Validator.string()).max(10),
});
type User = Infer<typeof user>;
// {
// id: number;
// name: string;
// email: string;
// role: "admin" | "user";
// tags: string[];
// }
user.safeParse({
id: 1,
name: "Ada",
email: "[email protected]",
role: "admin",
tags: ["fp", "math"],
});
// { success: true, value: { id: 1, name: "Ada", email: "[email protected]", role: "admin", tags: ["fp", "math"] } }Parsing
Every schema is callable and exposes four parse modes:
| API | Behavior |
|-----|----------|
| schema(value) | Returns the validated value or throws ValidationError |
| schema.validate(value) | Same as calling the schema directly |
| schema.safeParse(value) | Returns a Result — never throws, aborts on the first issue |
| schema.safeParseAll(value) | Returns a Result and collects every issue |
| schema.is(value) | Type-guard boolean — value is Out |
import { Validator } from "@panmdaa/validate";
const age = Validator.number().int().min(0).max(120);
age(42); // 42
age(-1); // throws ValidationError
age.safeParse(42);
// { success: true, value: 42 }
age.safeParse(-1);
// { success: false, issues: [{ path: [], message: "Number must be >= 0" }] }
const form = Validator.object({
name: Validator.string().minLength(3),
age: Validator.number().min(18),
});
form.safeParseAll({ name: "A", age: 5 });
// { success: false, issues: [
// { path: ["name"], message: "String must contain at least 3 character(s)" },
// { path: ["age"], message: "Number must be >= 18" },
// ] }
age.is(30); // true
age.is("30"); // falsesafeParse stops at the first failing check; safeParseAll keeps going across every field and nested schema so you get the full report (each leaf reports its first failing check). See Error handling for the shape of Issue.
Primitives
import { Validator } from "@panmdaa/validate";
Validator.string(); // string
Validator.number(); // number
Validator.boolean(); // boolean
Validator.bigint(); // bigint
Validator.literal("on"); // "on" | 42 | true | 10n | null | undefined
Validator.enum(["a", "b"]); // "a" | "b" (string | number | boolean values)
Validator.custom<number>((v) => v > 0, "must be positive");
Validator.unknown(); // unknown — accepts anything
Validator.any(); // any — accepts anything
Validator.never(); // never — rejects everythingcustom predicates may return true, false, or a string (used as the error message when falsy). never always fails with "Expected never".
Strings
| Method | Fails when |
|--------|------------|
| .minLength(n) | value.length < n |
| .maxLength(n) | value.length > n |
| .length(n) | value.length !== n |
| .pattern(re) | !re.test(value) — a lastIndex = 0 reset makes global/sticky flags safe |
| .email() | the value fails the built-in email pattern |
| .url() | new URL(value) throws |
| .startsWith(s) / .endsWith(s) / .includes(s) | the value doesn't start with / end with / include s |
Every check accepts an optional custom message as its last argument.
const username = Validator.string()
.minLength(3, "too short")
.maxLength(20)
.pattern(/^[a-z0-9_]+$/);
username.safeParse("A");
// { success: false, issues: [{ path: [], message: "too short" }] }Numbers
| Method | Fails when |
|--------|------------|
| .int() | !Number.isInteger(value) |
| .finite() | !Number.isFinite(value) |
| .safe() | !Number.isSafeInteger(value) |
| .min(n) | value < n |
| .max(n) | value > n |
| .positive() / .negative() | the sign is wrong (0 fails both) |
| .nonnegative() / .nonpositive() | value crosses zero in the wrong direction |
NaN is rejected as a type error, before any check runs.
const score = Validator.number().min(0).max(100).int();Objects
Validator.object(shape) strips unknown keys by default. Objects are rebuilt (or returned by reference when nothing needs to change) and always validated field by field:
const point = Validator.object({
x: Validator.number(),
y: Validator.number(),
});Three modes control unknown keys:
Validator.object({ a: Validator.number() }); // strip — unknown keys are dropped
Validator.object({ a: Validator.number() }).passthrough(); // keep unknown keys
Validator.object({ a: Validator.number() }).strict(); // fail on unknown keysIn strip mode with no transforms, an object that already has exactly the shape keys is returned by reference (no allocation).
Arrays & tuples
const ids = Validator.array(Validator.number().int()).min(1).max(100);
const pair = Validator.tuple([Validator.string(), Validator.number()]);
// tuple items are validated positionally; length must match exactlyarray supports .min(n), .max(n), and .length(n), each with an optional message.
Unions
const id = Validator.union([Validator.string(), Validator.number().int()]);
const entity = Validator.discriminatedUnion("type", {
user: Validator.object({ type: Validator.literal("user"), name: Validator.string() }),
admin: Validator.object({ type: Validator.literal("admin"), level: Validator.number() }),
});
entity.safeParse({ type: "user", name: "Ada" });
// { success: true, value: { type: "user", name: "Ada" } }union tries each option in order and rolls back issues between attempts. discriminatedUnion dispatches on a single tag key — the tag is matched by String(tag) === tagValue, so numeric tags work too.
Records
const scores = Validator.record(Validator.number().int());
scores.safeParse({ a: 90, b: 85 });
// { success: true, value: { a: 90, b: 85 } }Every own key's value is validated against the value schema; keys pass through unchanged.
Wrappers
Validator.string().optional(); // string | undefined
Validator.string().nullable(); // string | null
Validator.string().default("fallback"); // fills undefined, lazily if given a function
Validator.string().transform((s) => s.length); // maps the output
Validator.string().refine((s) => s.startsWith("x")); // extra boolean (or message-string) checkWrappers compose in any order and each one narrows the inferred type:
const title = Validator.string()
.optional()
.default("untitled")
.transform((s) => s.trim())
.refine((s) => s.length > 0, "title cannot be empty");
title(undefined); // "untitled"
title(" hi "); // "hi"default accepts a thunk, which is evaluated once at compile time. refine (like custom) may return a string to use as the error message.
Type inference
Infer (output), Input (input), and Output (alias of Infer) walk the schema tree:
import { Validator, type Infer, type Input, type Output } from "@panmdaa/validate";
const schema = Validator.object({
id: Validator.number().int(),
email: Validator.string().email(),
meta: Validator.record(Validator.string()).optional().default({} as Record<string, string>),
});
type Out = Infer<typeof schema>;
// { id: number; email: string; meta: Record<string, string> }
type In = Input<typeof schema>;
// { id: number; email: string; meta: Record<string, string> | undefined }Error handling
Failed validation produces a ValidationError (thrown) or a failed Result with an issues array:
interface Issue {
path: (string | number)[];
message: string;
expected?: string;
received?: string;
}pathwalks nested schemas (["user", "address", 0]for the first item of an array underuser.address).expecteddescribes what the schema wanted.receivedis a human-readable description of the actual value.
| Received value | received description |
|----------------|------------------------|
| null | "null" |
| an array | "array" |
| an object | "object" |
| a string | the string itself |
| anything else | String(value) |
received is omitted when the input is undefined.
import { Validator, ValidationError } from "@panmdaa/validate";
const schema = Validator.object({
user: Validator.object({ age: Validator.number().int() }),
});
try {
schema({ user: { age: "x" } });
} catch (error) {
if (error instanceof ValidationError) {
error.message; // "Expected number"
error.issues; // [{ path: ["user", "age"], message: "Expected number", expected: "number", received: "x" }]
}
}API
| Member | Description |
|--------|-------------|
| Validator.string() / number() / boolean() / bigint() | Primitive schemas |
| Validator.literal(v) | Exact match for string \| number \| boolean \| bigint \| null \| undefined |
| Validator.enum(values) | One of a fixed list of string \| number \| boolean |
| Validator.custom<T>(fn, message?) | Arbitrary predicate (boolean \| string return) |
| Validator.unknown() / any() / never() | Pass-all / pass-all / fail-all schemas |
| Validator.object(shape) | Object schema — .passthrough(), .strip(), .strict() |
| Validator.array(item) | Array schema — .min(n), .max(n), .length(n) |
| Validator.tuple(items) | Fixed-length, positionally validated tuple |
| Validator.union(options) | First matching option wins |
| Validator.discriminatedUnion(key, options) | Tag-keyed dispatch over object schemas |
| Validator.record(value) | Validates every own value against value |
| .optional() / .nullable() | Accept undefined / null |
| .default(value \| fn) | Fill undefined with a value (lazily if a function) |
| .transform(fn) | Map the output value |
| .refine(fn, message?) | Extra boolean (or message-string) check |
| schema(value) / .validate(value) | Parse or throw ValidationError |
| .safeParse(value) | Result, abort-early |
| .safeParseAll(value) | Result, collects all issues |
| .is(value) | Type guard: value is Out |
| .kind | Schema kind (e.g. "string", "object", "refine") |
| Infer<S> / Output<S> | Output type of a schema |
| Input<S> | Input type of a schema |
| ValidationError | Error with issues and message |
| Result<O> | { success: true; value: O } \| { success: false; issues: Issue[] } |
| Issue | { path, message, expected?, received? } |
Internal architecture
src/
├── index.ts ← root barrel — re-exports core, schemas, Validator
├── validator.ts ← the Validator namespace (factory functions)
├── core/
│ ├── compile.ts ← interpreter: compiles a schema node into a RunFn
│ ├── codegen.ts ← codegen: emits a dedicated function via new Function
│ ├── schema.ts ← makeCallable — binds parse methods to a node
│ ├── run.ts ← fail() + describe() — issue construction
│ └── types.ts ← Schema, Result, Issue, Infer/Input/Output
├── schemas/ ← declarative schema factories (primitive + compound)
└── compilers/ ← runtime backends: leaf checks, compound compilers, wrappersEvery schema is a plain node { kind, def }. When a schema is created, makeCallable compiles it: it tries the code generator (codegen.ts), which emits a specialized function with the checks inlined; if generation fails, it falls back to the interpreter (compile.ts). Both are cached per schema. Leaf nodes (string, number, boolean, bigint, literal, enum, custom, unknown, any, never) compile to a single closure reused by both backends.
Scripts
| npm run | Description |
|-----------|-------------|
| build | Bundle with tsup (ESM + DTS) and emit declaration types |
| test | Run the vitest test suite |
| test:watch | Run vitest in watch mode |
| typecheck | TypeScript strict check (tsc --noEmit) |
| lint | Biome lint (--write) |
| format | Biome format (--write) |
| bench | Run benchmarks against zod, valibot, arktype, yup and ajv |
| bench:memory | Run the memory-allocation benchmark |
Full documentation lives in docs/ — usage, the complete API reference, error handling, type inference, and the architecture behind the library.
