-
Notifications
You must be signed in to change notification settings - Fork 2
Guardian Schemas
Composition primitives: building structured types from the validators.
Guardian.objectGuardian.arrayGuardian.tupleGuardian.recordGuardian.setGuardian.mapGuardian.oneOfGuardian.discriminatedUnionGuardian.intersectionGuardian.lazyGuardian.preprocessGuardian.instanceofGuardian.never- Type inference
- Nominal brands
import { Guardian } from '@tundralibs/guardian';
const User = Guardian.object({
id: Guardian.number().integer().positive(),
name: Guardian.string().minLength(1),
email: Guardian.string().email().optional(),
});
User.parse({ id: 1, name: 'Ada' });
// → { id: 1, name: 'Ada' }Properties not declared in the schema are silently dropped:
import type { BaseGuardian } from '@tundralibs/guardian';
declare const User: BaseGuardian<{ id: number; name: string }>; // from above
User.parse({ id: 1, name: 'Ada', secret: 'leaked' });
// → { id: 1, name: 'Ada' } ← `secret` is droppedThis is safer than the typical "passthrough" default — extra keys from the client never leak onto your domain object. Switch via:
| Mode | Behaviour | Use when |
|---|---|---|
.strip() (default) |
drop unknown keys silently | API request validation, internal boundaries |
.passthrough() |
keep unknown keys on the output | forward-compatible wire protocols where unknown fields should flow through |
.strict() |
throw GuardianError on unknown keys |
strict API contracts, config files |
.catchall(g) |
run guardian g against every unknown key's value |
partially-typed bags where extras must still pass a shared validator |
import { Guardian } from '@tundralibs/guardian';
const Strict = Guardian.object({ id: Guardian.number() }).strict();
Strict.parse({ id: 1, extra: 'x' }); // throws
const Open = Guardian.object({ id: Guardian.number() }).passthrough();
Open.parse({ id: 1, extra: 'x' }); // → { id: 1, extra: 'x' }
const Tagged = Guardian.object({ id: Guardian.number() })
.catchall(Guardian.string());
Tagged.parse({ id: 1, label: 'beta', stage: 'rollout' });
// → { id: 1, label: 'beta', stage: 'rollout' }
Tagged.parse({ id: 1, count: 5 });
// throws — `count` value isn't a string.catchall(g) and the other three modes are mutually exclusive; the last call wins on the same chain.
Set the mode before chaining cross-field validation. A mode change rebuilds the object guardian from its schema, which cannot carry over .refine() / .superRefine() / .transform() / .process() steps added earlier in the chain. Rather than silently drop them, the mode setters throw when a step is already present — so put the mode first. (The same rule applies to the schema-manipulation methods and to tuple .rest() / .labels(), which rebuild from their parts for the same reason.)
import { Guardian } from '@tundralibs/guardian';
// ✓ mode first, then refine
Guardian.object({ a: Guardian.string(), b: Guardian.string() })
.strict()
.refine((d) => d.a !== d.b, 'a and b must differ');
// ✗ refine first, then mode → throws GuardianError at build time
Guardian.object({ a: Guardian.string() })
.refine((d) => d.a !== 'bad', 'no')
.strict(); // "Cannot call strict() after refinements or transforms …"A property's guardian carries the optional/nullable semantics:
import { Guardian } from '@tundralibs/guardian';
const Schema = Guardian.object({
name: Guardian.string(), // required
email: Guardian.string().email().optional(), // T | undefined (omittable from input)
bio: Guardian.string().nullable(), // T | null (must be present but can be null)
});The inferred type respects this:
import { Guardian } from '@tundralibs/guardian';
const Schema = Guardian.object({
name: Guardian.string(),
email: Guardian.string().email().optional(),
bio: Guardian.string().nullable(),
});
type S = Guardian.infer<typeof Schema>;
// { name: string; email?: string; bio: string | null }| Method | Description |
|---|---|
.pick(...keys) |
new schema with only the listed keys |
.omit(...keys) |
new schema without the listed keys |
.partial() |
top-level fields become optional |
.deepPartial() |
every nested ObjectGuardian field recursively becomes optional |
.required(...keys?) |
all fields (or listed keys) become required |
.extend(addSchema) |
add new fields |
.property(key, guardian) |
add a single field |
.merge(other) |
combine with another ObjectGuardian's schema (right wins on key conflicts) |
.exclude(...keys) |
alias for .omit(...keys) — convention from TS's Exclude<keyof T, …>
|
.renameField(from, to) |
rename a single schema key (the renamed schema validates against the new key name) |
.keyOf() |
returns an EnumGuardian over the schema's keys — useful for Pick-style request bodies |
.shape (getter) |
read-only access to the underlying { [key]: guardian } map |
import { Guardian } from '@tundralibs/guardian';
const Base = Guardian.object({
id: Guardian.number(),
name: Guardian.string(),
email: Guardian.string(),
});
const Identity = Base.pick('id', 'name'); // { id, name }
const Editable = Base.omit('id'); // { name, email }
const Patch = Base.partial(); // { id?, name?, email? }
const WithRole = Base.extend({ role: Guardian.string() });Derive before you chain. Every method in this table rebuilds the guardian from its schema, exactly like the mode setters above, and for the same reason cannot carry .refine() / .superRefine() / .transform() / .process() steps across. They therefore throw when a step is already present rather than silently dropping it:
import { Guardian } from '@tundralibs/guardian';
// ✗ refine first, then derive → throws GuardianError at build time
Guardian.object({ password: Guardian.string(), confirm: Guardian.string() })
.refine((d) => d.password === d.confirm, 'passwords must match')
.partial(); // "Cannot call partial() after refinements or transforms …"
// ✓ derive first, then refine
Guardian.object({ password: Guardian.string(), confirm: Guardian.string() })
.partial()
.refine((d) => d.password === d.confirm, 'passwords must match');| Method | Description |
|---|---|
.hasKeys(keys, msg?) |
every listed key must be present with a defined value |
.forbiddenKeys(keys, msg?) |
none of the listed keys may appear |
Note on forbiddenKeys + strip mode: under the default strip mode, unknown keys are dropped before forbiddenKeys runs as a refinement — making the check a no-op. Combine with .passthrough() (or rely on .strict()) to make forbiddenKeys meaningful.
import { Guardian } from '@tundralibs/guardian';
// Works as expected:
Guardian.object({ id: Guardian.number() })
.passthrough()
.forbiddenKeys(['secret']);
// No-op (extras stripped before refinement):
Guardian.object({ id: Guardian.number() })
.forbiddenKeys(['secret']);import { Guardian } from '@tundralibs/guardian';
const Tags = Guardian.array(Guardian.string()).minLength(1).maxLength(10);
Tags.parse(['guardian', 'validation']); // OK| Method | Description |
|---|---|
.minLength(n) / .maxLength(n) / .length(n)
|
size constraints |
.notEmpty(msg?) / .nonEmpty(msg?)
|
sugar for .minLength(1) (notEmpty canonical) |
.unique(msg?) |
reject duplicate elements |
.includes(value, msg?) |
array must contain value
|
.excludes(value, msg?) |
array must NOT contain value
|
.distinctBy(keyFn, msg?) |
reject duplicates under a key-extraction function |
.sorted(comparator?, msg?) |
reject unsorted inputs |
.map(fn) / .filter(fn) / .sort(cmp?) / .reverse()
|
post-validation transforms |
.take(n) / .skip(n) / .tail(n)
|
sub-array selection (first n / drop first n / last n) |
.toSet() |
convert to Set; returns BaseGuardian<Set<T>>
|
import { Guardian } from '@tundralibs/guardian';
const Anything = Guardian.array();
Anything.parse([1, 'mixed', true]); // accepts heterogeneous arraysFixed-length, position-typed array. Each position has its own validator and contributes its own type to the output tuple.
import { Guardian } from '@tundralibs/guardian';
const Range = Guardian.tuple([
Guardian.number().integer().min(0),
Guardian.number().integer().min(0),
]);
const r = Range.parse([10, 20]);
// r: [number, number] ← preserved positional typesMultiple-element tuples preserve every position's type:
import { Guardian } from '@tundralibs/guardian';
const Triple = Guardian.tuple([
Guardian.string(),
Guardian.number(),
Guardian.boolean(),
]);
const [s, n, b] = Triple.parse(['x', 1, true]);
// s: string, n: number, b: booleanLength is exact:
import type { BaseGuardian } from '@tundralibs/guardian';
declare const Range: BaseGuardian<[number, number]>; // from above
Range.parse([10, 20, 30]); // throws — too long
Range.parse([10]); // throws — too shortErrors carry the failing index:
import type { BaseGuardian } from '@tundralibs/guardian';
declare const Triple: BaseGuardian<[string, number, boolean]>; // from above
try {
Triple.parse(['x', 'not-a-number', true]);
} catch (e) {
// e.message: "Tuple element at index 1: …"
}Drop the fixed-length requirement and accept additional elements typed by g:
import { Guardian } from '@tundralibs/guardian';
const MoveCommand = Guardian.tuple([
Guardian.literal('move'),
Guardian.number().integer(),
Guardian.number().integer(),
]).rest(Guardian.string());
MoveCommand.parse(['move', 10, 20]); // OK — no rest
MoveCommand.parse(['move', 10, 20, 'fast', 'q']); // OK — rest values validated as stringsThe fixed prefix still must be present; tail elements may number zero or more.
Attach human-readable names to tuple positions so errors say 'y' (index 1) instead of just index 1:
import { Guardian } from '@tundralibs/guardian';
const Coord = Guardian.tuple([
Guardian.number(),
Guardian.number().positive(),
]).labels(['x', 'y']);
const [err] = Coord.safeParse([0, -1]);
err?.message;
// "Tuple element 'y' (index 1): …"Label count must match the fixed-prefix length; constructor throws otherwise.
.rest(g) and .labels([...]) both rebuild the tuple from its positional guardians, so — like the object mode setters and schema-manipulation methods — they throw when a .refine() / .superRefine() / .transform() / .process() step has already been chained, instead of dropping it. Call them before chaining.
Object with arbitrary keys, all values of one type.
import { Guardian } from '@tundralibs/guardian';
// 1-arg form: Record<string, V>
const Metrics = Guardian.record(Guardian.number());
Metrics.parse({ uptime: 60, errors: 0 });import { Guardian } from '@tundralibs/guardian';
// 2-arg form: pattern-validated keys
const EnvVars = Guardian.record(
Guardian.string().pattern(/^[A-Z_]+$/),
Guardian.string(),
);
EnvVars.parse({ API_KEY: 'abc', DB_HOST: 'localhost' }); // OK — keys match the pattern
const [err] = EnvVars.safeParse({ api_key: 'lowercase' });
err?.listCauses();
// { api_key: 'String does not match pattern /^[A-Z_]+$/' } ← key itself fails the pattern| Method | Description |
|---|---|
.minSize(n) / .maxSize(n) / .size(n)
|
key-count constraints |
.hasKeys(keys, msg?) / .forbiddenKeys(keys, msg?)
|
key existence |
.notEmpty(msg?) / .nonEmpty(msg?)
|
sugar for .minSize(1) (notEmpty canonical) |
Set<T> validator. JSON has no Set literal, so arrays are accepted at the boundary and deduplicated naturally by the resulting Set.
import { Guardian } from '@tundralibs/guardian';
const Tags = Guardian.set(Guardian.string().minLength(1));
Tags.parse(['a', 'b', 'a']); // Set { 'a', 'b' }
Tags.parse(new Set(['a', 'b'])); // Set { 'a', 'b' }
Tags.parse('not-iterable'); // throwsThe element guardian is optional. Without one, inputs flow through untouched into a Set<unknown>:
import { Guardian } from '@tundralibs/guardian';
Guardian.set().parse([1, 'two', true, null]);
// → Set { 1, 'two', true, null }Schema emit: type: 'array' with uniqueItems: true — the closest JSON Schema analogue. Downstream codegen tools typically render this as Set<T> in TypeScript or set[T] in Python.
Map<K, V> validator. Distinct from record, which produces a plain object — Map preserves insertion order and supports non-string keys.
Three input shapes are accepted at the boundary:
import { Guardian } from '@tundralibs/guardian';
const Headers = Guardian.map(Guardian.string(), Guardian.string());
Headers.parse(new Map([['x-trace', 'abc']])); // native Map
Headers.parse([['x-trace', 'abc']]); // array of [K,V] pairs
Headers.parse({ 'x-trace': 'abc' }); // plain object (string keys only)For non-string keys, pass an array of pairs:
import { Guardian } from '@tundralibs/guardian';
const Lookup = Guardian.map(Guardian.number(), Guardian.string());
Lookup.parse([[1, 'one'], [2, 'two']]); // Map<number, string>Schema emit: an array of fixed-length [K, V] tuples — the only faithful JSON Schema for a Map that preserves keys of arbitrary type and entry ordering.
Union of mutually-exclusive shapes. Tries each member in order; returns the first that validates.
import { Guardian } from '@tundralibs/guardian';
const IdOrName = Guardian.oneOf([
Guardian.number().integer().positive(),
Guardian.string().minLength(1),
], 'Expected user id or username');
IdOrName.parse(42); // 42
IdOrName.parse('alice'); // 'alice'
IdOrName.parse({}); // throws — 'Expected user id or username'The error message is mandatory — Guardian forces you to name what the union represents at the call site, so production errors don't bottom out at "expected union".
null / undefined are passed through to the members like any other input, so a nullable / optional member matches them and the mandatory message is what surfaces when none do:
import { Guardian } from '@tundralibs/guardian';
Guardian.oneOf([Guardian.string().nullable()], 'string or null').parse(null); // null
Guardian.oneOf([Guardian.string(), Guardian.number()], 'string or number')
.parse(null);
// → throws 'string or number' (the mandated message, not a generic one)Ordering matters under coerce-by-default. Put more specific types first, otherwise a more-permissive earlier member will absorb the input:
import { Guardian } from '@tundralibs/guardian';
// Wrong: string comes first; '42' coerces nothing, 42 coerces to '42' and matches string.
Guardian.oneOf([Guardian.string(), Guardian.number()], 'msg').parse(42);
// → '42' ← surprising; the string branch ate it
// Right: number first; 42 matches number directly.
Guardian.oneOf([Guardian.number(), Guardian.string()], 'msg').parse(42);
// → 42For tagged-shape unions, prefer discriminatedUnion — it's O(1) and doesn't have this ordering trap.
Tagged union where one field selects the variant. Build a lookup map at construction; parse() reads the discriminator and dispatches to the matching branch.
import { Guardian } from '@tundralibs/guardian';
const Shape = Guardian.discriminatedUnion('kind', [
Guardian.object({
kind: Guardian.literal('circle'),
radius: Guardian.number().positive(),
}),
Guardian.object({
kind: Guardian.literal('square'),
side: Guardian.number().positive(),
}),
Guardian.object({
kind: Guardian.literal('triangle'),
a: Guardian.number(),
b: Guardian.number(),
c: Guardian.number(),
}),
]);
const s = Shape.parse({ kind: 'circle', radius: 5 });
if (s.kind === 'circle') s.radius; // type-narrowedA single branch can match several discriminator values — useful for protocol-version aliases:
import { Guardian } from '@tundralibs/guardian';
const FrameV1 = Guardian.object({
v: Guardian.enum(['v1', 'v1.0'] as const), // either value routes here
data: Guardian.object({}),
});
const FrameV2 = Guardian.object({
v: Guardian.enum(['v2'] as const),
data: Guardian.object({}),
});
const Frame = Guardian.discriminatedUnion('v', [FrameV1, FrameV2]);
Frame.parse({ v: 'v1.0', data: {} }); // matchedTwo branches sharing the same discriminator value is a construction-time error (caught when you call Guardian.discriminatedUnion(...), not at parse time).
import type { BaseGuardian } from '@tundralibs/guardian';
declare const Shape: BaseGuardian<{ kind: string }>; // from above
Shape.parse({ kind: 'octagon', sides: 8 });
// throws: "Unknown kind: 'octagon' (expected one of: circle, square, triangle)"| Property / method | Description |
|---|---|
.discriminator |
the discriminator key name |
.options |
array of branch guardians (declaration order) |
.allowedValues |
array of valid discriminator values |
.variant(value) |
branch guardian for a given discriminator value, or undefined
|
Useful for UI form generation (render a "kind" dropdown from allowedValues, then render the matching branch's fields).
A & B — the input must satisfy both schemas. For object intersections, results are merged via spread with the right side winning on conflicts (matches extend() / merge() semantics).
import { Guardian } from '@tundralibs/guardian';
const Identified = Guardian.object({ id: Guardian.string() });
const Named = Guardian.object({ name: Guardian.string() });
const Person = Guardian.intersection(Identified, Named);
Person.parse({ id: 'u1', name: 'Ada' });
// → { id: 'u1', name: 'Ada' }Schema emit produces allOf: [a, b] — the standard JSON Schema / OpenAPI keyword for intersection. Useful when the two source schemas come from independent domains (e.g. a User schema imported from one package and an Auditable mixin from another) and restructuring either isn't an option. The allOf emit survives chaining, so Guardian.intersection(A, B).describe({ title: 'Person' }) keeps the allOf branches and adds the title (the same holds for .optional() / .clone(), and for the className / not emit of Guardian.instanceof / Guardian.never).
For non-object intersections (rare), b's output replaces a's — use .refine(...) for custom merge rules.
Defer resolution of an inner guardian until parse time. Required for recursive types — the thunk closes over the as-yet-unbound name and reads it on the first parse:
import { BaseGuardian, Guardian } from '@tundralibs/guardian';
type Tree = { value: number; children: Tree[] };
const TreeSchema: BaseGuardian<Tree> = Guardian.object({
value: Guardian.number(),
children: Guardian.array(Guardian.lazy(() => TreeSchema)),
});
TreeSchema.parse({
value: 1,
children: [
{ value: 2, children: [] },
{ value: 3, children: [{ value: 4, children: [] }] },
],
});Once the thunk resolves successfully, the result is cached and never invoked again — including across every subsequent .parse() call.
For a self-referencing target like
TreeSchemaabove, that first successful resolution doesn't necessarily happen on the very first call: whileTreeSchemais still being assigned, an internal async-capability probe may call the thunk before the binding exists, hit a "used before assignment" failure, and silently retry on the next probe — all before your code ever calls.parse(). This is invisible for a plain() => TreeSchemathunk, but if your thunk does more than return a binding (e.g. it increments a counter or has other side effects), don't assume it runs exactly once end-to-end — only the outcome (one resolved guardian, reused forever) is guaranteed.
Schema emit uses cycle detection: a LazyGuardian that emits itself emits { $ref: '#' } (self-reference) on the second visit, breaking the recursion. Downstream codegen tools may want to lift the cycle into a named $ref: '#/$defs/Tree'; pass name via .describe({ title: 'Tree' }) so it surfaces as the schema title.
Apply an input transform before any guardian runs. Useful for boundary normalisation where you want the schema declaration to remain a clean shape:
import { BaseGuardian, Guardian } from '@tundralibs/guardian';
const Trimmed = Guardian.preprocess(
(v) => typeof v === 'string' ? v.trim() : v,
Guardian.string().minLength(1),
);
Trimmed.parse(' hello '); // 'hello'
Trimmed.parse(' '); // throws — trim → '', fails minLengthCommon patterns:
- Coerce
undefinedto a sentinel before validation. - Strip a wrapping envelope (
{ data: ... }) before validating the payload. - Re-shape a legacy wire format into the canonical form the schema expects.
Distinct from .process() (which runs after the guardian) — preprocess runs before, so the schema can express what the input "should look like" after normalisation. Errors raised by the preprocess function are wrapped in a GuardianError with comparison: 'preprocess'.
Schema emit delegates to the inner schema (preprocess is a runtime concern; the emitted shape describes the post-preprocess value).
Validate that the input is an instance of a class. Returns the instance unchanged.
import { Guardian } from '@tundralibs/guardian';
const Url = Guardian.instanceof(URL);
const FormBody = Guardian.instanceof(FormData);
const ErrLike = Guardian.instanceof(Error);
Url.parse(new URL('https://example.com')); // URL { … }
Url.parse('https://example.com'); // throws — not a URL instanceThe most common use is browser globals (URL, File, Blob, FormData, Headers, Request, Response) and Error subclasses where the structural shape isn't reliable but the constructor identity is. Custom domain classes work too — Guardian validates with input instanceof Ctor, so anything that satisfies that check passes.
Schema emit: { type: 'object' } with a className annotation — instanceof isn't expressible in JSON Schema, so downstream codegen tools see an opaque object.
Always throws. The runtime mirror of TypeScript's never type — useful as an exhaustiveness guard in discriminated-union switch statements and as a placeholder in conditional schema construction:
import { Guardian } from '@tundralibs/guardian';
const EventUnion = Guardian.discriminatedUnion('type', [
Guardian.object({ type: Guardian.literal('created') }),
Guardian.object({ type: Guardian.literal('updated') }),
Guardian.object({ type: Guardian.literal('deleted') }),
]);
declare function onCreated(e: unknown): string;
declare function onUpdated(e: unknown): string;
declare function onDeleted(e: unknown): string;
function handle(event: Guardian.infer<typeof EventUnion>): string {
switch (event.type) {
case 'created':
return onCreated(event);
case 'updated':
return onUpdated(event);
case 'deleted':
return onDeleted(event);
default:
return Guardian.never().parse(event); // unreachable — compile-time exhaustiveness check
}
}Calling .parse() always throws a GuardianError with comparison: 'never'.
import { Guardian } from '@tundralibs/guardian';
const Schema = Guardian.object({
id: Guardian.number(),
tags: Guardian.array(Guardian.string()),
});
type T = Guardian.infer<typeof Schema>;
// { id: number; tags: string[] }Guardian.infer<T> and Guardian.inferInput<T> work in TypeScript type position. They're namespace-merged type aliases; there's no runtime overhead.
import { Guardian } from '@tundralibs/guardian';
declare const Schema: ReturnType<typeof Guardian.string>; // from above
// Same effect, different spelling — both work:
type A = Guardian.infer<typeof Schema>;
import { type GuardianInfer } from '@tundralibs/guardian';
type B = GuardianInfer<typeof Schema>;inferInput produces the input type before any .process() / .transform() reshape:
import { Guardian } from '@tundralibs/guardian';
const Schema = Guardian.string().process((s) => s.length);
type Out = Guardian.infer<typeof Schema>; // number
type In = Guardian.inferInput<typeof Schema>; // string.brand<B>() attaches a phantom tag to the output type. The runtime is a no-op — the brand lives entirely in TypeScript's type system — but two structurally-identical brands are assignment-incompatible:
const UserId = Guardian.string().uuid().brand<'UserId'>();
const OrderId = Guardian.string().uuid().brand<'OrderId'>();
type UserId = Guardian.infer<typeof UserId>; // Brand<string, 'UserId'>
type OrderId = Guardian.infer<typeof OrderId>; // Brand<string, 'OrderId'>
function loadUser(id: UserId): Promise<User> {/* … */}
const u = UserId.parse(crypto.randomUUID());
const o = OrderId.parse(crypto.randomUUID());
loadUser(u); // ✅
loadUser(o); // ❌ compile error: OrderId not assignable to UserIdUse brands when a function takes a primitive type (string / number) but only one origin produces valid values for it. Common targets: opaque IDs, currency codes that look like strings but shouldn't be mixed, integer counts that mustn't be confused with offsets, etc.
.brand<B>() is callable on any guardian — primitives and composites. The branded view is a BaseGuardian<Brand<T, B>>, so all the usual methods (.parse, .optional, .toJSONSchema, …) still work; only the output type changes.
The branded type also imports cleanly:
import { type Brand } from '@tundralibs/guardian';
type UserId = Brand<string, 'UserId'>;