https://github.com/mjancarik/esmj-schema
Tiny library for simple schema runtime validation system for JavaScript/TypeScript.
https://github.com/mjancarik/esmj-schema
runtime-validation schema schema-validation typescript
Last synced: 3 months ago
JSON representation
Tiny library for simple schema runtime validation system for JavaScript/TypeScript.
- Host: GitHub
- URL: https://github.com/mjancarik/esmj-schema
- Owner: mjancarik
- License: mit
- Created: 2025-02-14T19:29:59.000Z (over 1 year ago)
- Default Branch: main
- Last Pushed: 2026-02-12T16:24:42.000Z (4 months ago)
- Last Synced: 2026-02-13T00:36:13.188Z (4 months ago)
- Topics: runtime-validation, schema, schema-validation, typescript
- Language: JavaScript
- Homepage:
- Size: 129 KB
- Stars: 2
- Watchers: 1
- Forks: 0
- Open Issues: 0
-
Metadata Files:
- Readme: README.md
- Changelog: CHANGELOG.md
- License: LICENSE
Awesome Lists containing this project
README
# Schema
This small library provides a simple schema validation system for JavaScript/TypeScript. The library has basic types with opportunities for extending.
## Table of Contents
- [Installation](#installation)
- [Quick Start](#quick-start)
- [Why Use @esmj/schema?](#why-use-esmjschema)
- [Comparison with Similar Libraries](#comparison-with-similar-libraries)
- [Usage](#usage)
- [Basic Usage](#basic-usage)
- [Modular Extensions](#modular-extensions)
- [String Extensions](#string-extensions-esmjschemastring)
- [Number Extensions](#number-extensions-esmjschemanumber)
- [Array Extensions](#array-extensions-esmjschemaarray)
- [Full Extensions](#full-extensions-esmjschemafull)
- [API Reference Summary](#api-reference-summary)
- [Schema Types](#schema-types)
- [s.coerce](#scoerce)
- [s.cast](#scast)
- [Schema Methods](#schema-methods)
- [parse](#parsevalue-parseoptions)
- [safeParse](#safeparsevalue-parseoptions)
- [Error Collection with abortEarly](#error-collection-with-abortearly-option)
- [Extending Schemas](#extending-schemas)
- [More Examples](#more-examples)
- [Examples Folder](#examples-folder)
- [Migration Guide](#migration-guide)
- [From Zod](#from-zod)
- [From Yup](#from-yup)
- [License](#license)
## Installation
```sh
npm install @esmj/schema
```
## Quick Start
Get started with `@esmj/schema` in seconds:
```typescript
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:**
```typescript
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'] }
```
## Why Use `@esmj/schema`?
`@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:
1. **TypeScript First**: Built with TypeScript in mind, it provides strong type inference—even for deeply nested and complex schemas.
2. **Extensibility**: Easily extend the library with custom logic, refinements, and preprocessors using the `extend` function.
3. **Rich Features**: Includes advanced features like preprocessing, transformations, piping, refinements, and robust error collection (`abortEarly`), which are not always available in similar libraries.
4. **Actionable Error Handling**: Collect all validation errors at once for better debugging and user experience, with clear and consistent error structures.
5. **Lightweight**: No dependencies and a small footprint make it ideal for projects where performance and simplicity are key.
6. **Customizable**: Offers fine-grained control over validation, error handling, and schema composition.
7. **Performance**: Optimized for speed, making it one of the fastest schema validation libraries available.
8. **Modular**: Import only what you need with separate string, number, and array extension modules to minimize bundle size.
### Performance Highlights
- **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 and @esmj/schema are also among the fastest for schema creation.
- **Parsing**: Parse data at up to 4 627 714 ops/s (0.22 μs latency) with @zod/mini (note: @zod/mini was observed to consume 200% CPU, while other libraries used only 100% CPU), or 3 142 587 ops/s (0.32 μs latency) with @esmj/schema. ArkType and effect/Schema also show strong parsing throughput.
- **Error Handling**: Efficiently manage errors at up to 2 428 049 ops/s (0.41 μs latency) with @esmj/schema, or 1 386 616 ops/s (0.72 μs latency) with @zod/mini.
These performance metrics make `@esmj/schema` an excellent choice for both frontend and backend applications where speed and efficiency are critical.
## Comparison with Similar Libraries
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 |
| Joi | ~40.4 KB |
| Zod@4 | ~40.8 KB |
| ArkType | ~41.8 KB |
| Effect/Schema | ~115.5 KB |
### Performance Comparison
*All benchmarks were measured on Node.js v24.1.0.*
#### Schema Creation Performance
| 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% |
#### Parsing Performance
| 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% |
#### Error Handling Performance
| 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% |
**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.
## Usage
### Basic Usage
```typescript
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;
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: [],
// }
```
## Modular Extensions
`@esmj/schema` provides modular extensions that can be imported individually or all together, allowing you to include only the validation helpers you need.
### Import Options
```typescript
// 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';
// Mix and match (side-effect imports)
import '@esmj/schema/string';
import '@esmj/schema/number';
import { s } from '@esmj/schema';
```
### Bundle Size Impact
- **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
- **Full** (`@esmj/schema/full`): ~4 KB gzipped (all extensions)
**Recommendation:** Import only the extensions you need to minimize bundle size.
### String Extensions (`@esmj/schema/string`)
String extensions provide common validation and transformation methods for string schemas.
```typescript
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 (`@esmj/schema/number`)
Number extensions provide validation methods for number schemas including range checks and type validations.
```typescript
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 (`@esmj/schema/array`)
Array extensions provide validation and transformation methods for array schemas.
```typescript
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()`
### Full Extensions (`@esmj/schema/full`)
The full version includes all string, number, and array extensions in a single import.
```typescript
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:
```typescript
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.'
})
});
```
## API Reference Summary
### Core Types
- `s.string()` - String validation
- `s.number()` - Number validation
- `s.boolean()` - Boolean validation
- `s.date()` - Date validation
- `s.object(def)` - Object validation
- `s.array(def)` - Array validation
- `s.literal(value)` - Literal value validation
- `s.enum(values)` - Enum validation
- `s.union(schemas)` - Union validation
- `s.any()` - Any type
- `s.null()` - Null type
- `s.undefined()` - Undefined type
- `s.unknown()` - Unknown type
### Modifiers
- `.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
### Coerce
- `s.coerce.string()` - Coerce any value to string, then validate
- `s.coerce.number()` - Coerce any value to number, then validate (fails for NaN)
- `s.coerce.boolean()` - Coerce any value to boolean, then validate
- `s.coerce.date()` - Coerce any value to Date, then validate (fails for invalid dates)
### Cast
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); rejects `null`/`undefined`/unrecognised strings
- `s.cast.number()` - Cast to number; trims whitespace from strings, accepts booleans (`true`→1, `false`→0); rejects `null`/`undefined`/empty strings
- `s.cast.string()` - Cast to string; accepts strings, finite numbers, and booleans; rejects `null`/`undefined`/objects/`NaN`/`Infinity`
- `s.cast.date()` - Cast to Date; accepts ISO strings, finite timestamps, and existing Dates; rejects `null`/`undefined`/booleans/empty strings
- `s.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
### Transformations
- `.transform(fn)` - Transform value
- `s.preprocess(fn, schema)` - Preprocess before validation
- `.pipe(schema)` - Pipe to another schema
- `.refine(fn, opts)` - Custom validation
### String Extensions
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
### Number Extensions
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
### Array Extensions
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
### Schema Types
#### `s.string(options?)`
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`.
```typescript
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.`,
});
```
#### `s.number(options?)`
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`.
```typescript
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.`,
});
```
#### `s.boolean(options?)`
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`.
```typescript
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.`,
});
```
#### `s.date(options?)`
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`.
```typescript
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.`,
});
```
#### `s.object(definition, options?)`
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`.
```typescript
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.`,
},
);
```
#### `s.array(definition, options?)`
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`.
```typescript
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.`,
});
```
#### `s.enum(values, options?)`
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`.
```typescript
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.`,
});
```
#### `s.literal(value, options?)`
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`.
```typescript
// 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
#### `s.union(definitions, options?)`
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`.
```typescript
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.`,
});
```
#### `s.any()`
Creates a schema that accepts any value.
```typescript
const anySchema = s.any();
```
#### `s.preprocess(callback, schema)`
Creates a schema that preprocesses the input value using the provided callback before validating it with the given schema.
```typescript
const preprocessSchema = s.preprocess((value) => new Date(value), s.date());
```
#### `s.coerce`
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')` is `true` because `'false'` is a non-empty string. This matches JavaScript semantics.
```typescript
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 value
```
#### `s.cast`
Programmer-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 |
```typescript
// 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 JSON
```
### Schema Methods
#### `parse(value, parseOptions?)`
Parses the given value according to the schema.
```typescript
const result = stringSchema.parse('hello');
```
#### `safeParse(value, parseOptions?)`
Safely parses the given value according to the schema, returning a success or error result.
```typescript
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 errors
```
**Note:** The `error` returned by `safeParse` is not a native `Error` instance. Instead, it is a plain object with the following structure:
```typescript
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.
#### `optional()`
Makes the schema optional.
```typescript
const optionalSchema = stringSchema.optional();
```
#### `nullable()`
Makes the schema nullable.
```typescript
const nullableSchema = stringSchema.nullable();
```
#### `nullish()`
Makes the schema nullish (nullable and optional).
```typescript
const nullishSchema = stringSchema.nullish();
```
#### `default(defaultValue)`
Sets a default value for the schema.
```typescript
const defaultSchema = stringSchema.default('default value');
```
#### `catch(catchValue)`
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 value
- `error` — the `ErrorStructure` with 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.
```typescript
// 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 null
```
#### `transform(callback)`
Transforms the parsed value using the provided callback.
```typescript
const transformedSchema = s.string().transform((value) => value.toUpperCase());
```
#### `pipe(schema)`
Pipes the output of one schema into another schema for further validation or transformation.
```typescript
const pipedSchema = s.string().pipe(s.number());
```
#### `refine(validation, { message })`
Adds a refinement to the schema with a custom validation function and error message.
```typescript
const refinedSchema = s.string().refine((val) => val.length <= 255, {
message: "String can't be more than 255 characters",
});
```
#### Error Collection with `abortEarly` Option
Both `parse` and `safeParse` accept an optional second argument:
`parseOptions: { abortEarly?: boolean }`
- **`abortEarly`** (default: `true`):
If `true`, validation stops at the first error (previous behavior).
If `false`, all validation errors are collected and returned in the `errors` array.
**Example:**
```typescript
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); // 3
```
**Error Result Structure:**
- `error`: The first error encountered (for compatibility)
- `errors`: Array of all errors (when `abortEarly: 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:**
```json
{
"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" } }
]
}
```
### Extending Schemas
You can extend the schema system with custom validation methods. This is useful for adding domain-specific validations like email or URL formats.
#### Basic Extension Example
```typescript
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;
});
```
#### Usage of Extended Schemas
Once extended, you can use your custom methods in schema definitions:
```typescript
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"
}
```
#### Advanced Extensions
You can extend any schema type and add complex validations:
```typescript
declare module '@esmj/schema' {
interface NumberSchemaInterface {
positive(): NumberSchemaInterface;
range(min: number, max: number): NumberSchemaInterface;
}
interface ArraySchemaInterface {
minLength(length: number): ArraySchemaInterface;
unique(): ArraySchemaInterface;
}
}
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;
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.
### More Examples
#### Nested Objects
You can define schemas for deeply nested objects.
```typescript
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',
// },
// },
// }
```
#### Arrays with Validation
You can validate arrays with specific item schemas.
```typescript
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' },
// ]
```
#### Preprocessing Values
Use `s.preprocess` to transform input values before validation.
```typescript
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'
```
#### Transforming Values
Use `transform` to modify the parsed value.
```typescript
const transformSchema = s.string().transform((value) => value.toUpperCase());
const result = transformSchema.parse('hello');
console.log(result);
// 'HELLO'
```
#### Piping Schemas
Pipe the output of one schema into another for further validation or transformation.
```typescript
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);
// 42
```
#### Refining Values
Add custom validation logic with `refine`.
```typescript
const refinedSchema = s.string().refine((val) => val.startsWith('A'), {
message: 'String must start with "A"',
});
const result = refinedSchema.parse('Apple');
console.log(result);
// 'Apple'
```
#### Default Values
Set default values for optional fields.
```typescript
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 }
```
#### Safe Parsing
Use `safeParse` to handle errors gracefully.
```typescript
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".
```
#### Combining Multiple Features
Combine multiple features like preprocessing, transformations, and refinements.
```typescript
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'
```
## Examples Folder
The `examples/` folder contains comprehensive, runnable examples demonstrating various use cases. See the [examples README](examples/README.md) for detailed documentation.
### Basic Usage (`examples/basic-usage.ts`)
Demonstrates the core validation features with strings, numbers, arrays, and unions:
```bash
node --experimental-strip-types examples/basic-usage.ts
```
### Custom Validation (`examples/custom-validation.ts`)
Shows 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)
```bash
node --experimental-strip-types examples/custom-validation.ts
```
### Advanced Forms (`examples/advanced-forms.ts`)
Real-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
```bash
node --experimental-strip-types examples/advanced-forms.ts
```
### Custom Extensions (`examples/custom-extensions.ts`)
Demonstrates how to extend the library with custom methods:
- Email validation extension
- URL validation extension
- UUID validation extension
- Combining custom extensions with built-in validators
```bash
node --experimental-strip-types examples/custom-extensions.ts
```
### Registration Form (`examples/registration-form.ts`)
Complete 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`
```bash
node --experimental-strip-types examples/registration-form.ts
```
**To run all examples:**
```bash
# 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
```
## Migration Guide
### From Zod
`@esmj/schema` has a similar API to Zod, making migration straightforward:
```typescript
// 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](#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:**
1. Replace `z` with `s` in your imports
2. For string methods like `.min()`, `.trim()`, import from `@esmj/schema/full` or `@esmj/schema/string`
3. Add custom extensions for email, URL validation (see examples below)
4. Update error handling to use the plain object structure
### From Yup
Migrating from Yup requires a few adjustments in syntax:
```typescript
// 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:**
1. Remove `.required()` calls (fields are required by default)
2. Add `.optional()` for optional fields
3. Change `.array().of(type)` to `.array(type)`
4. Change `.integer()` to `.int()`
5. Add custom extensions for email, URL validation
## License
MIT