Typed, programmatic JSON schemas.
Writing JSON Schema by hand is verbose: every object needs its quotes and its additionalProperties. This package
wraps the common shapes in typed factories and adds utilities for composing and reshaping the result. It consumes and
produces plain JSON Schema objects with little to no processing — anything you already wrote can be passed straight in.
Ships both ESM and CommonJS builds, with TypeScript types for each.
Two positions the factories take, so you don't have to repeat yourself:
additionalPropertiesisfalseforobjectschemas unless you say otherwise.additionalItemsisfalseforarrayschemas unless you say otherwise.
Node 22 or newer (engines.node is >=22) — every line still receiving security support. Each release is tested
on 22, 24 and 26; the declared floor is the lowest version CI actually runs, not a guess.
npm i @tselect/schemapnpm add @tselect/schemaimport * as Schema from '@tselect/schema';
const user = Schema.object(
{
id: Schema.uuid(),
email: Schema.email(),
age: Schema.integer({ minimum: 0 }),
},
{ required: ['id', 'email'] },
);Named imports and require() both work:
import { email, object, omitProperties } from '@tselect/schema';const { object, email } = require('@tselect/schema');The result is an ordinary JSON Schema object, so a validator takes it as-is:
import { Ajv } from 'ajv';
const validate = new Ajv().compile(user);
validate({ id: '00000000-0000-0000-0000-000000000000', age: 3 }); // true
validate({ id: 'nope' }); // falseTwo things to know when validating with ajv 8 specifically:
formatkeywords — everythingemail(),date()anddateTime()produce — are not built in. Compiling such a schema throwsunknown formatunless you addajv-formats.- A schema whose
typeis a union of two non-nulltypes, which is whatenumeration()over mixed values produces, logs a strict-mode warning unless ajv is constructed with{ allowUnionTypes: true }.nullable: trueschemas are not affected: ajv accepts['string', 'null']without it.
Some factories mutate the options object you pass.
date,dateTime,uuid,array,list,tuple,objectandenumerationwrite their computed keys —format,pattern,items,properties,additionalItems/additionalProperties,enum— onto the argument rather than onto a copy, andanyreturns the argument itself.string,number,integerandbooleando not. Pass a fresh object literal if you intend to reuse it.
TJSONSchema is the base type: the keywords this package models by name, plus a [key: string]: any index signature
that keeps the whole of JSON Schema reachable. The rest narrow it — TStringJSONSchema, TNumberJSONSchema,
TIntegerJSONSchema, TBooleanJSONSchema, TArrayJSONSchema and TObjectJSONSchema<T>, which carries the shape T
through properties and required.
TOptions<T> is what the factories accept: Partial<T> plus TCommonOptions, i.e. { nullable?: boolean }.
Schema.JSONSchemaType.OBJECT; // 'object'
// also ARRAY, STRING, INTEGER, BOOLEAN, NULL, NUMBERSchema.JSONStringFormat.DATE_TIME; // 'date-time'
// also DATE, EMAIL, HOSTNAME, IPV4, IPV6, URIEach takes TOptions<...> and returns the corresponding schema. nullable: true turns type into a two-member array
and is stripped from the output.
Schema.string(); // { type: 'string' }
Schema.string({ minLength: 3, maxLength: 10 }); // { minLength: 3, maxLength: 10, type: 'string' }
Schema.string({ nullable: true }); // { type: ['string', 'null'] }Schema.number({ minimum: 0 }); // { minimum: 0, type: 'number' }Schema.integer({ multipleOf: 2 }); // { multipleOf: 2, type: 'integer' }Schema.boolean(); // { type: 'boolean' }string() with format preset.
Schema.date(); // { format: 'date', type: 'string' }
Schema.dateTime(); // { format: 'date-time', type: 'string' }
Schema.email(); // { format: 'email', type: 'string' }string() with a pattern matching a canonical UUID. There is no uuid format in draft-07, so this is a regex rather
than a format.
Schema.uuid();
// {
// pattern: '^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$',
// type: 'string'
// }Returns its argument untouched. Use it for a schema this package has no factory for.
Schema.any({ title: 'Anything' }); // { title: 'Anything' }Schema.array(); // { additionalItems: false, type: 'array' }An array whose every item matches one schema.
Schema.list(Schema.string());
// { items: { type: 'string' }, additionalItems: false, type: 'array' }An array whose items match a positional list of schemas.
Schema.tuple([Schema.string(), Schema.integer()]);
// { items: [{ type: 'string' }, { type: 'integer' }], additionalItems: false, type: 'array' }Schema.object({ foo: Schema.email() });
// {
// properties: { foo: { format: 'email', type: 'string' } },
// additionalProperties: false,
// type: 'object'
// }options.properties, if given, replaces the positional argument rather than merging with it. Every JSON Schema keyword
is accepted through options, so an existing schema round-trips unchanged:
Schema.object(
{},
{
type: Schema.JSONSchemaType.OBJECT,
required: ['foo'],
additionalProperties: true,
properties: { foo: { type: Schema.JSONSchemaType.STRING, format: Schema.JSONStringFormat.EMAIL } },
},
);
// the same object backtype and format are typed as the JSONSchemaType and JSONStringFormat enums, not as bare strings, so a schema
literal copied out of a .json file needs the enum members — or an assertion — to typecheck. The values are identical
either way; this is a compile-time distinction only.
Takes an array of values, or a TypeScript enum object. type is always an array, one member per distinct value type
found. Throws for any value that is not a string, a number or null.
Schema.enumeration(['a', 'b']); // { enum: ['a', 'b'], type: ['string'] }
Schema.enumeration([1, 'two']); // { enum: [1, 'two'], type: ['number', 'string'] }
enum Colour {
RED = 'red',
BLUE = 'blue',
}
Schema.enumeration(Colour); // { enum: ['red', 'blue'], type: ['string'] }Untyped wrappers around the corresponding keyword.
Schema.anyOf([Schema.string(), Schema.integer()]);
// { anyOf: [{ type: 'string' }, { type: 'integer' }] }Adds 'null' to type, or to the anyOf/oneOf branches when the schema has no type. Pass false to remove it.
Throws for a schema with neither.
Schema.nullable(Schema.string()); // { type: ['string', 'null'] }
Schema.nullable(Schema.string({ nullable: true }), false); // { type: 'string' }A deep clone.
Clone, then shallow-assign overrides over the top. Array-valued keys are replaced.
Schema.cloneWith(Schema.object({ foo: Schema.string() }), { required: ['foo'] });
// { properties: { foo: { type: 'string' } }, additionalProperties: false, type: 'object', required: ['foo'] }Clone, then deep-merge overrides. Array-valued keys are concatenated — this is the difference from cloneWith.
Schema.mergeWith(
Schema.object({ foo: Schema.string() }, { required: ['foo'] }),
Schema.object({ bar: Schema.integer() }, { required: ['bar'] }),
);
// {
// required: ['foo', 'bar'],
// properties: { foo: { type: 'string' }, bar: { type: 'integer' } },
// additionalProperties: false,
// type: 'object'
// }Return a clone with properties filtered, and required filtered to match.
const user = Schema.object(
{ foo: Schema.email(), bar: Schema.integer() },
{ required: ['foo', 'bar'] },
);
Schema.omitProperties(user, ['bar']);
// { required: ['foo'], properties: { foo: { format: 'email', type: 'string' } }, additionalProperties: false, type: 'object' }
Schema.pickProperties(user, ['foo']);
// the same result, reached from the other directionReturns a clone with required set to properties. Pass { preserveExisting: true } to union with the existing
required instead of replacing it.
const pair = Schema.object({ foo: Schema.string(), bar: Schema.string() }, { required: ['bar'] });
Schema.requireProperties(pair, ['foo']); // required: ['foo']
Schema.requireProperties(pair, ['foo'], { preserveExisting: true }); // required: ['foo', 'bar']A type guard: true for a plain object carrying type, allOf, anyOf or oneOf.
Schema.isJSONSchemaLike(Schema.string()); // true
Schema.isJSONSchemaLike({ foo: 1 }); // falseRenders a RegExp as a JSON Schema pattern. Throws if the expression carries flags — JSON Schema has nowhere to put
them.
Schema.toStringRegExp(/^\d+$/); // '^\\d+$'The internals the factories are built from, exported for building your own.
Schema.cleanOptions({ nullable: true, title: 'T' }); // { title: 'T' } — strips `nullable`
Schema.makeSchema({ minLength: 1 }, 'string'); // { minLength: 1, type: 'string' }MIT © Sylvain Estevez