Typer is a comprehensive TypeScript validation library that provides robust type checking, schema validation, and runtime type safety. Built with modern TypeScript features including generics, type guards, and advanced type inference.
Infer<typeof schema> derives the static type from the runtime schema, and a typo like 'nubmer' is a compile error, not a runtime surprise (4.0+)literal, arrayOf, record, tuple, refine, transform, withDefault, lazy, objectOf โ all nest freely (4.0+)code, path, expected and received, so you branch on data instead of parsing strings (4.0+)typer.standard(schema) is accepted by tRPC, Hono, TanStack Form and Router, Nuxt and the rest โ no adapter (4.1+)extend() and keep full type inferencetoJSONSchema() for OpenAPI and Swagger tooling (4.1+)npm install @illavv/run_typer
import { Typer } from '@illavv/run_typer';
const typer = new Typer();
// 3.1+: pass a literal alias and the return type is inferred โ no <generic> needed
const message = typer.isType('string', 'Hello World'); // message: string
const count = typer.isType('number', 42); // count: number
// Type guards narrow automatically from the literal alias
if (typer.is(userInput, 'string')) {
// userInput is now typed as string
console.log(userInput.toUpperCase());
}
// Non-throwing variant
const result = typer.safeParse('number', userInput);
if (result.success) {
// result.data: number
}
import { Typer, type Infer } from '@illavv/run_typer';
const typer = new Typer();
// One literal drives both runtime validation and the static type
const user = typer.parse(
{ id: 'number', name: 'string', email: 'string?' },
payload,
);
// user is typed: { id: number; name: string; email?: string | null }
// Reusable schemas with derived types โ no `as const` needed
const userSchema = typer.schema({
id: 'number',
name: 'string',
email: 'string?',
tags: ['string'],
address: { city: 'string', zip: 'string?' },
});
type User = Infer<typeof userSchema>;
const u = typer.parse(userSchema, payload); // throws + typed as User
const r = typer.safeParse(userSchema, payload); // { success, data: User } | { error }
Mix-and-match validator functions inside a schema โ any slot accepts a
Validator<T>, whether from a built-in helper, a combinator, or your own:
const strict = typer.schema({
id: typer.validators.isPositiveInteger,
name: typer.validators.isNonEmptyString,
color: (v) => typer.isHexColor(v),
role: typer.literal('admin', 'user', 'guest'),
});
Use
typer.validators.isX, nottyper.isX, when passing a validator as a value. A bare method reference loses itsthisand fails even on valid input.typer.validatorsholds the same validators, pre-bound; wrapping in an arrow โ(v) => typer.isX(v)โ works just as well.
Note: a type string like
'admin|user|guest'means "one of these type aliases", not "one of these values". Usetyper.literal(...)for specific values.
A misspelled alias used to infer unknown and blow up at runtime. Now it does
not compile:
typer.parse({ id: 'nubmer' }, payload);
// ~~~~~~~~
// Type '"nubmer"' is not assignable to type
// '`Typer: unknown type alias in "nubmer" โ register it with extend() or fix the spelling`'
This covers unions, optionals, array elements and nested schemas:
typer.schema({ a: 'number|strng' }); // โ
typer.schema({ b: 'nubmer?' }); // โ
typer.schema({ c: ['strng'] }); // โ
typer.schema({ d: { e: 'strng' } }); // โ
Dynamically built schemas carry no literal information to check, so they are still accepted:
const dynamic: Record<string, string> = buildSchema();
typer.checkStructure(dynamic, payload); // fine
Every failure carries machine-readable issues, so you branch on data rather than parsing messages:
const result = typer.safeParse(
{ id: 'number', address: { city: 'string' } },
{ id: 'nope', address: { city: 1 } },
);
if (!result.success) {
result.issues;
// [
// { code: 'invalid_type', path: 'id', expected: 'number', received: 'string', message: 'โฆ' },
// { code: 'invalid_type', path: 'address.city', expected: 'string', received: 'number', message: 'โฆ' },
// ]
}
Paths use dotted and indexed notation (address.city, tags[2]). code is
one of:
| Code | Meaning | Extra fields |
|---|---|---|
invalid_type |
present, but not one of the expected types | expected, received |
missing_key |
a required key was absent | expected |
unknown_type |
the schema named an alias that is not registered | expected |
invalid_schema |
the schema itself is malformed | expected, received |
unexpected_key |
strict mode: a key the schema does not declare | |
too_small |
below a lower bound โ too short, too few, too small (4.1+) | minimum |
too_big |
above an upper bound โ too long, too many, too large (4.1+) | maximum |
invalid_format |
right type, wrong shape: not an email, not a UUID, not an integer (4.1+) | expected |
dangerous_key |
an unsafe key could not be stripped (4.1+) | |
custom |
a validator in the schema threw without reporting a reason |
Constraint validators carry their code all the way out, whether called directly or from inside a schema โ which is what makes "too short" tellable from "out of range" without reading the message:
const result = typer.safeParse({
name: (v) => typer.isLength({ min: 3, max: 50 }, v),
pin: (v) => typer.isInRange(1000, 9999, v),
}, { name: 'ab', pin: 42 });
result.issues;
// [
// { code: 'too_small', path: 'name', minimum: 3, message: 'โฆ' },
// { code: 'too_small', path: 'pin', minimum: 1000, maximum: 9999, message: 'โฆ' },
// ]
parse throws a TyperError, which extends TypeError โ existing
instanceof TypeError handling keeps working:
import { TyperError } from '@illavv/run_typer';
try {
typer.parse(userSchema, payload);
} catch (e) {
if (e instanceof TyperError) {
e.issues; // the structured list
e.flatten(); // { 'address.city': ['Expected "address.city" to be string, got number'] }
}
}
result.erroris built on first access. Reading onlyresult.issuesskips constructing anErrorโ whose stack capture costs more than the validation itself.
toJSONSchema() turns a schema into the document OpenAPI and Swagger tooling
expects:
typer.toJSONSchema({ id: 'number', email: typer.validators.isEmail, note: 'string?' });
// {
// $schema: 'https://json-schema.org/draft/2020-12/schema',
// type: 'object',
// properties: {
// id: { type: 'number' },
// email: { type: 'string', format: 'email' },
// note: { type: ['string', 'null'] },
// },
// required: ['id', 'email'],
// }
Type strings, ? markers, | unions, arrays and nested objects have exact
equivalents. Validators do not โ a validator is an opaque function โ so Typer's
own validators and combinators carry the fragment they correspond to:
| Slot | Emitted |
|---|---|
typer.validators.isEmail |
{ type: 'string', format: 'email' } |
typer.literal('a', 'b') |
{ enum: ['a', 'b'] } |
typer.arrayOf(v, { min: 1 }) |
{ type: 'array', items: โฆ, minItems: 1 } |
typer.tuple([a, b]) |
{ type: 'array', prefixItems: [โฆ], minItems: 2, maxItems: 2 } |
typer.record(v) |
{ type: 'object', additionalProperties: โฆ } |
typer.optional(v) |
the inner fragment, key dropped from required |
typer.withDefault(v, 10) |
the inner fragment plus default: 10 |
typer.discriminatedUnion(k, โฆ) |
{ oneOf: [โฆ], discriminator: { propertyName: k } } |
A validator you wrote becomes {} โ which accepts anything โ as do the
aliases JSON cannot carry (symbol, function, map, set, regexp, the
buffer types) and anything registered with extend. Pass
{ unrepresentable: 'throw' } in a build step to be told about those instead of
shipping a schema that quietly accepts anything at those keys:
typer.toJSONSchema(schema, { unrepresentable: 'throw' });
// TyperError: Cannot convert to JSON Schema: 1 slot(s) have no equivalent โ session
{ strict: true } emits additionalProperties: false, and { $schema: false }
omits the dialect keyword for embedding in a larger document.
Query strings, form data and environment variables arrive as strings. Without coercion, every handler rewrites the conversion by hand โ which is where the mistakes are:
const query = typer.parse({
page: typer.coerce.number,
archived: typer.coerce.boolean,
since: typer.coerce.date,
}, req.query);
// { page: 2, archived: false, since: Date }
The converted value replaces the original in place, so what comes out of
parse holds numbers and dates rather than the strings that arrived.
These reject what they cannot convert instead of inventing a value, which is the
whole difference from the Number() / Boolean() they replace:
| Input | Number() / Boolean() |
typer.coerce.* |
|---|---|---|
'' |
0 |
rejected |
' ' |
0 |
rejected |
null |
0 |
rejected |
[] |
0 |
rejected |
'abc' |
NaN |
rejected |
'false' |
true |
false |
'0' |
true |
false |
coerce.boolean reads true/1/yes/on and false/0/no/off, in any
case and with surrounding whitespace; anything else is rejected rather than
guessed at.
Real applications derive shapes from each other constantly. Writing the second copy by hand means the two diverge at the first change:
const userSchema = typer.schema({
id: 'number',
name: 'string',
email: 'string',
password: 'string',
});
const publicUser = typer.pick(userSchema, ['id', 'name']);
const createUser = typer.omit(userSchema, ['id']);
const patchUser = typer.partial(typer.omit(userSchema, ['id', 'password']));
const timestamped = typer.merge(userSchema, { createdAt: 'date' });
type PatchUser = Infer<typeof patchUser>;
// โ { name?: string | null; email?: string | null }
All four return a plain new schema and leave the sources untouched, so the
result parses, infers and composes like any other. merge lets the second
schema win on overlapping keys.
partialgives a type-string slot the?marker it already understands. Slots with no marker of their own โ a validator, an array, a nested schema โ are rebuilt asoptional(...)validators instead, so a made-optional nested slot reports its failures as onecustomissue at the slot's path rather than one per field. Pass the keys you need if that matters:typer.partial(schema, ['name', 'email']).
Typer validates in place and returns the same reference it was given. That is
what makes it fast, and it used to mean that keys the schema never declared โ
including __proto__ and constructor from a JSON.parse payload โ survived
a success: true:
const payload = JSON.parse('{"id":1,"__proto__":{"admin":true}}');
typer.parse({ id: 'number' }, payload);
Object.keys(payload); // ['id'] โ was ['id', '__proto__'] before 4.1
{ ...payload }.admin; // undefined โ the spread that used to matter
__proto__, constructor and prototype are now removed from every validated
object unless the schema declares them as fields of its own, in which case they
are ordinary keys and are validated like any other. Nothing else about extra
keys changes: use strict mode if you want all undeclared keys rejected.
If the object is frozen and the key cannot be removed, validation fails with a
dangerous_key issue rather than passing an object it could not make safe.
The check costs ~2.5 ns per object: a cheap probe that only falls through to the
exact hasOwnProperty test when an object is not a plain, unpolluted one.
Standard Schema is the common contract that lets a
validation library be accepted by tRPC, Hono, TanStack Form and Router, Nuxt and
dozens of others without a per-library adapter. standard() produces it from a
schema, a validator, or a type alias:
import typer from '@illavv/run_typer';
const userSchema = typer.standard({ id: 'number', email: 'string' });
// Any Standard Schema consumer accepts it as-is:
app.post('/users', validator('json', userSchema), handler); // Hono
publicProcedure.input(userSchema).mutation(({ input }) => โฆ); // tRPC
The result is still an ordinary Validator, so it keeps working everywhere a
validator does โ including nested inside another schema:
userSchema({ id: 1, email: 'a@b.co' }); // returns the value, throws on failure
typer.arrayOf(userSchema); // composes like any other validator
objectOf() carries the contract as well, so an existing typer.objectOf(...)
is already a Standard Schema.
Issue paths follow the spec's segment form, while keeping Typer's machine-readable
code:
userSchema['~standard'].validate({ id: 'one', email: 'a@b.co' });
// {
// issues: [{
// code: 'invalid_type',
// path: ['id'], // segments, not 'id'
// message: 'Expected "id" to be number, got string',
// expected: 'number',
// received: 'string',
// }]
// }
Validation is synchronous: validate never returns a Promise.
All of these return a Validator<T>, so they compose with each other and slot
into any schema position:
const orderSchema = typer.schema({
id: typer.validators.isUUID,
status: typer.literal('pending', 'shipped', 'cancelled'),
qty: typer.refine((v) => typer.asNumber(v), (n) => n > 0, 'qty must be > 0'),
coupon: typer.withDefault((v) => typer.asString(v), ''),
sizes: typer.arrayOf((v) => typer.asNumber(v), { min: 1, max: 10 }),
labels: typer.record((v) => typer.asString(v)), // Record<string, string>
position: typer.tuple([(v) => typer.asNumber(v), (v) => typer.asNumber(v)]), // [number, number]
placedAt: typer.instanceOf(Date),
slug: typer.transform((v) => typer.asString(v), (s) => s.trim().toLowerCase()),
lines: typer.arrayOf(typer.objectOf({ sku: 'string', qty: 'number' })),
});
discriminatedUnion covers the payload shape most APIs actually use โ a union
told apart by one key (4.1+):
const event = typer.discriminatedUnion('type', {
created: { id: 'string', at: 'date' },
renamed: { id: 'string', name: 'string' },
deleted: { id: 'string' },
});
type Event = ReturnType<typeof event>;
// โ { type: 'created'; id: string; at: Date }
// | { type: 'renamed'; id: string; name: string }
// | { type: 'deleted'; id: string }
Unlike union, which tries each variant in turn, this reads the discriminant
once and goes straight to the only variant that can match โ in constant time,
however many there are โ and reports against that variant alone:
typer.safeParse(event, { type: 'renamed', id: 'x', name: 42 }).issues;
// [{ code: 'invalid_type', path: 'name', expected: 'string', received: 'number' }]
// not "none of the 3 variants matched, here is why each one failed"
Variants are keyed by discriminant value, so the mapping is exact by
construction. Each member carries its own tag as a literal type, so it narrows
on type the way a hand-written union does.
lazy makes recursive shapes expressible:
type Node = { name: string; children?: Node[] };
const node: Validator<Node> = typer.lazy(() => typer.objectOf({
name: 'string',
children: typer.optional(typer.arrayOf(node)),
}) as Validator<Node>);
extend() is registerType that also records the alias in the instance's
type, so it works in compile-checked schemas and resolves in Infer:
const typer = new Typer()
.extend('positive', (v): number => {
if (typeof v !== 'number' || v <= 0) throw new TypeError('Must be positive');
return v;
})
.extend('slug', (v): string => new Typer().isSlug(v));
const schema = typer.schema({ qty: 'positive', handle: 'slug' });
type Product = Infer<typeof schema, { positive: number; slug: string }>;
// โ { qty: number; handle: string }
It returns the same instance, just re-typed, so it chains. registerType is
still available when you do not need the alias in the type system.
import { Typer } from '@illavv/run_typer';
const typer = new Typer();
// Type guards (return boolean)
console.log(typer.is<string>("Hello", "string")); // true
console.log(typer.is<number>(123, "number")); // true
console.log(typer.is<boolean>(true, "boolean")); // true
console.log(typer.is<unknown[]>([], "array")); // true
console.log(typer.is<object>({}, "object")); // true
// Type validation (throws on error)
const str = typer.isType<string>('string', 'Hello'); // Returns 'Hello' typed as string
const num = typer.isType<number>('number', 42); // Returns 42 typed as number
const typer = new Typer();
// Multiple type validation
const value = typer.isType<string | number>(['string', 'number'], 'Hello');
// Specific validations
const email = typer.isEmail('user@example.com');
const phone = typer.isPhoneNumber('+1234567890');
const url = typer.isURL('https://example.com');
// Array validations
const numbers = typer.isArrayOf<number>('number', [1, 2, 3]);
const nonEmpty = typer.isNonEmptyArray<string>(['a', 'b']);
// Range and constraints
const age = typer.isInRange(18, 65, 25);
const positiveInt = typer.isPositiveInteger(42);
const typer = new Typer();
// Modern JavaScript types
const bigIntVal = typer.isType<bigint>('bigint', BigInt(123));
const buffer = typer.isType<ArrayBuffer>('arraybuffer', new ArrayBuffer(8));
const typedArray = typer.isType<Int32Array>('typedarray', new Int32Array(4));
const dataView = typer.isType<DataView>('dataview', new DataView(buffer));
// Collections
const map = typer.isType<Map<string, number>>('map', new Map());
const set = typer.isType<Set<string>>('set', new Set());
// Special validations
const jsonStr = typer.isType<string>('json', '{"valid": "json"}');
const validDate = typer.isType<Date>('date', new Date());
const regex = typer.isType<RegExp>('regexp', /pattern/);
const typer = new Typer();
const userSchema = {
name: "string",
age: "number",
email: "string?", // Optional field
address: {
street: "string",
city: "string",
zip: "number|string" // Union types
},
hobbies: ["string"], // Array of strings
isActive: "boolean"
};
const userData = {
name: "John Doe",
age: 30,
address: {
street: "123 Main St",
city: "New York",
zip: 10001
},
hobbies: ["reading", "coding"],
isActive: true
};
const result = typer.checkStructure(userSchema, userData);
if (result.isValid) {
console.log("โ
Valid user data");
} else {
console.log("โ Validation errors:", result.errors);
}
// Strict mode (rejects extra properties)
const strictResult = typer.checkStructure(userSchema, userData, '', true);
const typer = new Typer();
// Register a custom validator
typer.registerType("positive", (value) => {
if (typeof value !== "number" || value <= 0) {
throw new TypeError("Value must be a positive number");
}
return value;
});
// Register email validator with override
typer.registerType("email", (value) => {
const email = typer.isType<string>('string', value);
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
throw new TypeError('Invalid email format');
}
return email;
}, true); // Override existing if present
// Use custom type
console.log(typer.is(10, "positive")); // true
console.log(typer.is(-5, "positive")); // false
// List all types
console.log(typer.listTypes());
// Export/Import types
const typesJson = typer.exportTypes();
typer.importTypes(typesJson);
// Remove custom type
typer.unregisterType("positive");
const typer = new Typer();
// Single parameter function
const safeMultiply = typer.expect(
(x: number) => x * 2,
{
paramTypes: ['number'],
returnType: ['number']
}
);
console.log(safeMultiply(4)); // 8
// safeMultiply("hello"); // Throws TypeError
// Multiple parameters
const safeAdd = typer.expect(
(x: number, y: number) => x + y,
{
paramTypes: ['number', 'number'],
returnType: ['number']
}
);
// Async function support
const asyncFunc = typer.expect(
async (x: number): Promise<string> => x.toString(),
{
paramTypes: ['number'],
returnType: ['string']
}
);
// Multiple return types
const flexibleFunc = typer.expect(
(x: boolean) => x ? 42 : "string",
{
paramTypes: ['boolean'],
returnType: ['number', 'string']
}
);
is<T>(value: unknown, types: string | string[]): value is TType guard that returns boolean. Safe for TypeScript type narrowing.
isType<T>(types: string | string[], value: unknown): TValidates type and returns the value cast to T. Throws TypeError on failure.
asString(value: unknown): stringValidates and returns a string. Alias for isType<string>('string', value).
asNumber(value: unknown): numberValidates and returns a number.
asBoolean(value: unknown): booleanValidates and returns a boolean.
asArray<T>(value: unknown): T[]Validates and returns an array.
asObject<T>(value: unknown): TValidates and returns an object.
isEmail(value: unknown): stringValidates email format.
isURL(value: unknown): stringValidates URL format.
isPhoneNumber(value: unknown): stringValidates international phone numbers (7-15 digits, ITU-T E.164 standard).
isArrayOf<T>(elementType: string, value: unknown): T[]Validates array with specific element type.
isNonEmptyString(value: unknown): stringValidates non-empty strings.
isNonEmptyArray<T>(value: unknown): T[]Validates non-empty arrays.
isOneOf<T>(values: readonly T[], value: unknown): TValidates value is one of specified options.
isInRange(min: number, max: number, value: unknown): numberValidates number within range.
isInteger(value: unknown): numberValidates integer values.
isPositiveNumber(value: unknown): numberValidates positive numbers.
isPositiveInteger(value: unknown): numberValidates positive integers.
isNegativeNumber(value: unknown): numberValidates negative numbers.
isNegativeInteger(value: unknown): numberValidates negative integers.
isFiniteNumber(value: unknown): numberStricter than isType('number', x): rejects NaN and Infinity. (3.1+)
isSafeInteger(value: unknown): numberValidates integers within Number.MIN/MAX_SAFE_INTEGER. (3.1+)
isUUID(value: unknown): stringValidates UUID strings (RFC 4122, versions 1โ5). (3.1+)
isIPv4(value: unknown): string / isIPv6(value: unknown): stringValidates IPv4 / IPv6 addresses. (3.1+)
isHexColor(value: unknown): stringValidates CSS hex colors (#RGB, #RGBA, #RRGGBB, #RRGGBBAA). (3.1+)
isISODate(value: unknown): DateValidates an ISO 8601 string and returns the parsed Date. (3.1+)
isBase64(value: unknown, opts?: { urlSafe?: boolean, requirePadding?: boolean }): stringValidates Base64 strings. (3.1+)
isPlainObject<T>(value: unknown): TValidates a plain object literal (rejects class instances, arrays, Map, Set). (3.1+)
isPromise<T>(value: unknown): Promise<T>Validates a Promise / thenable. (3.1+)
isInstanceOf<T>(ctor: new (...args: never[]) => T, value: unknown): TType-safe instanceof check. (3.1+)
matches(regex: RegExp, value: unknown): stringValidates a string matches the given regex. (3.1+)
isLength<T>(bounds: { min?: number; max?: number }, value: unknown): TValidates the length of a string or array. (3.1+)
isEmpty(value: unknown): unknown / isNonEmpty<T>(value: unknown): TPolymorphic emptiness check across string, array, Map, Set, object. (3.1+)
isIP(value: unknown): stringValidates an IP address of either version. (4.0+)
isSemver(value: unknown): stringValidates a Semantic Versioning 2.0.0 string, pre-release and build metadata included. (4.0+)
isSlug(value: unknown): stringValidates a URL-friendly slug (hello-world). (4.0+)
isPort(value: unknown): numberValidates a TCP/UDP port number (1โ65535; 0 is rejected). (4.0+)
isJWT(value: unknown): stringValidates the shape of a JSON Web Token. Does not verify the signature โ never use it as an authentication check. (4.0+)
isMACAddress(value: unknown): stringValidates a MAC address in colon- or hyphen-separated form. (4.0+)
nullable<T>(validator: Validator<T>): Validator<T | null>Wraps a validator so null is also accepted.
optional<T>(validator: Validator<T>): Validator<T | undefined>Wraps a validator so undefined is also accepted.
union<T extends readonly unknown[]>(...validators): Validator<T[number]>Tries each validator in order; succeeds on the first match.
discriminatedUnion<Key, V>(key: Key, variants: V, options?: { strict?: boolean }): StandardValidator<โฆ> (4.1+)A union told apart by one key. Selects the variant in constant time on the discriminant value and reports against that variant alone. Variants are keyed by discriminant value; each member carries its tag as a literal type.
literal<T>(...values): Validator<T[number]>Accepts only the listed literal values, narrowing to their union. (4.0+)
arrayOf<T>(element: Validator<T>, bounds?: { min?: number; max?: number }): Validator<T[]>Validates every element, reporting all failures at once. (4.0+)
record<T>(value: Validator<T>): Validator<Record<string, T>>Validates a dictionary object's values. Rejects arrays and null. (4.0+)
tuple<T>(validators: T): Validator<[...]>Validates a fixed-length, heterogeneous array. (4.0+)
refine<T>(validator: Validator<T>, predicate: (v: T) => boolean, message: string): Validator<T>Adds a constraint without changing the type. (4.0+)
transform<T, U>(validator: Validator<T>, transformer: (v: T) => U): Validator<U>Maps a validated value. The transformer only ever sees valid input. (4.0+)
withDefault<T>(validator: Validator<T>, fallback: T | (() => T)): Validator<T>Substitutes a default for undefined (not for null). Pass a factory for object/array defaults so instances are not shared. (4.0+)
lazy<T>(factory: () => Validator<T>): Validator<T>Defers construction, which is what makes recursive shapes expressible. Runs the factory once. (4.0+)
instanceOf<T>(ctor): Validator<T>Composable form of isInstanceOf. (4.0+)
objectOf<S>(schema: S, options?: { strict?: boolean }): StandardValidator<Infer<S>>Turns a schema into a validator, so object shapes nest inside the other combinators. The result also carries ~standard. (4.0+, Standard Schema in 4.1+)
standard<S>(target: S, options?: { strict?: boolean }): StandardValidator<T>Wraps a schema, validator or type alias as a Standard Schema, so it is accepted by tRPC, Hono, TanStack Form and Router, Nuxt and others with no adapter. The result stays a callable Validator. (4.1+)
validatorsThe is* / as* validators, pre-bound to the instance, so they can be passed as values without losing this. Built on first access and cached. (4.0+)
coercecoerce.number, coerce.boolean, coerce.date โ validators that convert before validating, for query strings, form data and environment variables. They reject what they cannot convert ('', null, [], 'abc') instead of producing 0, and read 'false' as false. (4.1+)
safeParse<K>(types, value): ParseResult<TypeMap[K]>Same input as parse, returns a discriminated union instead of throwing:
{ success: true, data } | { success: false, issues, error }.
issues is a ValidationIssue[] โ { code, path, message, expected?, received? }.
error is a TyperError (which extends TypeError) built lazily on first
access, so reading only issues avoids the cost of capturing a stack trace.
(issues added in 4.0)
parse<S>(schemaOrTypeOrValidator, value): T (3.2+)Universal entry point. Accepts a type alias, an array of aliases, a
Validator<T> function, or a Schema object. Throws TypeError on
failure; on success returns the value typed via Infer<S> (for
schemas) or TypeMap[K] (for type aliases).
safeParse<S>(schemaOrTypeOrValidator, value): ParseResult<T> (3.2+ for schema overload)Non-throwing variant. Returns
{ success: true, data } | { success: false, error: TypeError }.
toJSONSchema<S>(schema: S, options?: ToJSONSchemaOptions): JSONSchemaDocument (4.1+)Converts a schema into a JSON Schema document (draft 2020-12 by default), for OpenAPI and Swagger tooling. Options: $schema (dialect, or false to omit), id, title, description, strict (emits additionalProperties: false), and unrepresentable ('any' by default, 'throw' to fail on slots with no JSON Schema equivalent).
pick<S, K>(schema: S, keys: readonly K[]): PickSchema<S, K> (4.1+)Derives a schema keeping only the listed keys. Returns a new schema; the source is untouched.
omit<S, K>(schema: S, keys: readonly K[]): OmitSchema<S, K> (4.1+)Derives a schema without the listed keys โ the complement of pick.
merge<A, B>(base: A, extension: B): MergeSchema<A, B> (4.1+)Combines two schemas. Keys of extension win where the two overlap.
partial<S, K>(schema: S, keys?: readonly K[]): PartialSchema<S, K> (4.1+)Makes every key optional, or only the listed ones. Type-string slots gain the ? marker; validator, array and nested-schema slots are rebuilt as optional(...) validators and report failures as a single custom issue at the slot path.
schema<const S>(definition: S): S (3.2+)Identity helper that preserves literal types of a schema declared in a
variable. Use it to derive Infer<typeof schema> without as const.
Infer<S> (3.2+, type-only export)Derives a TypeScript type from a runtime schema literal โ handles
?-suffix optionals, |-unions, array elements, nested objects, and
embedded Validator<T> functions.
checkStructure(schema: Record<string, unknown>, obj: Record<string, unknown>, path?: string, strictMode?: boolean): StructureValidationReturnValidates object structure against schema. Returns {isValid: boolean, errors: string[]}.
Lower-level than parse โ kept for backward compatibility and for the
strict-mode entry point.
registerType(name: string, validator: (value: unknown) => unknown, override?: boolean): voidRegisters custom type validator.
unregisterType(name: string): voidRemoves registered type.
listTypes(): string[]Returns all registered type names.
exportTypes(): stringExports types as JSON string.
importTypes(json: string): voidImports types from JSON string.
expect(func: Function, types: TyperExpectTypes): FunctionWraps function with type checking for parameters and return value.
validate(schema: Record<string, string | string[]>, obj: Record<string, unknown>): string[]Validates object against simple schema, returns error array.
assert(value: unknown, expectedType: string | string[]): voidLogs warning if type assertion fails.
Primitives: string, number, boolean, bigint, symbol, undefined, null
Objects: object, array, function, date, regexp, map, set
Advanced: arraybuffer, dataview, typedarray, json, domelement
Aliases: Short forms like s/str for string, n/num for number, etc.
Validation libraries are usually sold on speed. Typer's honest position is narrower and worth stating plainly, because a library that says where it loses is easier to trust on where it wins.
From the 4.0.0 audit, against zod 4.6.5 and @sinclair/typebox 0.34.52 on
Node 26 / darwin arm64, each library measured in its own equivalent-work lane:
| Scenario | Typer | Zod | TypeBox JIT | |
|---|---|---|---|---|
| Schema setup | 1.13 ยตs | 5.74 ยตs | 3.43 ยตs | ๐ฅ |
| Bundle (gzip) | 7.0 KB | 92 KB | 22.4 KB | ๐ฅ * |
| Flat object, valid | 90 ns | 68 ns | 3.3 ns | ๐ฅ |
| Nested object, 25 items | 1.74 ยตs | 807 ns | 191 ns | ๐ฅ |
| Flat object, invalid | 404 ns | 354 ns | 1.05 ยตs | ๐ฅ |
| Deep nested, invalid | 2.07 ยตs | 1.27 ยตs | 15.19 ยตs | ๐ฅ |
* behind zod/mini at 4.8 KB. Those bundle figures are 4.0.0's; 4.1 added
Standard Schema, schema composition, coercion, discriminated unions and JSON
Schema output, and npm run size now reports 9.7 KB gzip for the full ESM
bundle. None of it can be tree-shaken away by a consumer who does not use it,
because the API hangs off a class instance โ which is what the 5.0
modularization is for.
On the hot path Typer is slower than Zod, and about nine times slower than a compiled TypeBox. That is not a problem in itself โ 90 ns is far below the point where validation is visible inside an HTTP handler โ but it is not the reason to choose Typer either. The three real wins are instant setup, a small bundle, and schemas that read like the shape they describe.
Compiled checkers are cached by schema object identity. A schema literal written inside a handler is a new object on every call, so it is recompiled every time โ about an order of magnitude slower, and silent, because the code looks perfectly ordinary:
const userSchema = typer.schema({ id: 'number' }); // compiled once
app.post('/u', (req) => typer.parse(userSchema, req.body)); // 78 ns
app.post('/u', (req) => typer.parse({ id: 'number' }, req.body)); // 873 ns
It is a cost, not a leak: the cache is a WeakMap, so the throwaway schema is
collected normally.
Schemas are compiled to closures once and cached by schema identity; every type name, optional marker and union alternative is resolved at compile time, so validating is a flat loop over predicates. Error paths are only built when something actually fails.
npm run bench on Node 26, median of 7 batches (absolute numbers are
machine-dependent):
| Operation | ops/sec |
|---|---|
is(value, 'string') |
~240M |
isType('string', value) |
~165M |
isArrayOf('number', 50 items) |
~45M |
parse(flat schema) โ valid |
~24M |
parse(nested schema) โ valid |
~8M |
checkStructure(nested) |
~7M |
safeParse(nested) โ invalid |
~2M |
The last row is the one that changed most in 4.0 (17ร faster): a failing validation no longer throws, catches and rebuilds its own error internally.
The
is/isTyperows measure a few-nanosecond operation and swing widely with V8's inlining decisions โ treat a change there as noise unless it reproduces across runs. The schema rows are stable to within a few percent.
Typer has 97% line / 99.4% function / 96.9% statement / 91.9% branch coverage with 406 tests covering:
npm test # Jest suite + type-level suite
npm run test:watch # Watch mode
npm run test:coverage # Coverage report
npm run test:types # Type-level assertions only
npm run bench # Benchmarks
The type-level suite (tests/types/) is checked by tsc, not Jest: it asserts
that Infer produces exactly the expected types, and that invalid schemas are
rejected. It is part of npm test.
The uncovered lines are the
return pstatements of the built-in checkers. Since the predicate fast-path landed, those checkers only ever run to throw โ see theTODO(tech-debt)note insrc/Typer.ts.
npm run build # Build all formats
npm run build:docs # Build with documentation
Outputs:
dist/Typer.min.js - UMD formatdist/Typer.esm.mjs - ES modulesdist/Typer.cjs.min.js - CommonJSdist/Typer.d.ts - TypeScript definitionsMIT License - see LICENSE file for details.
Contributions are welcome! Please:
Michael Lavigna
Built with โค๏ธ and TypeScript