tigerspec
v2026.7.7
Published
**A runtime assertion library.**
Downloads
134
Readme
spec.js
A runtime assertion library.
Doesn't have coercion, serialization, compilation, masking, or timeouts.
Why
Executable Schema Reference
- Document data model shapes in lieu of static types.
- Catch and pinpoint breaking changes with actionable errors.
- Prevent propagation of undefined or unexpected values early on.
System Invariant Checks (Correctness over uptime)
- Fail-fast on invalid program states with runtime assertions.
References
Heavily inspired by TigerStyle and Clojure Spec
Walkthrough
This guide explains the design, API surface, and usage of the spec.js validation library.
1. Quick start
Define a schema to validate a configuration payload:
import { spec, t } from './spec.js';
const configSchema = spec({
server: {
host: t.string,
port: t.number.min(1).max(65535),
},
database: t.optional({
urls: [t.string], // Repeating array of strings
retries: t.number.min(0), // Retries must be >= 0
}),
});Validate data using either enforcement (assert) or inspection (diff).
Enforcement (assert)
.assert(data) returns data on success, or throws a ValidationError (which inherits from TypeError) on failure:
const invalidConfig = {
server: { host: 'localhost', port: 8080 },
database: {
urls: ['postgres://db1', 'postgres://db2'],
retries: -5, // Invalid: must be >= 0
},
};
try {
configSchema.assert(invalidConfig);
} catch (error) {
console.dir(error, { depth: null });
}Specify a custom error message as a second argument:
configSchema.assert(invalidConfig, 'Invalid configuration');Inspection (diff)
.diff(data) returns undefined on success, or a mismatch object detailing the failure on failure:
const mismatch = configSchema.diff(invalidConfig);
if (mismatch) {
console.log(mismatch.kind); // "min"
console.log(mismatch.displayPath); // ".database.retries"
console.log(mismatch.path); // [".database", ".retries"]
}2. Core concept: Everything is a spec
Validators are Spec instances. The spec() parser is idempotent, which allows composing schemas from reusable blocks:
const portSpec = t.number.min(1).max(65535);
const hostSpec = t.string.min(1);
const serverSchema = spec({
host: hostSpec,
port: portSpec,
});spec.from(callback) provides access to the t object within the callback, avoiding explicit imports of t:
import { spec } from './spec.js';
const userSchema = spec.from((t) => ({
name: t.string,
age: t.number,
}));3. Primitive types and literal constraints
The t namespace provides validators for JavaScript types and literal values.
Primitive types
Validators on t match standard JavaScript types:
t.string,t.number,t.bigint,t.boolean,t.symbol,t.nullt.array: Matches any array.t.function: Matches executable functions.t.object: Matches plain objects (rejectsnulland arrays).
t.string.assert("hello");
t.number.assert(42);
t.object.assert({ user: "Alice" });
t.object.diff(null); // Fails
t.object.diff([1, 2, 3]); // FailsLiteral constraints
Passing a primitive value to spec() creates a literal constraint checked with strict equality (===). Passing undefined directly throws a TypeError; use t.undefined to match undefined:
const exactStr = spec("hello");
exactStr.assert("hello"); // Succeeds
const exactNum = spec(7);
exactNum.assert(7); // Succeeds
exactNum.diff(8); // Fails (8 !== 7)4. Custom predicates and range bounds
Attach custom logic constraints to any Spec instance.
Custom logic with .and()
Chain constraints using .and(schema). A function argument acts as a predicate that must return true to pass validation:
const idSpec = t.string.and((s) => s.startsWith("id-"));
idSpec.assert("id-1092"); // Succeeds
idSpec.diff("user-1092"); // FailsPassing a function directly to spec() is a shorthand for a predicate constraint:
const evenNumber = spec((n) => n % 2 === 0);
evenNumber.assert(4); // SucceedsRange checks using .min() and .max()
.min(limit) and .max(limit) apply numeric or length bounds:
- Numbers and bigints: Compares the numeric value directly.
- Strings: Compares the string length.
- Arrays: Compares the array length.
- Objects: Compares the number of keys.
- Other types (undefined, null, function, symbol, boolean): Evaluates against
NaN. Since comparison againstNaNis always false, range constraints on these types evaluate to false (fail validation).
const scoreSpec = t.number.min(0).max(100);
scoreSpec.assert(50);
const usernameSpec = t.string.min(3).max(10);
usernameSpec.assert("alex");
const listSpec = t.array.min(1);
listSpec.assert(["item"]);
const objectWithKeys = t.object.min(2).max(4);
objectWithKeys.assert({ a: 1, b: 2 });5. Complex collections
Objects and key validation
Object schemas validate defined properties. Validation is open by default: extra properties are preserved:
const userSchema = spec({
name: t.string,
});
const payload = {
name: "Alice",
role: "Admin", // Preserved
};
const user = userSchema.assert(payload);
console.log(user.role); // "Admin"To validate relationships between properties, apply a predicate to the parent object schema:
const rangeSchema = spec({
minVal: t.number,
maxVal: t.number,
}).and((data) => data.maxVal >= data.minVal);
rangeSchema.assert({ minVal: 10, maxVal: 20 }); // Succeeds
rangeSchema.diff({ minVal: 20, maxVal: 10 }); // FailsArray validation styles
Array schemas support three validation styles:
- Empty Array
[]: Matches any array. - Repeating Array
[schema]: Validates that every element in the array matches the schema. - Tuple
[schema1, schema2, ...]: Validates positional elements. Tuples are strictly closed: the input array must match the exact length of the schema.
// 1. Empty Array: matches any array
const anyArray = spec([]);
anyArray.assert([1, "two", {}]);
// 2. Repeating Array: matches all items
const numbersArray = spec([t.number]);
numbersArray.assert([1, 2, 3]);
numbersArray.diff([1, "two"]); // Fails
// 3. Tuple: strictly closed positional array
const geoCoords = spec([t.number, t.number]);
geoCoords.assert([37.7749, -122.4194]);
geoCoords.diff([37.7749, -122.4194, "altitude"]); // Fails (length must be 2)6. Handling absence and undefined values
The following helpers validate optional or missing data:
t.any(): Matches any value exceptundefined(equivalent to defined).t.optional(schema): Matchesundefinedor the nested schema.t.nullable(schema): Matchesnullor the nested schema.
These helpers can be nested:
const maybeMiddleName = t.optional(t.nullable(t.string));
maybeMiddleName.assert("Marie"); // Succeeds
maybeMiddleName.assert(null); // Succeeds
maybeMiddleName.assert(undefined); // SucceedsHandling undefined values
To protect against typos, spec() throws a TypeError if it parses a direct undefined value (such as { host: t.sting }):
// Throws: "Unsupported [undefined] while parsing schema."
spec({ host: t.sting }); Use t.undefined to explicitly validate undefined values or missing properties:
const schema = spec({
middleName: t.undefined,
});
schema.assert({}); // Succeeds
schema.assert({ middleName: undefined }); // Succeeds7. Union types
t.any(...schemas) validates that a value matches at least one of the schemas. Calling t.any() without arguments matches any value except undefined:
const stringOrNumberOrNull = t.any(t.string, t.number, t.null);
stringOrNumberOrNull.assert("hello"); // Succeeds
stringOrNumberOrNull.assert(42); // Succeeds
stringOrNumberOrNull.assert(null); // Succeeds
stringOrNumberOrNull.diff(undefined); // Fails8. Error tagging and localization
The .tag(label) method associates custom metadata or labels with constraints.
Single tag fallback
Place a tag at the end of a chain to apply it to any failure within the chain:
const ageSchema = t.number
.and((n) => n >= 18)
.tag("Invalid registration age");
// Any failure triggers the tag:
console.log(ageSchema.diff("not-a-number").tag); // "Invalid registration age"
console.log(ageSchema.diff(16).tag); // "Invalid registration age"Granular tagging
Assign tags to specific constraints in a chain to return granular errors:
const ageSchema = t.number
.tag("Age must be a number")
.and((n) => n >= 18)
.tag("You must be 18 or older to register");
// Fails the type check:
const typeErr = ageSchema.diff("underage");
console.log(typeErr.tag); // "Age must be a number"
// Fails the range check:
const rangeErr = ageSchema.diff(16);
console.log(rangeErr.tag); // "You must be 18 or older to register"Tag propagation
A constraint uses the closest downstream tag in the chain:
const ageSchema = t.number
.and(n => n >= 18)
.tag('Must be a number, 18+')
.and(n => n < 80); // Untagged constraint
// Fails t.number: uses closest downstream tag
console.log(ageSchema.diff('not-a-number').tag); // 'Must be a number, 18+'
// Fails n >= 18: uses closest downstream tag
console.log(ageSchema.diff(16).tag); // 'Must be a number, 18+'
// Fails n < 80: no tag downstream of this constraint
console.log(ageSchema.diff(90).tag); // undefinedLocalization using symbols
Use Symbol values to resolve translation keys dynamically:
const ERR_NOT_A_NUMBER = Symbol("ERR_NOT_A_NUMBER");
const ERR_UNDERAGE = Symbol("ERR_UNDERAGE");
const ageSchema = t.number
.tag(ERR_NOT_A_NUMBER)
.and((n) => n >= 18)
.tag(ERR_UNDERAGE);
const mismatch = ageSchema.diff(15);
const translations = {
[ERR_NOT_A_NUMBER]: "Please enter a valid numeric value.",
[ERR_UNDERAGE]: "You must be 18 or older to view this content.",
};
console.log(translations[mismatch.tag]); // "You must be 18 or older to view this content."9. Validation errors and mismatch shapes
Validation errors
Validation failures in .assert() throw a ValidationError (which extends TypeError). It exposes these properties:
message: The custom message string passed to.assert(), the label string/symbol from a failing spec's.tag(), or"Assertion failed".cause: The underlying mismatch object returned by.diff().input: The original input value that failed validation.
Mismatch objects
.diff() returns a mismatch object on failure.
Common fields
Every mismatch object contains:
kind: A string matching the category of constraint failure ('literal','type','predicate','min','max','any').data: The input value that failed validation.tag: (Optional) The resolved tag label for the failing constraint.path: (Optional) Array of path segments leading to the failure.displayPath: (Optional) Formatted path string (for example,".server.port"or"[0].name").
Constraint-specific fields
Other fields depend on the constraint kind:
| Failure kind | Description | expected Value | actual Value |
| :--- | :--- | :--- | :--- |
| 'literal' | Value did not match literal. | The expected literal value. | The actual input value. |
| 'type' | Value was the incorrect type. | The expected type name string. | The actual type name string. |
| 'predicate' | Custom predicate returned false. | String representation of the predicate function. | false |
| 'min' | Value or length was below the minimum. | String representation of the limit number. | false |
| 'max' | Value or length exceeded the maximum. | String representation of the limit number. | false |
| 'any' | Value failed to match all schemas in union. | Array of mismatch objects from each sub-schema. | false |
10. Advanced validation examples
Recursive validation
Define recursive object schemas by wrapping validation in a lazy predicate:
let treeSpec;
const nodeSchema = {
value: t.string,
// Validate each child recursively using treeSpec.diff.
children: [spec((node) => !treeSpec.diff(node))],
};
treeSpec = spec(nodeSchema);
const validTree = {
value: "root",
children: [
{ value: "branch-a", children: [] },
{ value: "branch-b", children: [{ value: "leaf", children: [] }] }
]
};
treeSpec.assert(validTree); // SucceedsClass instance validation
Validate instance types using instanceof inside a custom predicate:
const dateSpec = spec((val) => val instanceof Date);
dateSpec.assert(new Date()); // Succeeds
dateSpec.diff("2026-07-06"); // Fails