This small library provides a simple schema validation system for JavaScript/TypeScript. The library has basic types with opportunities for extending.
- Installation
- Quick Start
- Why Use @esmj/schema?
- Comparison with Similar Libraries
- Usage
- Modular Extensions
- API Reference Summary
- Schema Types
- Schema Methods
- Extending Schemas
- More Examples
- Examples Folder
- Migration Guide
- License
npm install @esmj/schemaGet started with @esmj/schema in seconds:
import { s } from '@esmj/schema';
// Define a schema
const userSchema = s.object({
name: s.string(),
age: s.number(),
email: s.string().optional()
});
// Parse data
const user = userSchema.parse({
name: 'John Doe',
age: 30
});
console.log(user);
// { name: 'John Doe', age: 30 }
// Safe parse with error handling
const result = userSchema.safeParse({
name: 'Jane',
age: 'invalid'
});
if (result.success) {
console.log(result.data);
} else {
console.error(result.error.message);
}With Extensions:
import { s } from '@esmj/schema/full';
const schema = s.object({
username: s.string().trim().toLowerCase().min(3).max(20),
age: s.number().int().positive().min(18),
tags: s.array(s.string()).min(1).unique()
});
const result = schema.parse({
username: ' JohnDoe ',
age: 25,
tags: ['developer', 'typescript']
});
// { username: 'johndoe', age: 25, tags: ['developer', 'typescript'] }@esmj/schema is a lightweight and flexible schema validation library designed for developers who need a simple yet powerful way to validate and transform data. Here are some reasons to choose this package:
- TypeScript First: Built with TypeScript in mind, it provides strong type inference—even for deeply nested and complex schemas.
- Extensibility: Easily extend the library with custom logic, refinements, and preprocessors using the
extendfunction. - Rich Features: Includes advanced features like preprocessing, transformations, piping, refinements, and robust error collection (
abortEarly), which are not always available in similar libraries. - Actionable Error Handling: Collect all validation errors at once for better debugging and user experience, with clear and consistent error structures.
- Lightweight: No dependencies and a small footprint make it ideal for projects where performance and simplicity are key.
- Customizable: Offers fine-grained control over validation, error handling, and schema composition.
- Performance: Optimized for speed, making it one of the fastest schema validation libraries available.
- Modular: Import only what you need with separate string, number, and array extension modules to minimize bundle size.
- Schema Creation: Create schemas at up to 4,370,618 ops/s (0.23 μs latency) with @sinclair/typebox, or 736,810 ops/s (1.36 μs latency) with @esmj/schema. Superstruct is also competitive for schema creation performance.
- Parsing: Parse data at up to 33,620,146 ops/s (0.03 μs latency) with AJV (best result in this benchmark). For comparison,
@zod/minireached 4,627,714 ops/s (0.22 μs, observed at 200% CPU), and@esmj/schemareached 3,142,587 ops/s (0.32 μs), with ArkType and effect/Schema also performing strongly. - Error Handling: Handle validation errors at up to 19,693,821 ops/s (0.05 μs latency) with AJV (best result in this benchmark), while
@esmj/schemadelivered 2,428,049 ops/s (0.41 μs) with a developer-friendly API.
Across the benchmark tables, @esmj/schema shows strong all-around results: fast schema creation, very competitive parsing and error-handling throughput, and a very small bundle size (~1.6 KB core).
For most TypeScript applications, it offers a practical balance of performance, size, and developer ergonomics. If absolute peak throughput in a single category is the only goal, some specialized options (for example, AJV or TypeBox in specific tests) can be faster.
When choosing a schema validation library, bundle size can be an important factor, especially for frontend applications where minimizing JavaScript size is critical. Here's how @esmj/schema compares to other popular libraries:
| Library | Bundle Size (minified + gzipped) |
|---|---|
@esmj/schema |
~1.6 KB |
| Superstruct | ~3.2 KB |
| @sinclair/typebox | ~11.7 KB |
| Yup | ~12.2 KB |
| Zod@3 | ~13 KB |
| @zod/mini | ~20.5 KB |
| AJV | ~31.4 KB |
| Joi | ~40.4 KB |
| Zod@4 | ~40.8 KB |
| ArkType | ~41.8 KB |
| Effect/Schema | ~115.5 KB |
All benchmarks were measured on Node.js v24.1.0.
| Library | Throughput average (ops/s) | Latency average (μs) |
|---|---|---|
| @esmj/schema | 736 810.12 ± 3.03% | 1.36 ± 3.24% |
| Zod@3 | 112 575.50 ± 0.86% | 8.88 ± 0.87% |
| @zod/mini | 23 456.07 ± 1.26% | 42.64 ± 1.28% |
| Yup | 75 051.06 ± 4.38% | 13.36 ± 4.41% |
| Superstruct | 509 401.06 ± 0.80% | 1.96 ± 0.80% |
| Joi | 42 455.28 ± 1.27% | 23.56 ± 1.30% |
@sinclair/typebox |
4 370 618.49 ± 1.23% |
0.23 ± 1.23% |
| ArkType | 16 282.69 ± 4.14% | 61.61 ± 4.38% |
| effect/Schema | 24 919.15 ± 4.31% | 40.31 ± 4.78% |
| AJV | 240.32 ± 4.67% | 4 164.31 ± 2.04% |
| Library | Throughput average (ops/s) | Latency average (μs) |
|---|---|---|
| @esmj/schema | 3 142 587.31 ± 0.97% | 0.32 ± 0.99% |
| zod@3 | 1 018 777.24 ± 0.64% | 0.98 ± 0.65% |
| @zod/mini. | 4 627 714.90 ± 2.23% | 0.22 ± 2.36% |
| Yup | 108 361.49 ± 0.50% | 9.23 ± 0.51% |
| Superstruct | 252 904.42 ± 2.20% | 3.96 ± 2.44% |
| Joi | 346 094.49 ± 0.65% | 2.89 ± 0.65% |
| @sinclair/typebox | 228 711.62 ± 2.03% | 4.38 ± 2.23% |
| ArkType | 1 677 066.00 ± 0.58% | 0.60 ± 0.59% |
| effect/Schema | 1 060 056.14 ± 0.61% | 0.94 ± 0.61% |
AJV |
33 620 146.24 ± 1.19% |
0.03 ± 1.26% |
| Library | Throughput average (ops/s) | Latency average (μs) |
|---|---|---|
| @esmj/schema | 2 428 049.34 ± 0.54% | 0.41 ± 0.53% |
| zod@3 | 641 504.22 ± 3.67% | 1.57 ± 4.38% |
| @zod/mini | 1 386 616.61 ± 0.60% | 0.72 ± 0.60% |
| Yup | 98 904.30 ± 0.61% | 10.11 ± 0.61% |
| Superstruct | 122 782.09 ± 1.03% | 8.15 ± 1.03% |
| Joi | 271 301.11 ± 1.58% | 3.69 ± 1.59% |
| @sinclair/typebox | 228 734.49 ± 0.55% | 4.37 ± 0.56% |
| ArkType | 258 685.33 ± 1.23% | 3.87 ± 1.23% |
| effect/Schema | 165 753.69 ± 0.99% | 6.03 ± 1.00% |
AJV |
19 693 821.79 ± 1.81% |
0.05 ± 1.83% |
Note: During the performance tests, @zod/mini was observed to consume 200% CPU, while other libraries used only 100% CPU. This may affect the interpretation of the results, especially in multi-threaded environments.
import { s, type Infer} from '@esmj/schema';
const schema = s.object({
username: s.string().optional().refine((val) => val.length <= 255, {
message: "Username can't be more than 255 characters",
}),
password: s.string().default('unknown'),
birthday: s.preprocess((value) => new Date(value), s.date()),
account: s.string().default('0').transform((value) => Number.parseInt(value)).pipe(s.number()),
money: s.number(),
address: s.object({
street: s.string(),
city: s.string().optional(),
}).default({ street: 'unknown' }),
records: s.array(s.object({ name: s.string() })).default([]),
});
type schemaType = Infer<typeof schema>;
const result = schema.parse({
username: 'john_doe',
birthday: '2000-01-01T23:59:59.000Z',
address: { city: 'New York' },
money: 100,
});
console.log(result);
// {
// username: 'john_doe',
// password: 'unknown',
// birthday: Date('2000-01-01T23:59:59.000Z'),
// account: 0,
// money: 100,
// address: {
// street: 'unknown',
// city: 'New York',
// },
// records: [],
// }@esmj/schema provides modular extensions that can be imported individually or all together, allowing you to include only the validation helpers you need.
// Minimal version (core only, ~1.5 KB)
import { s } from '@esmj/schema';
// Full version (all extensions included, ~4 KB)
import { s } from '@esmj/schema/full';
// String extensions only
import { s } from '@esmj/schema/string';
// Number extensions only
import { s } from '@esmj/schema/number';
// Array extensions only
import { s } from '@esmj/schema/array';
// Coerce extensions only
import { s } from '@esmj/schema/coerce';
// Mix and match (side-effect imports)
import '@esmj/schema/string';
import '@esmj/schema/number';
import '@esmj/schema/coerce';
import { s } from '@esmj/schema';
// Tree-shakeable named exports — bundle only what you use
import { string, number, object, array } from '@esmj/schema';
import { coerce, cast } from '@esmj/schema';
// Named exports for reserved-word factories
import { functionSchema, enumSchema } from '@esmj/schema';- Core only (
@esmj/schema): ~1.5 KB gzipped - String extensions (
@esmj/schema/string): +~0.8 KB - Number extensions (
@esmj/schema/number): +~0.6 KB - Array extensions (
@esmj/schema/array): +~0.5 KB - Coerce extensions (
@esmj/schema/coerce): +~0.3 KB - Full (
@esmj/schema/full): ~4 KB gzipped (all extensions)
Recommendation: Import only the extensions you need to minimize bundle size.
Every factory in the core module is exported both as a named function and as a property on s. The two references are identical — no extra wrapper, no overhead.
import { s } from '@esmj/schema';
import { string, number } from '@esmj/schema';
s.string === string; // trueWhen you import individual factories, bundlers (webpack, Rollup, Vite, esbuild) tree-shake everything else out:
// Only `string` and `number` end up in the final bundle — object, array, coerce, cast, etc. are excluded
import { string, number } from '@esmj/schema';
const nameSchema = string().optional();
const ageSchema = number();The coerce and cast namespaces are also individually exported:
// coerce is a named export from @esmj/schema but s.coerce methods
// require importing '@esmj/schema/coerce' (or '@esmj/schema/full') first:
import '@esmj/schema/coerce';
import { s } from '@esmj/schema';
const schema = s.coerce.number();
schema.parse('42'); // 42
// Or import the standalone coerce object directly:
import { coerce } from '@esmj/schema/coerce';
const schema2 = coerce.number();
schema2.parse('42'); // 42Because function and enum are reserved words in JavaScript, their standalone names are functionSchema and enumSchema:
import { functionSchema, enumSchema } from '@esmj/schema';
const cb = functionSchema();
const role = enumSchema(['admin', 'user', 'guest']);They are still accessible as s.function() and s.enum() for full API compatibility.
String extensions provide common validation and transformation methods for string schemas.
import { s } from '@esmj/schema/string';
const userSchema = s.object({
username: s.string()
.trim() // Remove whitespace
.toLowerCase() // Convert to lowercase
.min(3) // Minimum 3 characters
.max(20) // Maximum 20 characters
.startsWith('user_'), // Must start with 'user_'
email: s.string()
.trim()
.toLowerCase()
.includes('@') // Must contain '@'
});
userSchema.parse({
username: ' USER_John ',
email: ' John@Example.com '
});
// ✓ { username: 'user_john', email: 'john@example.com' }Available String Methods:
- Length validations:
min(length),max(length),length(exact),nonEmpty() - Pattern validations:
startsWith(prefix),endsWith(suffix),includes(substring) - Transformations:
trim(),toLowerCase(),toUpperCase(),padStart(length, char),padEnd(length, char),replace(search, replace)
Number extensions provide validation methods for number schemas including range checks and type validations.
import { s } from '@esmj/schema/number';
const productSchema = s.object({
price: s.number()
.positive() // Must be positive
.min(0.01) // Minimum value
.max(999999.99), // Maximum value
quantity: s.number()
.int() // Must be integer
.positive()
.min(1)
.max(1000),
discount: s.number()
.min(0)
.max(100)
.multipleOf(5) // Must be multiple of 5
});
productSchema.parse({
price: 29.99,
quantity: 5,
discount: 10
});
// ✓ { price: 29.99, quantity: 5, discount: 10 }Available Number Methods:
- Range validations:
min(value),max(value),positive(),negative() - Type validations:
int(),float(),multipleOf(value),finite()
Array extensions provide validation and transformation methods for array schemas.
import { s } from '@esmj/schema/array';
const tagsSchema = s.object({
tags: s.array(s.string())
.min(1) // At least 1 item
.max(5) // At most 5 items
.unique() // All items must be unique
});
tagsSchema.parse({
tags: ['javascript', 'typescript', 'node']
});
// ✓ { tags: ['javascript', 'typescript', 'node'] }Available Array Methods:
- Size validations:
min(length),max(length),length(exact),nonEmpty() - Content validations:
unique() - Transformations:
sort(),reverse()
Coerce extensions add s.coerce methods that apply native JS constructors before validation, providing convenient type coercion with clear error messages.
Note:
s.coerceis opt-in. Import@esmj/schema/coerce(or@esmj/schema/full) to activate it. Importing only the core@esmj/schemaleavess.coerceempty.
import { s } from '@esmj/schema/coerce';
s.coerce.number().parse('42'); // 42
s.coerce.string().parse(123); // '123'
s.coerce.boolean().parse(0); // false
s.coerce.date().parse('2024-01-01'); // Date object
// Coerce then chain schema methods:
s.coerce.number().refine((v) => v > 0, { message: 'Must be positive' }).parse('5'); // 5
// Custom error message:
s.coerce.number({ message: 'Expected a numeric value' }).parse('bad'); // throws: Expected a numeric valueAvailable Coerce Methods:
| Method | Coercion | Fails when |
|---|---|---|
s.coerce.string(options?) |
String(v) |
Never |
s.coerce.number(options?) |
Number(v) |
Result is NaN |
s.coerce.boolean(options?) |
Boolean(v) |
Never |
s.coerce.date(options?) |
new Date(v) |
Result is an invalid Date |
The full version includes all string, number, and array extensions in a single import.
import { s } from '@esmj/schema/full';
const productSchema = s.object({
// String extensions
name: s.string()
.trim()
.min(3)
.max(100),
sku: s.string()
.toUpperCase()
.length(8)
.startsWith('PROD'),
// Number extensions
price: s.number()
.positive()
.min(0.01)
.max(999999.99),
stock: s.number()
.int()
.min(0),
// Array extensions
categories: s.array(s.string())
.min(1)
.max(5)
.unique(),
dimensions: s.array(s.number().positive())
.length(3) // [length, width, height]
});Custom Error Messages:
All extension methods support custom error messages:
const schema = s.object({
username: s.string().min(3, {
message: 'Username is too short! Please use at least 3 characters.'
}),
age: s.number().positive({
message: 'Age must be a positive number.'
}),
tags: s.array(s.string()).unique({
message: 'Duplicate tags are not allowed.'
})
});All factory functions below are available both as methods on s and as individual named exports for tree-shaking:
s.string()/import { string }- String validations.number()/import { number }- Number validations.boolean()/import { boolean }- Boolean validations.date()/import { date }- Date validations.function()/import { functionSchema }- Function validations.object(def)/import { object }- Object validations.array(def)/import { array }- Array validations.literal(value)/import { literal }- Literal value validations.enum(values)/import { enumSchema }- Enum validations.union(schemas)/import { union }- Union validations.any()/import { any }- Any types.preprocess(fn, schema)/import { preprocess }- Preprocess before validations.coerce/import { coerce } from '@esmj/schema/coerce'- Coerce namespace (requires@esmj/schema/coerceimport)s.cast/import { cast }- Cast namespace
.optional()- Makes field optional.nullable()- Makes field nullable.nullish()- Makes field optional and nullable.default(value)- Sets default value.catch(value)- Returns fallback value on any parse failure
Requires
import '@esmj/schema/coerce'orimport '@esmj/schema/full'.s.coerceis empty without this import.
s.coerce.string()- Coerce any value to string, then validates.coerce.number()- Coerce any value to number, then validate (fails for NaN)s.coerce.boolean()- Coerce any value to boolean, then validates.coerce.date()- Coerce any value to Date, then validate (fails for invalid dates)
Semantic casting that understands common string representations and rejects ambiguous inputs:
s.cast.boolean()- Cast to boolean; understands'true'/'false','yes'/'no','on'/'off','1'/'0'(case-insensitive); rejectsnull/undefined/unrecognised stringss.cast.number()- Cast to number; trims whitespace from strings, accepts booleans (true→1,false→0); rejectsnull/undefined/empty stringss.cast.string()- Cast to string; accepts strings, finite numbers, and booleans; rejectsnull/undefined/objects/NaN/Infinitys.cast.date()- Cast to Date; accepts ISO strings, finite timestamps, and existing Dates; rejectsnull/undefined/booleans/empty stringss.cast.json(schema)- Parse a JSON string and validate the result against a schema; non-string inputs pass through directly; malformed JSON returns a proper validation failure
.transform(fn)- Transform values.preprocess(fn, schema)- Preprocess before validation.pipe(schema)- Pipe to another schema.refine(fn, opts)- Custom validation
Available when importing from @esmj/schema/string or @esmj/schema/full:
Length Validations:
.min(n)- Minimum length.max(n)- Maximum length.length(n)- Exact length.nonEmpty()- Non-empty string
Pattern Validations:
.startsWith(prefix)- Must start with prefix.endsWith(suffix)- Must end with suffix.includes(substring)- Must contain substring
Transformations:
.trim()- Remove whitespace.toLowerCase()- Convert to lowercase.toUpperCase()- Convert to uppercase.padStart(length, char)- Pad start.padEnd(length, char)- Pad end.replace(search, replace)- Replace text
Available when importing from @esmj/schema/number or @esmj/schema/full:
Range Validations:
.min(n)- Minimum value.max(n)- Maximum value.positive()- Must be positive.negative()- Must be negative
Type Validations:
.int()- Must be integer.float()- Must be float (non-integer).multipleOf(n)- Must be multiple of n.finite()- Must be finite
Available when importing from @esmj/schema/array or @esmj/schema/full:
Size Validations:
.min(n)- Minimum length.max(n)- Maximum length.length(n)- Exact length.nonEmpty()- Non-empty array
Content Validations:
.unique()- All items must be unique
Transformations:
.sort()- Sort array.reverse()- Reverse array
Creates a string schema. You can optionally pass options to customize error messages.
message: Can be either a constant string or a function(value) => string.
const stringSchema = s.string({
message: 'This is a constant error message.',
});
const stringSchemaFunc = s.string({
message: (value) => `Custom error: "${value}" is not a valid string.`,
});Creates a number schema. You can optionally pass options to customize error messages.
message: Can be either a constant string or a function(value) => string.
const numberSchema = s.number({
message: 'This is a constant error message.',
});
const numberSchemaFunc = s.number({
message: (value) => `Custom error: "${value}" is not a valid number.`,
});Creates a boolean schema. You can optionally pass options to customize error messages.
message: Can be either a constant string or a function(value) => string.
const booleanSchema = s.boolean({
message: 'This is a constant error message.',
});
const booleanSchemaFunc = s.boolean({
message: (value) => `Custom error: "${value}" is not a valid boolean.`,
});Creates a date schema. You can optionally pass options to customize error messages.
message: Can be either a constant string or a function(value) => string.
const dateSchema = s.date({
message: 'This is a constant error message.',
});
const dateSchemaFunc = s.date({
message: (value) => `Custom error: "${value}" is not a valid date.`,
});Creates a function schema that validates the value is callable.
message: Can be either a constant string or a function(value) => string.
const callbackSchema = s.function();
callbackSchema.parse(() => {}); // () => {}
callbackSchema.parse(async () => {}); // async () => {}
callbackSchema.parse('hello'); // throws
const callbackSchemaMsg = s.function({
message: 'Expected a callback function.',
});
const callbackSchemaFunc = s.function({
message: (value) => `Custom error: "${value}" is not a function.`,
});Creates an object schema with the given definition. You can optionally pass options to customize error messages.
message: Can be either a constant string or a function(value) => string.
const objectSchema = s.object(
{
key: s.string(),
value: s.number(),
},
{
message: 'This is a constant error message.',
},
);
const objectSchemaFunc = s.object(
{
key: s.string(),
value: s.number(),
},
{
message: (value) => `Custom error: "${JSON.stringify(value)}" is not a valid object.`,
},
);Creates an array schema with the given item definition. You can optionally pass options to customize error messages.
message: Can be either a constant string or a function(value) => string.
const arraySchema = s.array(s.string(), {
message: 'This is a constant error message.',
});
const arraySchemaFunc = s.array(s.string(), {
message: (value) => `Custom error: "${JSON.stringify(value)}" is not a valid array.`,
});Creates an enum schema that validates against a predefined set of string values. You can optionally pass options to customize error messages.
message: Can be either a constant string or a function(value) => string.
const enumSchema = s.enum(['admin', 'user', 'guest'], {
message: 'This is a constant error message.',
});
const enumSchemaFunc = s.enum(['admin', 'user', 'guest'], {
message: (value) => `Custom error: "${value}" is not a valid enum value.`,
});Creates a literal schema that validates against an exact value. The value can be a string, number, or boolean. This is useful for discriminated unions, API response types, and strict value validation. You can optionally pass options to customize error messages.
message: Can be either a constant string or a function(value) => string.
// String literal
const adminSchema = s.literal('admin');
adminSchema.parse('admin'); // ✅ 'admin'
adminSchema.parse('user'); // ❌ throws error
// Number literal
const statusCode = s.literal(200);
statusCode.parse(200); // ✅ 200
statusCode.parse(404); // ❌ throws error
// Boolean literal
const enabled = s.literal(true);
enabled.parse(true); // ✅ true
enabled.parse(false); // ❌ throws error
// Custom error message
const typeSchema = s.literal('success', {
message: 'Response type must be "success"',
});
// Custom error function
const versionSchema = s.literal(1, {
message: (value) => `API version must be 1, received ${value}`,
});
// Discriminated unions with literal
const responseSchema = s.union([
s.object({
type: s.literal('success'),
data: s.string(),
}),
s.object({
type: s.literal('error'),
error: s.string(),
}),
]);
// Using multiple literals in union (similar to enum but with type inference)
const roleSchema = s.union([
s.literal('admin'),
s.literal('user'),
s.literal('guest'),
]);Common Use Cases:
- Discriminated Unions: Use literal types to distinguish between different object shapes
- API Response Types: Validate exact status codes or response types
- Configuration Flags: Validate boolean flags or specific string values
- Type Guards: Create strict type validation for specific values
Creates a schema that validates against multiple schemas (a union of schemas). The value must match at least one of the provided schemas. You can optionally pass options to customize error messages.
message: Can be either a constant string or a function(value) => string.
const schema = s.union([
s.string(),
s.number(),
s.boolean(),
], {
message: 'This is a constant error message.',
});
const schemaFunc = s.union([
s.string(),
s.number(),
s.boolean(),
], {
message: (value) => `Custom error: "${value}" does not match any of the union schemas.`,
});Creates a schema that accepts any value.
const anySchema = s.any();Creates a schema that preprocesses the input value using the provided callback before validating it with the given schema.
const preprocessSchema = s.preprocess((value) => new Date(value), s.date());Requires
import '@esmj/schema/coerce'(or'@esmj/schema/full') as a side-effect. Importing only the core@esmj/schemaleavess.coerceempty — callings.coerce.string()will throw at runtime and produce a TypeScript error.
The coerce namespace applies a native JS constructor to the input before validation.
Unlike s.preprocess, you don't need to write the conversion yourself, and coerce methods
provide clear, specific error messages when coercion produces an invalid result.
| Method | Coercion applied | Fails when |
|---|---|---|
s.coerce.string(options?) |
String(v) |
Never — String() always succeeds |
s.coerce.number(options?) |
Number(v) |
Result is NaN (e.g. 'bad', undefined) |
s.coerce.boolean(options?) |
Boolean(v) |
Never — Boolean() always succeeds |
s.coerce.date(options?) |
new Date(v) |
Result is an invalid Date (e.g. 'garbage') |
Note:
Boolean('false')istruebecause'false'is a non-empty string. This matches JavaScript semantics.
s.coerce.number().parse('42'); // 42
s.coerce.number().parse(true); // 1
s.coerce.number().parse('bad'); // throws: Cannot coerce "NaN" to a valid number.
s.coerce.string().parse(123); // '123'
s.coerce.string().parse(null); // 'null'
s.coerce.boolean().parse(0); // false
s.coerce.boolean().parse('false'); // true — non-empty string!
s.coerce.date().parse('2024-01-01'); // Date object
s.coerce.date().parse('garbage'); // throws: Cannot coerce "Invalid Date" to a valid date.
// All schema methods chain normally after coerce:
s.coerce.number().refine((v) => v > 0, { message: 'Must be positive' }).parse('5'); // 5
// Custom error message:
s.coerce.number({ message: 'Expected a numeric value' }).parse('bad'); // throws: Expected a numeric valueProgrammer-friendly semantic casting. Unlike s.coerce (raw JS constructors), s.cast understands
common string representations and rejects ambiguous inputs like null, undefined, and empty strings.
| Method | Accepted inputs | Rejects |
|---|---|---|
s.cast.string(options?) |
strings, finite numbers, booleans | null, undefined, objects, NaN, Infinity |
s.cast.number(options?) |
numbers (incl. booleans true/false→1/0), trimmed numeric strings |
null, undefined, empty strings, non-numeric strings |
s.cast.boolean(options?) |
booleans, 1/0, 'true'/'false', 'yes'/'no', 'on'/'off', '1'/'0' |
null, undefined, unrecognised strings, other numbers |
s.cast.date(options?) |
Date objects, ISO strings, finite integer timestamps |
null, undefined, booleans, empty strings, invalid date strings |
s.cast.json(schema, options?) |
JSON strings (parsed), any non-string value (pass-through) | malformed JSON strings |
Key differences from s.coerce:
| Input | s.coerce.boolean() |
s.cast.boolean() |
|---|---|---|
'false' |
true (non-empty string!) |
false |
'yes' / 'no' |
true / true |
true / false |
null |
false |
throws |
| Input | s.coerce.number() |
s.cast.number() |
|---|---|---|
null |
0 |
throws |
'' |
0 |
throws |
| Input | s.coerce.string() |
s.cast.string() |
|---|---|---|
null |
'null' |
throws |
undefined |
'undefined' |
throws |
// boolean
s.cast.boolean().parse('false'); // false — unlike coerce!
s.cast.boolean().parse('yes'); // true
s.cast.boolean().parse('on'); // true
s.cast.boolean().parse('OFF'); // false (case-insensitive)
s.cast.boolean().parse(1); // true
s.cast.boolean().parse(0); // false
s.cast.boolean().parse('hello'); // throws: Cannot cast "hello" to boolean...
s.cast.boolean().parse(null); // throws
// number
s.cast.number().parse('42'); // 42
s.cast.number().parse(' 3.14 '); // 3.14 — trims whitespace
s.cast.number().parse(true); // 1
s.cast.number().parse(false); // 0
s.cast.number().parse(null); // throws: Cannot cast "null" to a number...
s.cast.number().parse(''); // throws
// string
s.cast.string().parse(123); // '123'
s.cast.string().parse(true); // 'true'
s.cast.string().parse(false); // 'false'
s.cast.string().parse(null); // throws: Cannot cast "null" to string...
s.cast.string().parse(NaN); // throws
// date
s.cast.date().parse('2024-01-01'); // Date object
s.cast.date().parse(1704067200000); // Date object
s.cast.date().parse(null); // throws: Cannot cast "null" to a valid date.
s.cast.date().parse(true); // throws
// All schema methods chain normally:
s.cast.number().refine((v) => v > 0, { message: 'Must be positive' }).parse('5'); // 5
// Custom error message:
s.cast.boolean({ message: 'Must be a boolean flag' }).parse('maybe'); // throws: Must be a boolean flag
// json
s.cast.json(s.object({ name: s.string() })).parse('{"name":"Alice"}'); // { name: 'Alice' }
s.cast.json(s.array(s.number())).parse('[1,2,3]'); // [1, 2, 3]
s.cast.json(s.object({ name: s.string() })).parse({ name: 'Alice' }); // { name: 'Alice' } — pass-through
s.cast.json(s.number()).safeParse('not json'); // { success: false, error: ... }
s.cast.json(s.number(), { message: 'Invalid JSON' }).parse('bad'); // throws: Invalid JSONParses the given value according to the schema.
const result = stringSchema.parse('hello');Safely parses the given value according to the schema, returning a success or error result.
const result = stringSchema.safeParse('hello');
// { success: true, data: 'hello' }
const errorResult = stringSchema.safeParse(123);
// { success: false, error: { message: 'The value "123" must be type of string but is type of "number".' } }
// Collect all errors (not just the first)
const allErrorsResult = stringSchema.safeParse(123, { abortEarly: false });
console.log(allErrorsResult.errors); // Array of all errorsNote: The error returned by safeParse is not a native Error instance. Instead, it is a plain object with the following structure:
type ErrorStructure = {
message: string;
cause?: {
key?: string;
};
};This allows for easier serialization and debugging but may require additional handling if you expect a native Error instance.
Makes the schema optional.
const optionalSchema = stringSchema.optional();Makes the schema nullable.
const nullableSchema = stringSchema.nullable();Makes the schema nullish (nullable and optional).
const nullishSchema = stringSchema.nullish();Sets a default value for the schema.
const defaultSchema = stringSchema.default('default value');Returns a fallback value whenever parsing fails, instead of throwing or returning an error.
Unlike default() which only fires when the input is undefined, catch() fires on any validation failure.
The fallback can be a static value or a function that receives a context object { input, error }:
input— the original raw input valueerror— theErrorStructurewith the failure message
Note: The fallback value is returned as-is without re-validation. catch() only intercepts failures from schemas and refinements placed before it in the chain.
// Static fallback
const schema = s.string().catch('unknown');
schema.parse(123); // 'unknown'
schema.parse('hello'); // 'hello'
// Function fallback with context
const schema2 = s.number().catch((ctx) => {
console.warn(`Invalid input: ${ctx.input} — ${ctx.error.message}`);
return 0;
});
schema2.parse('bad'); // 0
// Distinction from default()
s.string().catch('fallback').parse(null); // 'fallback' — catch fires for null
s.string().default('fallback').parse(null); // throws — default does not fire for nullTransforms the parsed value using the provided callback.
const transformedSchema = s.string().transform((value) => value.toUpperCase());Pipes the output of one schema into another schema for further validation or transformation.
const pipedSchema = s.string().pipe(s.number());Adds a refinement to the schema with a custom validation function and error message.
const refinedSchema = s.string().refine((val) => val.length <= 255, {
message: "String can't be more than 255 characters",
});Both parse and safeParse accept an optional second argument:
parseOptions: { abortEarly?: boolean }
abortEarly(default:true): Iftrue, validation stops at the first error (previous behavior). Iffalse, all validation errors are collected and returned in theerrorsarray.
Example:
const schema = s.object({
name: s.string(),
age: s.number(),
email: s.string()
});
// Default behavior (abortEarly: true)
const result1 = schema.safeParse({
name: 123,
age: 'not a number',
email: 42
});
console.log(result1.success); // false
console.log(result1.errors.length); // 1
// Collect all errors (abortEarly: false)
const result2 = schema.safeParse({
name: 123,
age: 'not a number',
email: 42
}, { abortEarly: false });
console.log(result2.success); // false
console.log(result2.errors.length); // 3Error Result Structure:
error: The first error encountered (for compatibility)errors: Array of all errors (whenabortEarly: false)
Note:
The abortEarly option is propagated through nested schemas, arrays, unions, and refinements.
This means you get all errors from deeply nested structures when using { abortEarly: false }.
Example Output:
{
"success": false,
"error": {
"message": "Error parsing key \"name\": The value \"123\" must be type of string but is type of \"number\".",
"cause": { "key": "name" }
},
"errors": [
{ "message": "Error parsing key \"name\": ...", "cause": { "key": "name" } },
{ "message": "Error parsing key \"age\": ...", "cause": { "key": "age" } },
{ "message": "Error parsing key \"email\": ...", "cause": { "key": "email" } }
]
}You can extend the schema system with custom validation methods. This is useful for adding domain-specific validations like email or URL formats.
import { extend, type SchemaType, type StringSchemaInterface } from '@esmj/schema';
// First, declare the new methods you want to add
declare module '@esmj/schema' {
interface StringSchemaInterface {
email(): StringSchemaInterface;
url(): StringSchemaInterface;
trim(): StringSchemaInterface;
}
}
// Define validation patterns
const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
const URL_REGEX = /^(https?:\/\/[^\s$.?#].[^\s]*)$/;
// Extend the schema system
extend((schema: SchemaType, _, options) => {
// Only add methods to string schemas
if (options?.type === 'string') {
const stringSchema = schema as StringSchemaInterface;
// Add email validation
stringSchema.email = function() {
return this.refine((value) => EMAIL_REGEX.test(value), {
message: 'Invalid email format'
});
};
// Add URL validation
stringSchema.url = function() {
return this.refine((value) => URL_REGEX.test(value), {
message: 'Invalid URL format'
});
};
// Add string trimming
stringSchema.trim = function() {
return this.transform((value) => value.trim());
};
}
return schema;
});Once extended, you can use your custom methods in schema definitions:
const userSchema = s.object({
name: s.string().trim(),
email: s.string().email(),
website: s.string().url().optional()
});
// Valid data
userSchema.parse({
name: ' John Doe ', // Will be trimmed
email: 'john@example.com'
});
// Invalid data
try {
userSchema.parse({
name: 'John Doe',
email: 'not-an-email'
});
} catch (error) {
console.error(error); // "Invalid email format"
}You can extend any schema type and add complex validations:
declare module '@esmj/schema' {
interface NumberSchemaInterface {
positive(): NumberSchemaInterface;
range(min: number, max: number): NumberSchemaInterface;
}
interface ArraySchemaInterface<T> {
minLength(length: number): ArraySchemaInterface<T>;
unique(): ArraySchemaInterface<T>;
}
}
extend((schema: SchemaType, _, options) => {
if (options?.type === 'number') {
const numberSchema = schema as NumberSchemaInterface;
numberSchema.positive = function() {
return this.refine((value) => value > 0, {
message: 'Number must be positive'
});
};
numberSchema.range = function(min, max) {
return this.refine((value) => value >= min && value <= max, {
message: `Number must be between ${min} and ${max}`
});
};
}
if (options?.type === 'array') {
const arraySchema = schema as ArraySchemaInterface<unknown>;
arraySchema.minLength = function(length) {
return this.refine((value) => value.length >= length, {
message: `Array must contain at least ${length} items`
});
};
arraySchema.unique = function() {
return this.refine((value) => {
const seen = new Set();
return value.every(item => {
const serialized = JSON.stringify(item);
if (seen.has(serialized)) return false;
seen.add(serialized);
return true;
});
}, { message: 'Array items must be unique' });
};
}
return schema;
});This extension system gives you the flexibility to create domain-specific validation rules while maintaining type safety and the fluent API style.
You can define schemas for deeply nested objects.
const nestedSchema = s.object({
user: s.object({
id: s.number(),
profile: s.object({
name: s.string(),
age: s.number().optional(),
}),
}),
});
const result = nestedSchema.parse({
user: {
id: 1,
profile: {
name: 'John Doe',
},
},
});
console.log(result);
// {
// user: {
// id: 1,
// profile: {
// name: 'John Doe',
// },
// },
// }You can validate arrays with specific item schemas.
const arraySchema = s.array(s.object({ id: s.number(), name: s.string() }));
const result = arraySchema.parse([
{ id: 1, name: 'Item 1' },
{ id: 2, name: 'Item 2' },
]);
console.log(result);
// [
// { id: 1, name: 'Item 1' },
// { id: 2, name: 'Item 2' },
// ]Use s.preprocess to transform input values before validation.
const preprocessSchema = s.preprocess(
(value) => value.trim(),
s.string().refine((val) => val.length > 0, { message: 'String cannot be empty' }),
);
const result = preprocessSchema.parse(' hello ');
console.log(result);
// 'hello'Use transform to modify the parsed value.
const transformSchema = s.string().transform((value) => value.toUpperCase());
const result = transformSchema.parse('hello');
console.log(result);
// 'HELLO'Pipe the output of one schema into another for further validation or transformation.
const pipedSchema = s.string()
.transform((value) => Number.parseInt(value))
.pipe(s.number().refine((val) => val > 0, { message: 'Number must be positive' }));
const result = pipedSchema.parse('42');
console.log(result);
// 42Add custom validation logic with refine.
const refinedSchema = s.string().refine((val) => val.startsWith('A'), {
message: 'String must start with "A"',
});
const result = refinedSchema.parse('Apple');
console.log(result);
// 'Apple'Set default values for optional fields.
const defaultSchema = s.object({
name: s.string().default('Anonymous'),
age: s.number().optional().default(18),
});
const result = defaultSchema.parse({});
console.log(result);
// { name: 'Anonymous', age: 18 }Use safeParse to handle errors gracefully.
const safeSchema = s.number();
const result = safeSchema.safeParse('not a number');
if (!result.success) {
console.error(result.error.message);
} else {
console.log(result.data);
}
// Error: The value "not a number" must be type of number but is type of "string".Combine multiple features like preprocessing, transformations, and refinements.
const combinedSchema = s.preprocess(
(value) => value.trim(),
s.string()
.transform((value) => value.toUpperCase())
.refine((val) => val.length <= 10, { message: 'String must be at most 10 characters' }),
);
const result = combinedSchema.parse(' hello ');
console.log(result);
// 'HELLO'The examples/ folder contains comprehensive, runnable examples demonstrating various use cases. See the examples README for detailed documentation.
Demonstrates the core validation features with strings, numbers, arrays, and unions:
node --experimental-strip-types examples/basic-usage.tsShows how to create custom validators for common use cases:
- Email validation with regex
- URL validation
- Age range validation
- Password strength validation
- Cross-field validation (e.g., password confirmation)
node --experimental-strip-types examples/custom-validation.tsReal-world form validation examples:
- User profile schema with nested objects
- Address validation with postal codes
- Phone number formatting and validation
- API response validation
- Complex nested structures
node --experimental-strip-types examples/advanced-forms.tsDemonstrates how to extend the library with custom methods:
- Email validation extension
- URL validation extension
- UUID validation extension
- Combining custom extensions with built-in validators
node --experimental-strip-types examples/custom-extensions.tsComplete user registration form validation with email and phone number validation:
- Username validation with pattern matching
- Email validation using custom extension
- International phone number validation
- Password strength requirements
- Password confirmation matching
- Age verification (18+)
- Terms acceptance validation
- Error collection with
abortEarly: false
node --experimental-strip-types examples/registration-form.tsTo run all examples:
# Using Node.js with experimental type stripping (built-in, no dependencies)
node --experimental-strip-types examples/basic-usage.ts
node --experimental-strip-types examples/custom-validation.ts
node --experimental-strip-types examples/advanced-forms.ts
node --experimental-strip-types examples/custom-extensions.ts
node --experimental-strip-types examples/registration-form.ts
# OR using npm scripts from examples folder
cd examples
npm install
npm run basic
npm run custom
npm run advanced
npm run extensions
npm run registration
npm run all # Run all examples
# OR using tsx (requires installation)
npm install -g tsx # If not already installed
npx tsx examples/basic-usage.ts
npx tsx examples/custom-validation.ts
npx tsx examples/advanced-forms.ts
npx tsx examples/custom-extensions.ts
npx tsx examples/registration-form.ts@esmj/schema has a similar API to Zod, making migration straightforward:
// Zod
import { z } from 'zod';
const userSchema = z.object({
name: z.string().min(3).max(50),
email: z.string().email(),
age: z.number().positive().int(),
role: z.enum(['admin', 'user']),
tags: z.array(z.string()).optional()
});
// @esmj/schema (with extensions)
import { s } from '@esmj/schema/full';
const userSchema = s.object({
name: s.string().min(3).max(50),
email: s.string(), // Note: email() validation requires custom extension
age: s.number().positive().int(),
role: s.enum(['admin', 'user']),
tags: s.array(s.string()).optional()
});Key Differences:
| Feature | Zod | @esmj/schema |
|---|---|---|
| Import | import { z } from 'zod' |
import { s } from '@esmj/schema' |
| Extensions | Built-in | Modular (/string, /number, /array, /full) |
| Bundle size | ~13 KB | ~1.4 KB (core), ~4 KB (full) |
| Email validation | .email() built-in |
Custom extension (see Extending Schemas) |
| Error format | Native Error | Plain object { success, error, errors } |
| Coerce | z.coerce.number() |
s.coerce.number() |
| Smart cast | No direct equivalent | s.cast.number() — rejects nulls, understands 'yes'/'no', etc. |
Migration Tips:
- Replace
zwithsin your imports - For string methods like
.min(),.trim(), import from@esmj/schema/fullor@esmj/schema/string - Add custom extensions for email, URL validation (see examples below)
- Update error handling to use the plain object structure
Migrating from Yup requires a few adjustments in syntax:
// Yup
import * as yup from 'yup';
const userSchema = yup.object({
name: yup.string().required().min(3).max(50),
email: yup.string().required().email(),
age: yup.number().required().positive().integer(),
website: yup.string().url().nullable(),
tags: yup.array().of(yup.string()).min(1)
});
// @esmj/schema (with extensions)
import { s } from '@esmj/schema/full';
const userSchema = s.object({
name: s.string().min(3).max(50), // Fields are required by default
email: s.string(), // Note: email() validation requires custom extension
age: s.number().positive().int(),
website: s.string().nullable(),
tags: s.array(s.string()).min(1)
});Key Differences:
| Feature | Yup | @esmj/schema |
|---|---|---|
| Required fields | .required() explicit |
Required by default |
| Optional fields | Default behavior | .optional() explicit |
| Array of type | .array().of(type) |
.array(type) |
| Integer | .integer() |
.int() |
| Email validation | .email() built-in |
Custom extension needed |
| Async validation | Supported | Not currently supported |
Migration Tips:
- Remove
.required()calls (fields are required by default) - Add
.optional()for optional fields - Change
.array().of(type)to.array(type) - Change
.integer()to.int() - Add custom extensions for email, URL validation
MIT