Ascertain
Compiled schema-and-constraint validation for JavaScript-native runtime values.
Write schemas as native JavaScript values, compile them once, and validate at AJV-class or better speed with zero dependencies and detailed pathful errors.
Table of Contents
- Features
- Install
- Quick Start
- Performance
- Schema Reference
- Type Casting
- Patterns
- Compile Options
- Standard Schema
- Complete Example
- API
$keys$values$strictor(...schemas: Schema[]): OrShapeand(...schemas: Schema[]): AndShapeoptional(schema: Schema): OptionalShapetuple(...schemas: Schema[]): TupleShapediscriminated(schemas: Schema[], key: string): DiscriminatedShapecheck(fnOrOpts, message?: string): CheckShapemin(n: number, message?: string): CheckShapemax(n: number, message?: string): CheckShapeinteger(message?: string): CheckShapeminLength(n: number, message?: string): CheckShapemaxLength(n: number, message?: string): CheckShapegt(n: number, message?: string): CheckShapelt(n: number, message?: string): CheckShapemultipleOf(n: number, message?: string): CheckShapeuniqueItems(message?: string): CheckShapeoneOf(values: string | number[] | T, message?: string): CheckShapefromBase64(value: string): stringasError(message: string)asformatcompile(schema: Schema, options?: CompileOptions): Validatorascertain(schema: Schema, data: T)createValidator(config: C)standardSchema(schema: Schema): StandardSchemaFn
- License
Features
- JavaScript-native schemas - Use constructors, literals, regexes, arrays, and object shapes directly
- Compiled validators -
compile()emits specialized JS validators instead of interpreting the schema on every call - Fast first-error and all-errors modes - Stay fast on valid data and on bad input
- Zero dependencies - Small footprint for Node.js and browsers
- Type-safe - Full TypeScript support with type inference
- Flexible schemas - AND, OR, optional, tuple, discriminated, and custom check operators
- Object validation - Validate keys/values with
$keys,$values,$strict - Type casting - Built-in parsers for numbers, dates, JSON, base64, bytes, and durations
- Partial validation -
createValidatorvalidates subsets with type narrowing - Standard Schema v1 - Interoperable with tRPC, TanStack Form, and other ecosystem tools
Install
npm install ascertain
Quick Start
import { compile, discriminated, optional, or } from 'ascertain';
const validateUser = compile({
id: Number,
role: or('admin', 'user', 'guest'),
score: optional(Number),
notifications: [
discriminated(
[
{ type: 'email', address: String },
{ type: 'sms', phone: String },
{ type: 'push', token: String },
],
'type',
),
],
});
if (!validateUser(userData)) {
console.error(validateUser.issues);
}
Use ascertain(schema, data) when you want the convenience wrapper that compiles and throws immediately on failure.
Performance
compile() is the center of the library. It turns a schema written as native JS values into a specialized validator function that you can reuse in hot paths.
import { compile } from 'ascertain';
// Compile once
const validateUser = compile(userSchema);
// Validate many (no recompilation)
validateUser(user1);
validateUser(user2);
| When to use | Function | Why |
|---|---|---|
| Repeated validation (API handlers, loops, message boundaries) | compile() | Compile once, reuse the generated validator |
| One-off validation | ascertain() | Convenience wrapper around compile() that throws on failure |
Benchmark
Maintainer-run benchmark suites live in src/__bench__/benchmark.ts and src/__bench__/benchmark-complex.ts. On the current checkout, Ascertain is consistently faster than AJV in this suite and dramatically faster than Zod on invalid all-errors workloads.
Comparable all-errors results from the current run:
| Workload | Ascertain | AJV | Zod |
|---|---|---|---|
| Simple valid | 350.9M ops/s | 88.7M ops/s | 57.9M ops/s |
| Simple invalid | 38.2M ops/s | 28.2M ops/s | 75.7K ops/s |
| Complex valid | 47.8M ops/s | 38.1M ops/s | 6.1M ops/s |
| Complex invalid | 16.9M ops/s | 11.7M ops/s | 44.0K ops/s |
First-error mode is the default and pushes invalid-path throughput higher still. In the same run, Ascertain reached 91.1M ops/s on simple invalid data and 83.6M ops/s on complex invalid data.
Benchmark sources: src/__bench__/benchmark.ts, src/__bench__/benchmark-complex.ts, src/__bench__/self.ts
Run it locally:
pnpm build
pnpm bench
Notes:
- Maintainer-run benchmark, not an independent study
- Measures valid and invalid paths for compiled validators
- Compares the current checkout, published
ascertain, AJV, and Zod - Results vary by CPU, Node.js version, and workload, so treat the numbers as directional
Schema Reference
| Schema | Validates | Example |
|---|---|---|
String, Number, Boolean | Type check | { age: Number } |
Date, Array, Object | Instance check | { created: Date } |
Function | Any callable | { handler: Function } |
| Primitives | Exact value | { status: 'active' } |
| RegExp | Pattern match | { email: /^.+@.+$/ } |
[Schema] | Array of type | { tags: [String] } |
{ key: Schema } | Object shape | { user: { name: String } } |
or(a, b, ...) | Any match | or(String, Number) |
and(a, b, ...) | All match | and(Date, { toJSON: Function }) |
optional(s) | Nullable | optional(String) |
tuple(a, b) | Fixed array | tuple(Number, Number) |
discriminated(schemas, key) | Tagged union | discriminated([{ type: 'a' }, { type: 'b' }], 'type') |
Special Symbols
import { $keys, $values, $strict } from 'ascertain';
const schema = {
[$keys]: /^[a-z]+$/, // Validate all keys
[$values]: Number, // Validate all values
[$strict]: true, // No extra properties
};
Type Casting
Parse strings into typed values (environment variables, query params):
import { as } from 'ascertain';
as.number('42') // 42
as.number('3.14') // 3.14
as.number('0xFF') // 255 (hex)
as.number('0o77') // 63 (octal)
as.number('0b1010') // 10 (binary)
as.number('1e10') // 10000000000
as.boolean('true') // true
as.boolean('1') // true
as.time('500ms') // 500
as.time('30s') // 30000
as.time('5m') // 300000
as.time('2h') // 7200000
as.time('1d') // 86400000
as.date('2024-12-31') // Date object
as.array('a,b,c', ',') // ['a', 'b', 'c']
as.json('{"x":1}') // { x: 1 }
as.base64('dGVzdA==') // 'test'
Invalid values return TypeError for deferred validation:
const config = {
port: as.number(process.env.PORT), // TypeError if invalid
host: as.string(process.env.HOST),
};
// Errors surface with clear paths
ascertain({ port: Number, host: String }, config);
// → TypeError: "Invalid value undefined, expected a string"
Patterns
Batch Validation
Compile once, validate many:
const validateUser = compile(userSchema);
const results = users.map((user, i) => {
if (validateUser(user)) {
return { index: i, valid: true };
}
return { index: i, valid: false, error: validateUser.issues[0].message };
});
Discriminated Unions
Use discriminated() for efficient tagged union validation. Instead of trying each variant like or(), it checks the discriminant field first and only validates the matching variant:
import { compile, discriminated } from 'ascertain';
const messageSchema = discriminated([
{ type: 'email', address: String },
{ type: 'sms', phone: String },
{ type: 'push', token: String },
], 'type');
const validate = compile(messageSchema);
validate({ type: 'email', address: 'user@example.com' }); // true
validate({ type: 'sms', phone: '123456' }); // true
validate({ type: 'push', token: 123 }); // false
validate({ type: 'unknown' }); // false
Discriminant values must be string, number, or boolean literals.
Conditional Rules
Use or() and and() for complex conditions:
const schema = {
type: or('email', 'sms'),
// Conditional: email requires address, sms requires phone
contact: or(
and({ type: 'email' }, { address: String }),
and({ type: 'sms' }, { phone: String }),
),
};
Schema Composition
Build schemas from reusable parts:
const addressSchema = {
street: String,
city: String,
zip: /^\d{5}$/,
};
const personSchema = {
name: String,
address: addressSchema,
};
const companySchema = {
name: String,
headquarters: addressSchema,
employees: [personSchema],
};
Versioned Schemas
Version schemas as modules:
// schemas/user.v1.ts
export const userSchemaV1 = { name: String, email: String };
// schemas/user.v2.ts
export const userSchemaV2 = {
...userSchemaV1,
phone: optional(String),
createdAt: Date,
};
// api/handler.ts
import { userSchemaV2 } from './schemas/user.v2';
const validate = compile(userSchemaV2);
Config Validation
Validate only what each module needs:
import { createValidator, as } from 'ascertain';
const config = {
app: { name: as.string(process.env.APP_NAME), port: as.number(process.env.PORT) },
db: { host: as.string(process.env.DB_HOST), pool: as.number(process.env.DB_POOL) },
cache: { ttl: as.time(process.env.CACHE_TTL) },
};
const validate = createValidator(config);
// Each module validates only what it needs
const { db } = validate({
db: { host: String, pool: Number },
});
db.host; // string - validated and typed
db.pool; // number - validated and typed
// db.xxx // TypeScript error - property doesn't exist
// cache not validated = not accessible
// cache.ttl // TypeScript error - cache not in returned type
Compile Options
By default compile() stops at the first validation error (fastest for invalid data). Pass { allErrors: true } to collect all errors:
import { compile } from 'ascertain';
const schema = { name: String, age: Number, active: Boolean };
// First-error mode (default) - stops at first failure
const validate = compile(schema);
if (!validate({ name: 123, age: 'bad', active: 'no' })) {
console.log(validate.issues.length); // 1
console.log(validate.issues[0].path); // ['name']
}
// All-errors mode - collects every failure
const validateAll = compile(schema, { allErrors: true });
if (!validateAll({ name: 123, age: 'bad', active: 'no' })) {
console.log(validateAll.issues.length); // 3
}
Standard Schema
Wrap a schema for Standard Schema v1 compliance, enabling interoperability with tRPC, TanStack Form, and other ecosystem libraries:
import { standardSchema, or, optional } from 'ascertain';
const userValidator = standardSchema({
name: String,
age: Number,
role: or('admin', 'user'),
email: optional(String),
});
// Use as regular validator (throws on error)
userValidator({ name: 'Alice', age: 30, role: 'admin' });
// Use Standard Schema interface (returns result object)
const result = userValidator['~standard'].validate(unknownData);
if (result.issues) {
console.log(result.issues);
} else {
console.log(result.value);
}
Complete Example
import { compile, or, optional, and, tuple, $keys, $values, $strict, as } from 'ascertain';
const schema = {
id: Number,
name: String,
email: /^[^@]+@[^@]+$/,
status: or('active', 'inactive', 'pending'),
role: optional(or('admin', 'user')),
profile: {
bio: optional(String),
avatar: optional(String),
},
settings: {
[$keys]: /^[a-z_]+$/,
[$values]: or(String, Number, Boolean),
[$strict]: true,
},
coordinates: optional(tuple(Number, Number)),
createdAt: and(Date, { toISOString: Function }),
retries: as.number(process.env.MAX_RETRIES),
timeout: as.time(process.env.TIMEOUT),
};
const validate = compile(schema);
if (!validate(data)) {
console.error(validate.issues);
}
API
$keys
Symbol for validating object keys against a schema.
$values
Symbol for validating object values against a schema.
$strict
Symbol for enforcing strict object validation (no extra properties allowed).
or(...schemas: Schema[]): OrShape
Operator for validating data against any of the provided schemas (logical OR).
and(...schemas: Schema[]): AndShape
Operator for validating data against all provided schemas (logical AND).
optional(schema: Schema): OptionalShape
Operator for making a schema optional (nullable).
tuple(...schemas: Schema[]): TupleShape
Operator for validating data against a fixed-length tuple of schemas.
discriminated(schemas: Schema[], key: string): DiscriminatedShape
Operator for validating data against a discriminated union.
Optimizes validation by checking the discriminant field first and only validating the matching variant. More efficient than or() for unions where each variant has a common field with a unique literal value.
@param schemas - Array of object schemas, each with a discriminant field containing a literal value.
@param key - The name of the discriminant field present in all variants.
@example
const messageSchema = discriminated([
{ type: 'email', address: String },
{ type: 'sms', phone: String },
{ type: 'push', token: String },
], 'type');
check(fnOrOpts, message?: string): CheckShape
Creates a custom validation check. Accepts a predicate function or an object with a compile method for inlined checks.
- @param fnOrOpts - A predicate function
(value) => booleanor an object with acompilemethod for code-generating checks. - @param message - Optional custom error message.
min(n: number, message?: string): CheckShape
Validates that a numeric value is greater than or equal to n.
- @param n - The minimum allowed value (inclusive).
- @param message - Optional custom error message.
max(n: number, message?: string): CheckShape
Validates that a numeric value is less than or equal to n.
- @param n - The maximum allowed value (inclusive).
- @param message - Optional custom error message.
integer(message?: string): CheckShape
Validates that a value is an integer.
- @param message - Optional custom error message.
minLength(n: number, message?: string): CheckShape
Validates that a value's length is greater than or equal to n.
- @param n - The minimum allowed length (inclusive).
- @param message - Optional custom error message.
maxLength(n: number, message?: string): CheckShape
Validates that a value's length is less than or equal to n.
- @param n - The maximum allowed length (inclusive).
- @param message - Optional custom error message.
gt(n: number, message?: string): CheckShape
Validates that a numeric value is strictly greater than n.
- @param n - The exclusive lower bound.
- @param message - Optional custom error message.
lt(n: number, message?: string): CheckShape
Validates that a numeric value is strictly less than n.
- @param n - The exclusive upper bound.
- @param message - Optional custom error message.
multipleOf(n: number, message?: string): CheckShape
Validates that a numeric value is a multiple of n.
- @param n - The divisor to check against.
- @param message - Optional custom error message.
uniqueItems(message?: string): CheckShape
Validates that an array contains only unique items.
- @param message - Optional custom error message.
oneOf(values: string | number[] | T, message?: string): CheckShape
Validates that a value is one of the allowed values. Accepts an array or an enum-like object.
- @param values - Array of allowed values or an enum-like object.
- @param message - Optional custom error message.
fromBase64(value: string): string
asError(message: string)
Creates a TypeError with the given message, typed as T for deferred error handling.
Used by as.* conversion utilities to return errors that can be caught during schema validation rather than throwing immediately.
- @template T - The expected return type (for type compatibility with conversion functions).
- @param message - The error message.
- @returns A TypeError instance typed as T.
as
Type casting utilities for parsing strings into typed values. Useful for environment variables, query parameters, and other string inputs. Returns a TypeError for invalid values, enabling deferred validation with ascertain().
format
String format validators for common patterns (RFC 3339 date-time, email, URI, UUID, etc.). Each method returns a CheckShape that can be composed with and() for schema validation.
compile(schema: Schema, options?: CompileOptions): Validator
Compiles a schema into a high-performance validation function.
By default uses first-error mode which stops at the first validation failure and returns immediately. This provides optimal performance for invalid data.
Set allErrors: true to collect all validation errors (slower but more informative).
@template T - The type of data the schema validates.
@param schema - The schema to compile.
@param options - Optional configuration (allErrors: boolean).
@returns A validator function that returns boolean. Access
.issuesfor error details.@example
import { compile, optional, or } from 'ascertain';
const userSchema = {
name: String,
age: Number,
email: optional(String),
role: or('admin', 'user', 'guest')
};
// First-error mode (default) - fastest for invalid data
const validate = compile(userSchema);
// All-errors mode - collects all validation issues
const validateAll = compile(userSchema, { allErrors: true });
// Valid data
if (validate({ name: 'John', age: 30, role: 'user' })) {
console.log('Valid!');
}
// Invalid data - check .issues for details
if (!validate({ name: 123, age: 'thirty' })) {
console.log(validate.issues); // Array with first validation issue
}
ascertain(schema: Schema, data: T)
Asserts that data conforms to a given schema.
This function is a convenient wrapper around compile. It compiles the schema and immediately validates the provided data against it.
@template T - The type of data the schema validates.
@param schema - The schema to validate against.
@param data - The data to validate.
@throws
{TypeError}If the data does not conform to the schema.@example
import { ascertain, optional, and, or } from 'ascertain';
const userSchema = {
name: String,
age: Number,
email: optional(String),
active: Boolean
};
const userData = {
name: 'Alice',
age: 25,
email: 'alice-@example.com',
active: true
};
// Validate data - throws if invalid, otherwise continues silently
ascertain(userSchema, userData);
console.log('User data is valid!');
// Example with invalid data
try {
ascertain(userSchema, {
name: 'Bob',
age: 'twenty-five', // Invalid: should be number
active: true
});
} catch (error) {
console.error('Validation failed:', error.message);
}
- @example
// Array validation
const numbersSchema = [Number];
const numbers = [1, 2, 3, 4, 5];
ascertain(numbersSchema, numbers);
// Tuple validation
const coordinateSchema = tuple(Number, Number);
const point = [10, 20];
ascertain(coordinateSchema, point);
createValidator(config: C)
Creates a typed validator function for a config object.
Returns a function that validates a schema against the config and returns the same config reference with a narrowed type containing only the validated fields.
@template C - The type of the config object.
@param config - The config object to validate against.
@returns A validator function that takes a schema and returns the typed config subset.
@example
import { createValidator, as } from 'ascertain';
const config = {
app: { name: as.string(process.env.APP_NAME) },
kafka: { brokers: as.array(process.env.BROKERS, ',') },
redis: { host: as.string(process.env.REDIS_HOST) },
};
const validate = createValidator(config);
// Consumer only validates what it needs
const { app, kafka } = validate({
app: { name: String },
kafka: { brokers: [String] },
});
// app.name is typed as string
// kafka.brokers is typed as string[]
// redis is not accessible - TypeScript error
standardSchema(schema: Schema): StandardSchemaFn
Wraps a schema for Standard Schema v1 compliance. The returned function throws on invalid data and exposes a ~standard interface for interoperability with tRPC, TanStack Form, and other ecosystem tools.
- @param schema - The schema to wrap.
- @returns A validator function with a
~standardproperty conforming to Standard Schema v1.
License
MIT - Ivan Zakharchanka