@cleverbrush/schema 1.1.11 → 3.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1721 -104
- package/dist/builders/AnySchemaBuilder.d.ts +54 -14
- package/dist/builders/AnySchemaBuilder.js +2 -112
- package/dist/builders/AnySchemaBuilder.js.map +1 -0
- package/dist/builders/ArraySchemaBuilder.d.ts +126 -23
- package/dist/builders/ArraySchemaBuilder.js +2 -284
- package/dist/builders/ArraySchemaBuilder.js.map +1 -0
- package/dist/builders/BooleanSchemaBuilder.d.ts +109 -19
- package/dist/builders/BooleanSchemaBuilder.js +2 -150
- package/dist/builders/BooleanSchemaBuilder.js.map +1 -0
- package/dist/builders/DateSchemaBuilder.d.ts +154 -34
- package/dist/builders/DateSchemaBuilder.js +2 -433
- package/dist/builders/DateSchemaBuilder.js.map +1 -0
- package/dist/builders/ExternSchemaBuilder.d.ts +202 -0
- package/dist/builders/ExternSchemaBuilder.js +2 -0
- package/dist/builders/ExternSchemaBuilder.js.map +1 -0
- package/dist/builders/FunctionSchemaBuilder.d.ts +205 -18
- package/dist/builders/FunctionSchemaBuilder.js +2 -113
- package/dist/builders/FunctionSchemaBuilder.js.map +1 -0
- package/dist/builders/GenericSchemaBuilder.d.ts +294 -0
- package/dist/builders/LazySchemaBuilder.d.ts +169 -0
- package/dist/builders/NullSchemaBuilder.d.ts +162 -0
- package/dist/builders/NumberSchemaBuilder.d.ts +159 -31
- package/dist/builders/NumberSchemaBuilder.js +2 -386
- package/dist/builders/NumberSchemaBuilder.js.map +1 -0
- package/dist/builders/ObjectSchemaBuilder.d.ts +486 -61
- package/dist/builders/ObjectSchemaBuilder.js +2 -589
- package/dist/builders/ObjectSchemaBuilder.js.map +1 -0
- package/dist/builders/ParseStringSchemaBuilder.d.ts +204 -0
- package/dist/builders/ParseStringSchemaBuilder.js +2 -0
- package/dist/builders/ParseStringSchemaBuilder.js.map +1 -0
- package/dist/builders/PromiseSchemaBuilder.d.ts +213 -0
- package/dist/builders/PromiseSchemaBuilder.js +2 -0
- package/dist/builders/PromiseSchemaBuilder.js.map +1 -0
- package/dist/builders/PropertyValidationResult.d.ts +68 -0
- package/dist/builders/RecordSchemaBuilder.d.ts +343 -0
- package/dist/builders/RecordSchemaBuilder.js +2 -0
- package/dist/builders/RecordSchemaBuilder.js.map +1 -0
- package/dist/builders/SchemaBuilder.d.ts +907 -30
- package/dist/builders/StringSchemaBuilder.d.ts +154 -37
- package/dist/builders/StringSchemaBuilder.js +2 -414
- package/dist/builders/StringSchemaBuilder.js.map +1 -0
- package/dist/builders/TupleSchemaBuilder.d.ts +250 -0
- package/dist/builders/TupleSchemaBuilder.js +2 -0
- package/dist/builders/TupleSchemaBuilder.js.map +1 -0
- package/dist/builders/UnionSchemaBuilder.d.ts +141 -39
- package/dist/builders/UnionSchemaBuilder.js +2 -216
- package/dist/builders/UnionSchemaBuilder.js.map +1 -0
- package/dist/chunk-3JMDGYDT.js +2 -0
- package/dist/chunk-3JMDGYDT.js.map +1 -0
- package/dist/chunk-BUEVZ3KA.js +2 -0
- package/dist/chunk-BUEVZ3KA.js.map +1 -0
- package/dist/chunk-CFIJQ4GP.js +2 -0
- package/dist/chunk-CFIJQ4GP.js.map +1 -0
- package/dist/chunk-DY7J6RNN.js +2 -0
- package/dist/chunk-DY7J6RNN.js.map +1 -0
- package/dist/chunk-EIVZX4ZO.js +2 -0
- package/dist/chunk-EIVZX4ZO.js.map +1 -0
- package/dist/chunk-GXPV6UQK.js +2 -0
- package/dist/chunk-GXPV6UQK.js.map +1 -0
- package/dist/chunk-HN774HD7.js +2 -0
- package/dist/chunk-HN774HD7.js.map +1 -0
- package/dist/chunk-K6Z47OQY.js +2 -0
- package/dist/chunk-K6Z47OQY.js.map +1 -0
- package/dist/chunk-NUW3VXZV.js +2 -0
- package/dist/chunk-NUW3VXZV.js.map +1 -0
- package/dist/chunk-PHE4LIAN.js +2 -0
- package/dist/chunk-PHE4LIAN.js.map +1 -0
- package/dist/chunk-QARCEYGO.js +2 -0
- package/dist/chunk-QARCEYGO.js.map +1 -0
- package/dist/chunk-WDMJBGBD.js +2 -0
- package/dist/chunk-WDMJBGBD.js.map +1 -0
- package/dist/chunk-WQDYWDOE.js +2 -0
- package/dist/chunk-WQDYWDOE.js.map +1 -0
- package/dist/chunk-YQZHDMRF.js +2 -0
- package/dist/chunk-YQZHDMRF.js.map +1 -0
- package/dist/chunk-ZC6YBKCP.js +2 -0
- package/dist/chunk-ZC6YBKCP.js.map +1 -0
- package/dist/chunk-ZFI27R3L.js +2 -0
- package/dist/chunk-ZFI27R3L.js.map +1 -0
- package/dist/core.d.ts +28 -0
- package/dist/core.js +2 -0
- package/dist/core.js.map +1 -0
- package/dist/extension.d.ts +421 -0
- package/dist/extensions/array.d.ts +112 -0
- package/dist/extensions/enum.d.ts +190 -0
- package/dist/extensions/index.d.ts +112 -0
- package/dist/extensions/nullable.d.ts +26 -0
- package/dist/extensions/number.d.ts +228 -0
- package/dist/extensions/string.d.ts +332 -0
- package/dist/extensions/util.d.ts +45 -0
- package/dist/index.d.ts +10 -20
- package/dist/index.js +2 -19
- package/dist/index.js.map +1 -0
- package/dist/utils/transaction.d.ts +27 -4
- package/package.json +83 -7
- package/dist/builders/SchemaBuilder.js +0 -275
- package/dist/utils/transaction.js +0 -178
package/README.md
CHANGED
|
@@ -1,152 +1,1769 @@
|
|
|
1
|
-
|
|
1
|
+
# @cleverbrush/schema
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
[](https://github.com/cleverbrush/framework/actions/workflows/ci.yml)
|
|
4
|
+
[](https://standardschema.dev/)
|
|
5
|
+
<!-- bundle-badge-start -->
|
|
6
|
+
[](https://github.com/cleverbrush/framework/blob/master/libs/schema)
|
|
7
|
+
<!-- bundle-badge-end -->
|
|
8
|
+
[](../../LICENSE)
|
|
9
|
+
<!-- coverage-badge-start -->
|
|
10
|
+

|
|
11
|
+
<!-- coverage-badge-end -->
|
|
12
|
+
|
|
13
|
+
A schema definition and validation library for TypeScript — faster than Zod in 14/15 benchmarks (up to 204× faster on invalid input), 3× smaller than Zod v4, and compatible with 50+ ecosystem tools via [Standard Schema v1](https://standardschema.dev/).
|
|
14
|
+
|
|
15
|
+
Define a schema **once** and get TypeScript type inference, runtime validation, object mapping ([`@cleverbrush/mapper`](../mapper)), auto-generated React forms ([`@cleverbrush/react-form`](../react-form)), and bidirectional JSON Schema conversion ([`@cleverbrush/schema-json`](../schema-json)) — all from the same immutable, fluent API.
|
|
16
|
+
|
|
17
|
+
## Why @cleverbrush/schema?
|
|
18
|
+
|
|
19
|
+
**The problem:** In a typical TypeScript project, types and runtime validation are separate concerns. You define a `User` type in one file, then write Joi / Yup / Zod schemas (or manual `if` checks) in another. Over time these drift apart — the type says a field is required, but the validation allows it to be `undefined`. Tests pass, but production data breaks because the validation didn't match the type.
|
|
20
|
+
|
|
21
|
+
**The solution:** `@cleverbrush/schema` lets you define a schema **once** and derive both the TypeScript type (via `InferType`) and runtime validation from the same source. Because every method returns a **new builder instance** (immutability), you can safely compose and extend schemas without accidentally mutating shared definitions.
|
|
22
|
+
|
|
23
|
+
**What makes it different from Zod / Yup / Joi:**
|
|
24
|
+
|
|
25
|
+
- **PropertyDescriptors** — a runtime descriptor tree that every tool in the ecosystem can introspect. The [`@cleverbrush/mapper`](../mapper) uses it for type-safe property selectors. The [`@cleverbrush/react-form`](../react-form) uses it to auto-generate form fields with correct validation. This makes the schema library a **foundation** for an entire ecosystem — not just a standalone validation tool. No other popular schema library exposes this level of runtime metadata.
|
|
26
|
+
- **Standard Schema v1** — the `['~standard']` getter is implemented on every builder. That means your schema works as-is with tRPC, TanStack Form, React Hook Form, T3 Env, Hono, Elysia, next-safe-action, and every other [Standard Schema consumer](https://standardschema.dev/).
|
|
27
|
+
- **Extension system** — add custom methods to any builder type (`string`, `number`, `date`, …) via `defineExtension()` + `withExtensions()`. Extensions are fully typed, chainable, composable, and appear in `introspect()`. No other popular schema library offers a comparable type-safe plugin system.
|
|
28
|
+
- **Built-in extension pack** — common validators like `email()`, `url()`, `uuid()`, `ip()`, `trim()`, `positive()`, `negative()`, `nonempty()`, `unique()`, and more are included out of the box. The default import has them pre-applied; import from `@cleverbrush/schema/core` to get bare builders without extensions.
|
|
29
|
+
- **14 KB gzipped (full) — 3× smaller than Zod v4** — sub-path imports (`@cleverbrush/schema/string`, `/number`, `/object`, `/array`) drop individual builders to ~4 KB.
|
|
30
|
+
- **First-class nullable support** — `.nullable()` and `.notNullable()` are native methods on every builder. The inferred type automatically includes or excludes `null`, and `introspect()` exposes `isNullable` for runtime metadata.
|
|
31
|
+
- **JSDoc comment preservation** — JSDoc comments on schema properties carry through to the inferred TypeScript type, so IDE tooltips and autocomplete descriptions come from the schema definition itself.
|
|
32
|
+
- **Zero runtime dependencies.**
|
|
33
|
+
|
|
34
|
+
| Feature | @cleverbrush/schema | Zod | Yup | Joi |
|
|
35
|
+
| --- | --- | --- | --- | --- |
|
|
36
|
+
| TypeScript type inference | ✓ | ✓ | ~ | ✗ |
|
|
37
|
+
| [Standard Schema v1](https://standardschema.dev/) | ✓ | ✓ | ✗ | ✗ |
|
|
38
|
+
| **PropertyDescriptors** (runtime introspection) | ✓ | ✗ | ✗ | ✗ |
|
|
39
|
+
| **Type-safe extension system** | ✓ | ✗ | ✗ | ✗ |
|
|
40
|
+
| **Built-in object mapper** | ✓ | ✗ | ✗ | ✗ |
|
|
41
|
+
| **Built-in form generation** | ✓ | ✗ | ✗ | ✗ |
|
|
42
|
+
| Bidirectional JSON Schema | ✓ | ~ (output only) | ✗ | ✗ |
|
|
43
|
+
| **External schema interop** (`extern()`) | ✓ | ✗ | ✗ | ✗ |
|
|
44
|
+
| JSDoc preservation | ✓ | ✗ | ✗ | ✗ |
|
|
45
|
+
| Immutable schemas | ✓ | ✓ | ✗ | ✗ |
|
|
46
|
+
| Zero dependencies | ✓ | ✓ | ✗ | ✗ |
|
|
47
|
+
| Sync + async validation | ✓ | ✓ | ✓ | ✓ |
|
|
48
|
+
| Per-property error inspection | ✓ | ~ | ~ | ~ |
|
|
49
|
+
| Default values | ✓ | ✓ | ✓ | ✓ |
|
|
50
|
+
| Bundle size (full, gzipped) | **14 KB** | 41 KB (v4) | ~19 KB | ~26 KB |
|
|
4
51
|
|
|
5
52
|
## Installation
|
|
6
53
|
|
|
7
54
|
```bash
|
|
8
|
-
|
|
55
|
+
npm install @cleverbrush/schema
|
|
9
56
|
```
|
|
10
57
|
|
|
11
|
-
|
|
58
|
+
## Quick Start
|
|
59
|
+
|
|
60
|
+
[▶ Open in Playground](https://docs.cleverbrush.com/playground/quick-start)
|
|
12
61
|
|
|
13
62
|
```typescript
|
|
14
|
-
import {
|
|
63
|
+
import {
|
|
64
|
+
object,
|
|
65
|
+
string,
|
|
66
|
+
number,
|
|
67
|
+
boolean,
|
|
68
|
+
InferType
|
|
69
|
+
} from '@cleverbrush/schema';
|
|
15
70
|
|
|
71
|
+
// 1. Define a schema with fluent constraints
|
|
16
72
|
const UserSchema = object({
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
73
|
+
name: string().nonempty('Name is required').minLength(2, 'Name must be at least 2 characters'),
|
|
74
|
+
email: string().email('Please enter a valid email'),
|
|
75
|
+
age: number().min(0, 'Age cannot be negative').max(150).positive(),
|
|
76
|
+
isActive: boolean()
|
|
20
77
|
});
|
|
21
78
|
|
|
22
|
-
//
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
};
|
|
26
|
-
|
|
27
|
-
const { valid, object: result, errors } = await UserSchema.validate(user);
|
|
28
|
-
```
|
|
79
|
+
// 2. TypeScript type is inferred automatically — no duplication!
|
|
80
|
+
type User = InferType<typeof UserSchema>;
|
|
81
|
+
// Equivalent to: { name: string; email: string; age: number; isActive: boolean }
|
|
29
82
|
|
|
30
|
-
|
|
83
|
+
// 3. Validate data at runtime — synchronous by default
|
|
84
|
+
const result = UserSchema.validate({
|
|
85
|
+
name: 'Alice',
|
|
86
|
+
email: 'alice@example.com',
|
|
87
|
+
age: 30,
|
|
88
|
+
isActive: true
|
|
89
|
+
});
|
|
31
90
|
|
|
32
|
-
|
|
33
|
-
const
|
|
91
|
+
// Or use validateAsync() when you have async validators/preprocessors
|
|
92
|
+
// const result = await UserSchema.validateAsync({ ... });
|
|
34
93
|
|
|
35
|
-
if (valid) {
|
|
36
|
-
//
|
|
37
|
-
// And `object` has { id: number; name: string; dateOfBirth: Date; } type.
|
|
94
|
+
if (result.valid) {
|
|
95
|
+
console.log('Validated:', result.object); // typed as User
|
|
38
96
|
} else {
|
|
39
|
-
//
|
|
40
|
-
|
|
97
|
+
// For object schemas, prefer getErrorsFor() for per-property error inspection:
|
|
98
|
+
const nameErrors = result.getErrorsFor((p) => p.name);
|
|
99
|
+
console.log(nameErrors.isValid); // false
|
|
100
|
+
console.log(nameErrors.errors); // ['Name must be at least 2 characters']
|
|
101
|
+
|
|
102
|
+
// result.errors on object schemas is deprecated — use getErrorsFor() instead
|
|
103
|
+
console.log('Errors:', result.errors);
|
|
104
|
+
// Array of { message: string }
|
|
41
105
|
}
|
|
42
106
|
```
|
|
43
107
|
|
|
44
|
-
|
|
108
|
+
Type inference works in plain JavaScript too, using JSDoc:
|
|
45
109
|
|
|
46
110
|
```javascript
|
|
47
111
|
/**
|
|
48
112
|
* @type {import('@cleverbrush/schema').InferType<typeof UserSchema>}
|
|
49
113
|
*/
|
|
50
114
|
const user = {
|
|
51
|
-
//
|
|
115
|
+
// type is inferred as { name: string; email: string; age: number; isActive: boolean }
|
|
52
116
|
};
|
|
53
117
|
```
|
|
54
118
|
|
|
55
|
-
See [Documentation](https://docs.cleverbrush.com/modules/_cleverbrush_schema.html) or `docs` folder for more information.
|
|
56
|
-
|
|
57
119
|
## Schema Types
|
|
58
120
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
- `any` - any object. Similar to the `any` type in TypeScript.
|
|
62
|
-
- `string` - string value.
|
|
63
|
-
- `number` - number value.
|
|
64
|
-
- `boolean` - boolean value.
|
|
65
|
-
- `func` - function value.
|
|
66
|
-
- `object` - object schema (you can define list of properties, along with schemas for every property).
|
|
67
|
-
- `date` - defines object of JavaScript `Date` class.
|
|
68
|
-
- `array` - defines array, you can define a schema for Array emelement.
|
|
69
|
-
- `union` - allows to define unions. e.g. `string | number` types (or any combination off schema types from this list).
|
|
70
|
-
- custom schema types. You just need to inherit `SchemaBuilder` abstract class to implement your own schema type.
|
|
71
|
-
|
|
72
|
-
## What is exported from the library?
|
|
73
|
-
|
|
74
|
-
Library exports several functions used to define schemas:
|
|
75
|
-
|
|
76
|
-
- `any`
|
|
77
|
-
- `string`
|
|
78
|
-
- `number`
|
|
79
|
-
- `boolean`
|
|
80
|
-
- `func`
|
|
81
|
-
- `object`
|
|
82
|
-
- `date`
|
|
83
|
-
- `array`
|
|
84
|
-
- `union`
|
|
85
|
-
|
|
86
|
-
All these functions returns so called schema builders.
|
|
87
|
-
Schema builder classes are also exported (in case if you want to develop your own schema builder based on it):
|
|
88
|
-
|
|
89
|
-
- `AnySchemaBuilder`
|
|
90
|
-
- `ArraySchemaBuilder`
|
|
91
|
-
- `BooleanSchemaBuilder`
|
|
92
|
-
- `DateSchemaBuilder`
|
|
93
|
-
- `FunctionSchemaBuilder`
|
|
94
|
-
- `ObjectSchemaBuilder`
|
|
95
|
-
- `NumberSchemaBuilder`
|
|
96
|
-
- `SchemaBuilder` - abstract class.
|
|
97
|
-
- `StringSchemaBuilder`
|
|
98
|
-
- `UnionSchemaBuilder`
|
|
99
|
-
|
|
100
|
-
## Schema Builders Are Immutable
|
|
101
|
-
|
|
102
|
-
All schema builders listed above are immutable, which means that every call of it's methods should return a new Schema builder.
|
|
103
|
-
For example in the example below call to the `.optional()` method will not affect `UserSchema` or any other schemas using it. Instead it will return a new schema builder:
|
|
121
|
+
[▶ Open in Playground](https://docs.cleverbrush.com/playground/schema-types)
|
|
104
122
|
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
123
|
+
The following builder functions are available:
|
|
124
|
+
|
|
125
|
+
| Function | Description | Key Methods |
|
|
126
|
+
| --------------- | ------------------------------------------------- | --------------------------------------------------------------- |
|
|
127
|
+
| `any()` | Any value. Similar to TypeScript's `any` type. | `.optional()`, `.nullable()`, `.notNullable()`, `.default(value)`, `.addValidator(fn)` |
|
|
128
|
+
| `string()` | String value with constraints. | `.minLength(n)`, `.maxLength(n)`, `.matches(re)`, `.email()`, `.url()`, `.uuid()`, `.ip()`, `.trim()`, `.toLowerCase()`, `.nonempty()`, `.oneOf(...values)`, `.nullable()`, `.notNullable()`, `.default(value)` |
|
|
129
|
+
| `number()` | Numeric value with constraints. | `.min(n)`, `.max(n)`, `.integer()`, `.positive()`, `.negative()`, `.finite()`, `.multipleOf(n)`, `.coerce()`, `.oneOf(...values)`, `.nullable()`, `.notNullable()`, `.default(value)` |
|
|
130
|
+
| `boolean()` | Boolean value. | `.coerce()`, `.optional()`, `.nullable()`, `.notNullable()`, `.default(value)` |
|
|
131
|
+
| `date()` | JavaScript `Date` instance. | `.coerce()`, `.optional()`, `.nullable()`, `.notNullable()`, `.default(value)` |
|
|
132
|
+
| `func()` | Function value. Supports typed parameter and return-type schemas. | `.addParameter(schema)`, `.hasReturnType(schema)`, `.optional()`, `.nullable()`, `.notNullable()`, `.default(value)` |
|
|
133
|
+
| `promise(schema?)` | JavaScript `Promise`. Optionally typed resolved value via `promise(schema)` or `.hasResolvedType(schema)`. | `.hasResolvedType(schema)`, `.optional()`, `.nullable()`, `.notNullable()`, `.default(value)` |
|
|
134
|
+
| `nul()` | Exactly `null`. Useful in nullable unions. | `.optional()`, `.default(value)` |
|
|
135
|
+
| `object(props)` | Object with typed properties. Supports nesting. | `.validate(data)`, `.addProps({...})`, `.optional()`, `.nullable()`, `.notNullable()`, `.default(value)` |
|
|
136
|
+
| `array()` | Array with optional element schema (via `.of()`). | `.minLength(n)`, `.maxLength(n)`, `.of(schema)`, `.nonempty()`, `.unique()`, `.nullable()`, `.notNullable()`, `.default(value)` |
|
|
137
|
+
| `tuple([...schemas])` | Fixed-length array with per-position types. Each index validated against its own schema — mirrors TypeScript tuple types. | `.rest(schema)`, `.optional()`, `.nullable()`, `.notNullable()`, `.default(value)` |
|
|
138
|
+
| `record(keySchema, valSchema)` | Object with dynamic string keys. Every key must satisfy `keySchema` (a string schema) and every value must satisfy `valSchema` — mirrors TypeScript's `Record<K, V>`. | `.optional()`, `.nullable()`, `.notNullable()`, `.default(value)`, `.addValidator(fn)` |
|
|
139
|
+
| `union(schema)` | Union of schemas — e.g. `string \| number`. | `.or(schema)`, `.validate(data)`, `.optional()`, `.nullable()`, `.notNullable()`, `.default(value)` |
|
|
140
|
+
| `enumOf(...values)` | String enum — sugar for `string().oneOf(...)`. | `.optional()`, `.nullable()`, `.notNullable()`, `.default(value)` |
|
|
141
|
+
| `lazy(getter)` | Recursive/self-referential schema. The getter is called once and its result is cached. Enables tree structures, linked lists, and other recursive types. | `.resolve()`, `.optional()`, `.addValidator(fn)`, `.default(value)` |
|
|
142
|
+
| `generic(fn)` | Parameterized schema template. Call `.apply(...schemas)` with concrete schemas to obtain a fully typed concrete schema builder. TypeScript infers the result type from the template function's own generic signature. Optionally pass a `defaults` array as the first argument to enable direct validation without calling `.apply()`. | `.apply(...schemas)`, `.optional()`, `.nullable()`, `.default(value)` |
|
|
143
|
+
| `parseString(objectSchema, templateFn)` | Validates a string against a template pattern and parses it into a strongly-typed object. Property schemas handle their own coercion (e.g. `number().coerce()`). | `.optional()`, `.nullable()`, `.default(value)`, `.readonly()`, `.brand()` |
|
|
144
|
+
|
|
145
|
+
## Immutability
|
|
146
|
+
|
|
147
|
+
[▶ Open in Playground](https://docs.cleverbrush.com/playground/immutability)
|
|
148
|
+
|
|
149
|
+
All schema builders are immutable. Every method call returns a **new** schema builder instance, so existing schemas are never modified:
|
|
150
|
+
|
|
151
|
+
```typescript
|
|
152
|
+
const base = string().minLength(1);
|
|
153
|
+
const strict = base.maxLength(50); // new instance — base is unchanged
|
|
154
|
+
const loose = base.optional(); // another new instance
|
|
155
|
+
|
|
156
|
+
// base still only has minLength(1)
|
|
157
|
+
// strict has minLength(1) + maxLength(50)
|
|
158
|
+
// loose has minLength(1) + optional
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
This is especially powerful when building a library of reusable schema fragments:
|
|
162
|
+
|
|
163
|
+
```typescript
|
|
164
|
+
const Email = string().minLength(5).maxLength(255);
|
|
165
|
+
const Name = string().minLength(1).maxLength(100);
|
|
166
|
+
|
|
167
|
+
const CreateUser = object({ name: Name, email: Email });
|
|
168
|
+
const UpdateUser = object({ name: Name.optional(), email: Email.optional() });
|
|
169
|
+
// Both schemas share the same base constraints but differ in optionality
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
## Composing Schemas
|
|
173
|
+
|
|
174
|
+
[▶ Open in Playground](https://docs.cleverbrush.com/playground/composing-schemas)
|
|
175
|
+
|
|
176
|
+
Schemas can be extended with additional properties, combined with unions, or nested inside arrays and objects:
|
|
177
|
+
|
|
178
|
+
```typescript
|
|
179
|
+
import { object, string, number, array, union } from '@cleverbrush/schema';
|
|
180
|
+
|
|
181
|
+
// Extend an existing schema with new properties
|
|
182
|
+
const BaseEntity = object({
|
|
183
|
+
id: string(),
|
|
184
|
+
createdAt: string()
|
|
109
185
|
});
|
|
110
186
|
|
|
111
|
-
const
|
|
112
|
-
|
|
113
|
-
|
|
187
|
+
const UserEntity = BaseEntity.addProps({
|
|
188
|
+
name: string().minLength(2),
|
|
189
|
+
email: string().minLength(5)
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
// Nest objects inside arrays
|
|
193
|
+
const TeamSchema = object({
|
|
194
|
+
name: string().minLength(1),
|
|
195
|
+
members: array().of(UserEntity).minLength(1).maxLength(50)
|
|
114
196
|
});
|
|
197
|
+
|
|
198
|
+
// Union types
|
|
199
|
+
const IdOrEmail = union(string().minLength(1)).or(
|
|
200
|
+
string().matches(/^[^@]+@[^@]+$/)
|
|
201
|
+
);
|
|
115
202
|
```
|
|
116
203
|
|
|
117
|
-
|
|
118
|
-
which can be used to validate objects and infer their types.
|
|
204
|
+
## Generic Schemas
|
|
119
205
|
|
|
120
|
-
|
|
206
|
+
[▶ Open in Playground](https://docs.cleverbrush.com/playground/generic-basic)
|
|
121
207
|
|
|
122
|
-
|
|
208
|
+
Use `generic(fn)` to create a reusable, parameterized schema template. The template function accepts one or more schema builders as arguments and returns a concrete schema. Call `.apply(...schemas)` to instantiate the template — TypeScript infers the resulting type automatically from the function's own generic signature.
|
|
209
|
+
|
|
210
|
+
**Single type parameter** — a paginated list that works for any element type:
|
|
211
|
+
|
|
212
|
+
```typescript
|
|
213
|
+
import {
|
|
214
|
+
generic, object, array, number, string,
|
|
215
|
+
type SchemaBuilder, type InferType
|
|
216
|
+
} from '@cleverbrush/schema';
|
|
217
|
+
|
|
218
|
+
const PaginatedList = generic(
|
|
219
|
+
<T extends SchemaBuilder<any, any, any, any, any>>(itemSchema: T) =>
|
|
220
|
+
object({
|
|
221
|
+
items: array(itemSchema),
|
|
222
|
+
total: number(),
|
|
223
|
+
page: number(),
|
|
224
|
+
})
|
|
225
|
+
);
|
|
226
|
+
|
|
227
|
+
const PaginatedUsers = PaginatedList.apply(
|
|
228
|
+
object({ name: string(), age: number() })
|
|
229
|
+
);
|
|
230
|
+
|
|
231
|
+
type PaginatedUsersType = InferType<typeof PaginatedUsers>;
|
|
232
|
+
// → { items: { name: string; age: number }[]; total: number; page: number }
|
|
233
|
+
|
|
234
|
+
PaginatedUsers.validate({
|
|
235
|
+
items: [{ name: 'Alice', age: 30 }],
|
|
236
|
+
total: 1,
|
|
237
|
+
page: 1,
|
|
238
|
+
}); // { valid: true }
|
|
239
|
+
```
|
|
240
|
+
|
|
241
|
+
**Multiple type parameters** — a Result / Either type:
|
|
242
|
+
|
|
243
|
+
```typescript
|
|
244
|
+
import {
|
|
245
|
+
generic, object, boolean, string, number,
|
|
246
|
+
type SchemaBuilder, type InferType
|
|
247
|
+
} from '@cleverbrush/schema';
|
|
248
|
+
|
|
249
|
+
const Result = generic(
|
|
250
|
+
<
|
|
251
|
+
T extends SchemaBuilder<any, any, any, any, any>,
|
|
252
|
+
E extends SchemaBuilder<any, any, any, any, any>
|
|
253
|
+
>(
|
|
254
|
+
valueSchema: T,
|
|
255
|
+
errorSchema: E
|
|
256
|
+
) =>
|
|
257
|
+
object({
|
|
258
|
+
ok: boolean(),
|
|
259
|
+
value: valueSchema.optional(),
|
|
260
|
+
error: errorSchema.optional(),
|
|
261
|
+
})
|
|
262
|
+
);
|
|
263
|
+
|
|
264
|
+
const StringResult = Result.apply(string(), number());
|
|
265
|
+
// InferType → { ok: boolean; value?: string; error?: number }
|
|
266
|
+
|
|
267
|
+
StringResult.validate({ ok: true, value: 'hello' }); // valid
|
|
268
|
+
StringResult.validate({ ok: false, error: 404 }); // valid
|
|
269
|
+
```
|
|
270
|
+
|
|
271
|
+
**Default arguments** — pass a `defaults` array as the first argument so the template can be validated directly without calling `.apply()` first:
|
|
272
|
+
|
|
273
|
+
```typescript
|
|
274
|
+
import {
|
|
275
|
+
generic, object, array, number, any,
|
|
276
|
+
type SchemaBuilder
|
|
277
|
+
} from '@cleverbrush/schema';
|
|
278
|
+
|
|
279
|
+
const AnyList = generic(
|
|
280
|
+
[any()], // default for the single type parameter
|
|
281
|
+
<T extends SchemaBuilder<any, any, any, any, any>>(itemSchema: T) =>
|
|
282
|
+
object({ items: array(itemSchema), total: number() })
|
|
283
|
+
);
|
|
284
|
+
|
|
285
|
+
// Validate directly — uses the any() default
|
|
286
|
+
AnyList.validate({ items: [1, 'two', true], total: 3 }); // valid
|
|
287
|
+
|
|
288
|
+
// Or apply a stricter schema first
|
|
289
|
+
AnyList.apply(string()).validate({ items: ['a', 'b'], total: 2 }); // valid
|
|
290
|
+
```
|
|
291
|
+
|
|
292
|
+
> **Tip:** Each call to `.apply()` returns an independent schema builder. You can chain `.optional()`, `.addValidator()`, `.default(value)`, and every other fluent method on the result.
|
|
293
|
+
|
|
294
|
+
## Function Schemas
|
|
295
|
+
|
|
296
|
+
[▶ Open in Playground](https://docs.cleverbrush.com/playground/function-schema)
|
|
297
|
+
|
|
298
|
+
Use `func()` to validate that a value is a function. Two fluent methods let you annotate the expected signature — the inferred TypeScript type reflects both the parameter types and the return type:
|
|
299
|
+
|
|
300
|
+
- **`.addParameter(schema)`** — appends a parameter schema. Each call extends the inferred tuple of parameter types. The schemas are accessible at runtime via `introspect().parameters`.
|
|
301
|
+
- **`.hasReturnType(schema)`** — sets the return type schema. The inferred function type gains a concrete return type instead of `any`. Accessible via `introspect().returnType`.
|
|
302
|
+
|
|
303
|
+
```typescript
|
|
304
|
+
import { func, string, number, boolean, InferType } from '@cleverbrush/schema';
|
|
305
|
+
|
|
306
|
+
// Plain function validator — accepts any () => any
|
|
307
|
+
const anyFn = func();
|
|
308
|
+
anyFn.validate(() => 42); // { valid: true }
|
|
309
|
+
anyFn.validate('not a fn'); // { valid: false }
|
|
310
|
+
|
|
311
|
+
// Typed parameter and return type
|
|
312
|
+
const greet = func()
|
|
313
|
+
.addParameter(string()) // first param: string
|
|
314
|
+
.addParameter(number().optional()) // second param: number | undefined
|
|
315
|
+
.hasReturnType(string()); // return type: string
|
|
316
|
+
|
|
317
|
+
// InferType preserves the declared parameter types and return type.
|
|
318
|
+
// Function schemas also remain compatible with callbacks of any arity.
|
|
319
|
+
type Greet = InferType<typeof greet>;
|
|
320
|
+
// → (param0: string, param1: number | undefined, ...args: any[]) => string
|
|
321
|
+
|
|
322
|
+
// Introspect at runtime
|
|
323
|
+
const info = greet.introspect();
|
|
324
|
+
// info.parameters → [StringSchemaBuilder, NumberSchemaBuilder]
|
|
325
|
+
// info.returnType → StringSchemaBuilder
|
|
326
|
+
|
|
327
|
+
// Optional function schemas infer a union with undefined
|
|
328
|
+
const optionalHandler = func()
|
|
329
|
+
.addParameter(string())
|
|
330
|
+
.addParameter(boolean())
|
|
331
|
+
.hasReturnType(boolean())
|
|
332
|
+
.optional();
|
|
333
|
+
|
|
334
|
+
type OptionalHandler = InferType<typeof optionalHandler>;
|
|
335
|
+
// → ((param0: string, param1: boolean, ...args: any[]) => boolean) | undefined
|
|
336
|
+
```
|
|
337
|
+
|
|
338
|
+
## Constructor Schemas
|
|
339
|
+
|
|
340
|
+
[▶ Open in Playground](https://docs.cleverbrush.com/playground/constructor-schema)
|
|
341
|
+
|
|
342
|
+
Use `.addConstructor(funcSchema)` on an `object()` schema to declare one or more constructor overloads. Each call appends a `FunctionSchemaBuilder` to an accumulated list; the inferred TypeScript type becomes an intersection of all construct signatures and the plain instance type.
|
|
343
|
+
|
|
344
|
+
- **`.addConstructor(funcSchema)`** — appends a constructor overload. Each call extends the inferred tuple of constructor schemas. Chainable.
|
|
345
|
+
- **`.clearConstructors()`** — resets constructor schemas to an empty list, removing all construct signatures from the inferred type.
|
|
346
|
+
- **`introspect().constructorSchemas`** — array of all accumulated constructor `FunctionSchemaBuilder` schemas.
|
|
347
|
+
|
|
348
|
+
Constructor signatures are **type-only**: runtime `validate()` continues to validate plain objects as before.
|
|
349
|
+
|
|
350
|
+
```typescript
|
|
351
|
+
import { object, func, string, number, InferType } from '@cleverbrush/schema';
|
|
352
|
+
|
|
353
|
+
// Single constructor overload
|
|
354
|
+
const PersonSchema = object({ name: string(), age: number() })
|
|
355
|
+
.addConstructor(func().addParameter(string()));
|
|
356
|
+
|
|
357
|
+
type Person = InferType<typeof PersonSchema>;
|
|
358
|
+
// → { new(p0: string): { name: string; age: number } } & { name: string; age: number }
|
|
359
|
+
|
|
360
|
+
// Multiple chained constructors → overloaded construct signatures
|
|
361
|
+
const FlexPersonSchema = object({ name: string(), age: number() })
|
|
362
|
+
.addConstructor(func().addParameter(string()))
|
|
363
|
+
.addConstructor(func().addParameter(string()).addParameter(number()));
|
|
364
|
+
|
|
365
|
+
type FlexPerson = InferType<typeof FlexPersonSchema>;
|
|
366
|
+
// → { new(p0: string): { name: string; age: number } }
|
|
367
|
+
// & { new(p0: string, p1: number): { name: string; age: number } }
|
|
368
|
+
// & { name: string; age: number }
|
|
369
|
+
|
|
370
|
+
// Runtime validation is unchanged — plain objects still validate
|
|
371
|
+
FlexPersonSchema.validate({ name: 'Alice', age: 30 }); // { valid: true }
|
|
372
|
+
|
|
373
|
+
// Introspect constructor schemas at runtime
|
|
374
|
+
const { constructorSchemas } = FlexPersonSchema.introspect();
|
|
375
|
+
// constructorSchemas.length → 2
|
|
376
|
+
|
|
377
|
+
// Remove all constructor signatures
|
|
378
|
+
const PlainSchema = FlexPersonSchema.clearConstructors();
|
|
379
|
+
type Plain = InferType<typeof PlainSchema>;
|
|
380
|
+
// → { name: string; age: number }
|
|
381
|
+
```
|
|
382
|
+
|
|
383
|
+
## Promise Schemas
|
|
384
|
+
|
|
385
|
+
[▶ Open in Playground](https://docs.cleverbrush.com/playground/promise-schema)
|
|
386
|
+
|
|
387
|
+
Use `promise()` to validate that a value is a JavaScript `Promise`. Pass an optional schema to type the resolved value — the inferred TypeScript type becomes `Promise<T>`. You can also call `.hasResolvedType(schema)` on the returned builder to set or replace the resolved-value schema.
|
|
388
|
+
|
|
389
|
+
- **`promise(schema?)`** — factory shorthand. When `schema` is provided the inferred type is `Promise<InferType<typeof schema>>`.
|
|
390
|
+
- **`.hasResolvedType(schema)`** — sets the resolved-value schema; replaces any previously set one. Accessible via `introspect().resolvedType`.
|
|
391
|
+
|
|
392
|
+
```typescript
|
|
393
|
+
import { promise, string, number, InferType } from '@cleverbrush/schema';
|
|
394
|
+
|
|
395
|
+
// Untyped — validates that the value is any Promise
|
|
396
|
+
const anyPromise = promise();
|
|
397
|
+
anyPromise.validate(Promise.resolve(42)); // { valid: true }
|
|
398
|
+
anyPromise.validate('not a promise' as any); // { valid: false }
|
|
399
|
+
|
|
400
|
+
// Typed resolved value via factory argument
|
|
401
|
+
const stringPromise = promise(string());
|
|
402
|
+
type StringPromise = InferType<typeof stringPromise>;
|
|
403
|
+
// → Promise<string>
|
|
404
|
+
|
|
405
|
+
// Typed resolved value via fluent method
|
|
406
|
+
const numPromise = promise().hasResolvedType(number());
|
|
407
|
+
type NumPromise = InferType<typeof numPromise>;
|
|
408
|
+
// → Promise<number>
|
|
409
|
+
|
|
410
|
+
// Introspect at runtime
|
|
411
|
+
const info = numPromise.introspect();
|
|
412
|
+
// info.resolvedType → NumberSchemaBuilder
|
|
413
|
+
|
|
414
|
+
// Optional promise — undefined is also accepted
|
|
415
|
+
const optPromise = promise(string()).optional();
|
|
416
|
+
type OptPromise = InferType<typeof optPromise>;
|
|
417
|
+
// → Promise<string> | undefined
|
|
418
|
+
|
|
419
|
+
optPromise.validate(undefined as any); // { valid: true }
|
|
420
|
+
```
|
|
421
|
+
|
|
422
|
+
## Record Schemas
|
|
423
|
+
|
|
424
|
+
[▶ Open in Playground](https://docs.cleverbrush.com/playground/record-basics)
|
|
425
|
+
|
|
426
|
+
Use `record(keySchema, valueSchema)` to validate objects with **dynamic string keys** — lookup tables, i18n bundles, caches, or any `Record<string, V>` shape. Unlike `object()`, which requires a fixed set of known property names, `record()` validates objects whose keys are not known at schema-definition time.
|
|
427
|
+
|
|
428
|
+
Both the key and the value schema are enforced at runtime, and the inferred TypeScript type mirrors `Record<K, V>`.
|
|
429
|
+
|
|
430
|
+
```typescript
|
|
431
|
+
import { record, string, number, object, InferType } from '@cleverbrush/schema';
|
|
432
|
+
|
|
433
|
+
// ── Basic: string keys → number values ──────────────────────────────────────
|
|
434
|
+
const scores = record(string(), number().min(0).max(100));
|
|
435
|
+
// InferType<typeof scores> → Record<string, number>
|
|
436
|
+
|
|
437
|
+
scores.validate({ alice: 95, bob: 87 }); // { valid: true }
|
|
438
|
+
scores.validate({ alice: 95, bob: -1 }); // { valid: false } — negative score
|
|
439
|
+
|
|
440
|
+
// ── Key constraint — only locale-style keys allowed ──────────────────────────
|
|
441
|
+
const i18n = record(
|
|
442
|
+
string().matches(/^[a-z]{2}(-[A-Z]{2})?$/),
|
|
443
|
+
string().nonempty()
|
|
444
|
+
);
|
|
445
|
+
|
|
446
|
+
i18n.validate({ en: 'Hello', 'fr-FR': 'Bonjour' }); // { valid: true }
|
|
447
|
+
i18n.validate({ '123': 'oops' }); // { valid: false } — bad key
|
|
448
|
+
|
|
449
|
+
// ── Nested: values are objects ───────────────────────────────────────────────
|
|
450
|
+
const userMap = record(
|
|
451
|
+
string(),
|
|
452
|
+
object({ name: string(), age: number() })
|
|
453
|
+
);
|
|
454
|
+
// InferType<typeof userMap> → Record<string, { name: string; age: number }>
|
|
455
|
+
|
|
456
|
+
// ── Optional with factory default ────────────────────────────────────────────
|
|
457
|
+
const cache = record(string(), number()).optional().default(() => ({}));
|
|
458
|
+
|
|
459
|
+
// ── getErrorsFor(key) — rich per-key result with descriptor ────────────────────
|
|
460
|
+
const schema = record(string(), number().min(0));
|
|
461
|
+
const result = schema.validate(
|
|
462
|
+
{ a: 1, b: -2, c: -3 },
|
|
463
|
+
{ doNotStopOnFirstError: true }
|
|
464
|
+
);
|
|
465
|
+
|
|
466
|
+
if (!result.valid) {
|
|
467
|
+
// Root-level errors (e.g. 'object expected')
|
|
468
|
+
const root = result.getErrorsFor();
|
|
469
|
+
console.log(root.isValid); // false if the container itself is invalid
|
|
470
|
+
|
|
471
|
+
// Per-key errors
|
|
472
|
+
const bResult = result.getErrorsFor('b');
|
|
473
|
+
console.log(bResult.isValid); // false
|
|
474
|
+
console.log(bResult.errors[0]); // 'the value must be >= 0'
|
|
475
|
+
console.log(bResult.seenValue); // -2
|
|
476
|
+
|
|
477
|
+
// Descriptor: read/write the entry on the original object
|
|
478
|
+
const descriptor = bResult.descriptor;
|
|
479
|
+
console.log(descriptor.key); // 'b'
|
|
480
|
+
descriptor.getSchema(); // → NumberSchemaBuilder
|
|
481
|
+
descriptor.getValue(result.object); // → { success: true, value: -2 }
|
|
482
|
+
descriptor.setValue(result.object, 0); // fixes the value in-place
|
|
483
|
+
}
|
|
484
|
+
```
|
|
485
|
+
|
|
486
|
+
## Parse String Schemas
|
|
487
|
+
|
|
488
|
+
[▶ Open in Playground](https://docs.cleverbrush.com/playground/parse-string-basic)
|
|
489
|
+
|
|
490
|
+
Use `parseString(objectSchema, templateFn)` to validate a string against a template pattern and parse it into a strongly-typed object. The template expression uses a tagged-template syntax with type-safe property selectors — you get full IntelliSense in the selector lambdas.
|
|
491
|
+
|
|
492
|
+
```typescript
|
|
493
|
+
import { parseString, object, string, number, type InferType } from '@cleverbrush/schema';
|
|
494
|
+
|
|
495
|
+
const RouteSchema = parseString(
|
|
496
|
+
object({ userId: string().uuid(), id: number().coerce() }),
|
|
497
|
+
$t => $t`/orders/${t => t.id}/${t => t.userId}`
|
|
498
|
+
);
|
|
499
|
+
|
|
500
|
+
type Route = InferType<typeof RouteSchema>;
|
|
501
|
+
// { userId: string; id: number }
|
|
502
|
+
|
|
503
|
+
const result = RouteSchema.validate('/orders/42/550e8400-e29b-41d4-a716-446655440000');
|
|
504
|
+
// result.valid === true
|
|
505
|
+
// result.object === { id: 42, userId: '550e8400-...' }
|
|
506
|
+
```
|
|
507
|
+
|
|
508
|
+
**Nested objects** — navigate deep properties via `t => t.parent.child`:
|
|
509
|
+
|
|
510
|
+
```typescript
|
|
511
|
+
const schema = parseString(
|
|
512
|
+
object({
|
|
513
|
+
order: object({ id: number().coerce() }),
|
|
514
|
+
user: object({ name: string() })
|
|
515
|
+
}),
|
|
516
|
+
$t => $t`/orders/${t => t.order.id}/by/${t => t.user.name}`
|
|
517
|
+
);
|
|
518
|
+
// InferType → { order: { id: number }; user: { name: string } }
|
|
519
|
+
|
|
520
|
+
schema.validate('/orders/42/by/Alice');
|
|
521
|
+
// { valid: true, object: { order: { id: 42 }, user: { name: 'Alice' } } }
|
|
522
|
+
```
|
|
523
|
+
|
|
524
|
+
**Coercion** is the property schema's responsibility — the builder passes the raw captured substring directly to each property schema's `validate()`. Use `.coerce()` on `number()`, `boolean()`, or `date()` to convert from strings:
|
|
525
|
+
|
|
526
|
+
```typescript
|
|
527
|
+
const LogEntry = parseString(
|
|
528
|
+
object({
|
|
529
|
+
level: string(),
|
|
530
|
+
ts: date().coerce(),
|
|
531
|
+
message: string()
|
|
532
|
+
}),
|
|
533
|
+
$t => $t`[${t => t.level}] ${t => t.ts} ${t => t.message}`
|
|
534
|
+
);
|
|
535
|
+
```
|
|
536
|
+
|
|
537
|
+
Error messages include the property path for easy debugging:
|
|
538
|
+
|
|
539
|
+
```typescript
|
|
540
|
+
const result = RouteSchema.validate('/orders/abc/bad-uuid');
|
|
541
|
+
// result.errors[0].message → "id: expected an integer number"
|
|
542
|
+
```
|
|
543
|
+
|
|
544
|
+
## Coercion
|
|
545
|
+
|
|
546
|
+
The `number()`, `boolean()`, and `date()` builders each have a `.coerce()` method that adds a preprocessor to convert string values to the target type. This is especially useful with parse-string schemas where captured segments are always strings, but also works standalone for URL parameters, form inputs, or any other string source.
|
|
547
|
+
|
|
548
|
+
```typescript
|
|
549
|
+
import { number, boolean, date } from '@cleverbrush/schema';
|
|
550
|
+
|
|
551
|
+
// number().coerce() — uses Number(value)
|
|
552
|
+
number().coerce().validate('42'); // { valid: true, object: 42 }
|
|
553
|
+
number().coerce().validate('hello'); // { valid: false } — NaN fails
|
|
554
|
+
|
|
555
|
+
// boolean().coerce() — "true" → true, "false" → false
|
|
556
|
+
boolean().coerce().validate('true'); // { valid: true, object: true }
|
|
557
|
+
boolean().coerce().validate('yes'); // { valid: false } — unrecognized
|
|
558
|
+
|
|
559
|
+
// date().coerce() — new Date(value) if valid
|
|
560
|
+
date().coerce().validate('2024-01-15'); // { valid: true, object: Date }
|
|
561
|
+
date().coerce().validate('nope'); // { valid: false } — invalid date
|
|
562
|
+
```
|
|
563
|
+
|
|
564
|
+
Non-string values pass through unchanged, so `.coerce()` is safe to chain even when the input might already be the correct type. All three methods return a new immutable schema instance.
|
|
565
|
+
|
|
566
|
+
## Recursive Schemas
|
|
567
|
+
|
|
568
|
+
[▶ Open in Playground](https://docs.cleverbrush.com/playground/recursive-schemas)
|
|
569
|
+
|
|
570
|
+
Use `lazy(() => schema)` to define recursive or self-referential schemas — tree structures, comment threads, nested menus, org charts, and any other type that refers to itself.
|
|
571
|
+
|
|
572
|
+
The getter function is called **once** on first validation, and the resolved schema is cached. Every subsequent call reuses the cache.
|
|
573
|
+
|
|
574
|
+
> **TypeScript limitation:** TypeScript cannot infer recursive types automatically. You must provide an explicit type annotation on the variable holding the schema.
|
|
575
|
+
|
|
576
|
+
```typescript
|
|
577
|
+
import {
|
|
578
|
+
object,
|
|
579
|
+
string,
|
|
580
|
+
number,
|
|
581
|
+
array,
|
|
582
|
+
lazy,
|
|
583
|
+
type SchemaBuilder
|
|
584
|
+
} from '@cleverbrush/schema';
|
|
585
|
+
|
|
586
|
+
// ── Tree structure ───────────────────────────────────────────────
|
|
587
|
+
type TreeNode = { value: number; children: TreeNode[] };
|
|
588
|
+
|
|
589
|
+
// Explicit annotation required — TypeScript can't infer recursive types
|
|
590
|
+
const treeNode: SchemaBuilder<TreeNode, true> = object({
|
|
591
|
+
value: number(),
|
|
592
|
+
children: array(lazy(() => treeNode))
|
|
593
|
+
});
|
|
594
|
+
|
|
595
|
+
treeNode.validate({
|
|
596
|
+
value: 1,
|
|
597
|
+
children: [
|
|
598
|
+
{ value: 2, children: [] },
|
|
599
|
+
{ value: 3, children: [{ value: 4, children: [] }] }
|
|
600
|
+
]
|
|
601
|
+
});
|
|
602
|
+
// { valid: true, object: { value: 1, children: [...] } }
|
|
603
|
+
|
|
604
|
+
// ── Comment thread ───────────────────────────────────────────────
|
|
605
|
+
type Comment = { text: string; replies: Comment[] };
|
|
606
|
+
|
|
607
|
+
const commentSchema: SchemaBuilder<Comment, true> = object({
|
|
608
|
+
text: string(),
|
|
609
|
+
replies: array(lazy(() => commentSchema))
|
|
610
|
+
});
|
|
611
|
+
|
|
612
|
+
// ── Navigation menu with optional sub-levels ─────────────────────
|
|
613
|
+
type MenuItem = { label: string; submenu?: MenuItem[] };
|
|
614
|
+
|
|
615
|
+
const menuItem: SchemaBuilder<MenuItem, true> = object({
|
|
616
|
+
label: string(),
|
|
617
|
+
submenu: array(lazy(() => menuItem)).optional()
|
|
618
|
+
});
|
|
619
|
+
```
|
|
620
|
+
|
|
621
|
+
`lazy()` is fully compatible with `.optional()`, `.addPreprocessor()`, `.addValidator()`, and all other fluent methods. The wrapper's own preprocessors and validators run before delegating to the resolved schema.
|
|
622
|
+
|
|
623
|
+
```typescript
|
|
624
|
+
// Preprocessors and validators work on the lazy wrapper itself
|
|
625
|
+
const schema = lazy(() => string())
|
|
626
|
+
.addPreprocessor((v) => (typeof v === 'number' ? String(v) : v))
|
|
627
|
+
.addValidator((v) => ({ valid: v !== 'forbidden' }));
|
|
628
|
+
```
|
|
629
|
+
|
|
630
|
+
## Discriminated Unions
|
|
631
|
+
|
|
632
|
+
[▶ Open in Playground](https://docs.cleverbrush.com/playground/discriminated-unions)
|
|
633
|
+
|
|
634
|
+
Some libraries ship a dedicated `.discriminator()` API for tagged unions. With `@cleverbrush/schema` you don't need one — `union()` combined with **string-literal schemas** gives you the same pattern naturally, with full type inference.
|
|
635
|
+
|
|
636
|
+
Use `string('literal')` for the discriminator field. Each branch of the union gets its own object schema whose discriminator can only match one exact value. TypeScript narrows the inferred type automatically:
|
|
637
|
+
|
|
638
|
+
```typescript
|
|
639
|
+
import { object, string, number, union, type InferType } from '@cleverbrush/schema';
|
|
640
|
+
|
|
641
|
+
// Each variant has a literal "type" field acting as the discriminator
|
|
642
|
+
const Circle = object({
|
|
643
|
+
type: string('circle'),
|
|
644
|
+
radius: number().min(0)
|
|
645
|
+
});
|
|
646
|
+
|
|
647
|
+
const Rectangle = object({
|
|
648
|
+
type: string('rectangle'),
|
|
649
|
+
width: number().min(0),
|
|
650
|
+
height: number().min(0)
|
|
651
|
+
});
|
|
652
|
+
|
|
653
|
+
const Triangle = object({
|
|
654
|
+
type: string('triangle'),
|
|
655
|
+
base: number().min(0),
|
|
656
|
+
height: number().min(0)
|
|
657
|
+
});
|
|
658
|
+
|
|
659
|
+
// Combine with union() — no special .discriminator() call needed
|
|
660
|
+
const ShapeSchema = union(Circle).or(Rectangle).or(Triangle);
|
|
661
|
+
|
|
662
|
+
type Shape = InferType<typeof ShapeSchema>;
|
|
663
|
+
// Shape is automatically:
|
|
664
|
+
// | { type: 'circle'; radius: number }
|
|
665
|
+
// | { type: 'rectangle'; width: number; height: number }
|
|
666
|
+
// | { type: 'triangle'; base: number; height: number }
|
|
667
|
+
|
|
668
|
+
// Validation picks the matching branch by the literal field
|
|
669
|
+
const result = ShapeSchema.validate({ type: 'circle', radius: 5 });
|
|
670
|
+
```
|
|
671
|
+
|
|
672
|
+
### Real-World Example: Job Scheduler
|
|
673
|
+
|
|
674
|
+
The `@cleverbrush/scheduler` library uses this exact pattern to validate job schedules. The `every` field acts as the discriminator, and each variant adds its own set of allowed properties:
|
|
675
|
+
|
|
676
|
+
```typescript
|
|
677
|
+
import { object, string, number, array, date, union, type InferType } from '@cleverbrush/schema';
|
|
678
|
+
|
|
679
|
+
// Shared base with common schedule fields
|
|
680
|
+
const ScheduleBase = object({
|
|
681
|
+
interval: number().min(1).max(356),
|
|
682
|
+
hour: number().min(0).max(23).optional(),
|
|
683
|
+
minute: number().min(0).max(59).optional(),
|
|
684
|
+
startsOn: date().acceptJsonString().optional(),
|
|
685
|
+
endsOn: date().acceptJsonString().optional()
|
|
686
|
+
});
|
|
687
|
+
|
|
688
|
+
// Minute schedule — omit hour/minute (they don't apply)
|
|
689
|
+
const EveryMinute = ScheduleBase
|
|
690
|
+
.omit('hour').omit('minute')
|
|
691
|
+
.addProps({ every: string('minute') });
|
|
692
|
+
|
|
693
|
+
// Day schedule
|
|
694
|
+
const EveryDay = ScheduleBase
|
|
695
|
+
.addProps({ every: string('day') });
|
|
696
|
+
|
|
697
|
+
// Week schedule — adds dayOfWeek array
|
|
698
|
+
const EveryWeek = ScheduleBase.addProps({
|
|
699
|
+
every: string('week'),
|
|
700
|
+
dayOfWeek: array().of(number().min(1).max(7)).minLength(1).maxLength(7)
|
|
701
|
+
});
|
|
702
|
+
|
|
703
|
+
// Month schedule — adds day (number or 'last')
|
|
704
|
+
const EveryMonth = ScheduleBase.addProps({
|
|
705
|
+
every: string('month'),
|
|
706
|
+
day: union(string('last')).or(number().min(1).max(28))
|
|
707
|
+
});
|
|
708
|
+
|
|
709
|
+
// Combine all variants in a single union
|
|
710
|
+
const ScheduleSchema = union(EveryMinute)
|
|
711
|
+
.or(EveryDay)
|
|
712
|
+
.or(EveryWeek)
|
|
713
|
+
.or(EveryMonth);
|
|
714
|
+
|
|
715
|
+
type Schedule = InferType<typeof ScheduleSchema>;
|
|
716
|
+
// TypeScript infers a proper discriminated union on "every"
|
|
717
|
+
```
|
|
718
|
+
|
|
719
|
+
Because each branch uses a string literal (`string('minute')`, `string('day')`, etc.) for the `every` field, TypeScript can narrow the full union based on that single property — exactly like zod's `z.discriminatedUnion()`, but without any extra API surface.
|
|
720
|
+
|
|
721
|
+
## JSDoc Comment Preservation
|
|
722
|
+
|
|
723
|
+
When you define an object schema, JSDoc comments on properties are preserved in the inferred TypeScript type. This means your IDE tooltips, hover documentation, and autocomplete descriptions all carry through from the schema definition — no need to maintain separate documentation:
|
|
724
|
+
|
|
725
|
+
```typescript
|
|
123
726
|
const UserSchema = object({
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
.
|
|
128
|
-
.
|
|
129
|
-
|
|
130
|
-
|
|
727
|
+
/** Full display name of the user */
|
|
728
|
+
name: string().minLength(1).maxLength(200),
|
|
729
|
+
/** Contact email — must be unique across all users */
|
|
730
|
+
email: string().minLength(5),
|
|
731
|
+
/** Age in years. Must be a positive integer. */
|
|
732
|
+
age: number().min(0).max(150)
|
|
733
|
+
});
|
|
734
|
+
|
|
735
|
+
type User = InferType<typeof UserSchema>;
|
|
736
|
+
// Hovering over User.name in your IDE shows:
|
|
737
|
+
// "Full display name of the user"
|
|
738
|
+
// Hovering over User.email shows:
|
|
739
|
+
// "Contact email — must be unique across all users"
|
|
131
740
|
```
|
|
132
741
|
|
|
133
|
-
|
|
742
|
+
## Deep Partial
|
|
134
743
|
|
|
135
|
-
|
|
136
|
-
having at least two elements, but not more than 5:
|
|
744
|
+
[▶ Open in Playground](https://docs.cleverbrush.com/playground/deep-partial)
|
|
137
745
|
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
746
|
+
`.deepPartial()` recursively marks **all properties at every nesting level** as optional. It is the deep-object equivalent of a common `DeepPartial<T>` helper type in TypeScript, and is the recommended way to build PATCH API bodies or partial form state.
|
|
747
|
+
|
|
748
|
+
| Schema type | Effect |
|
|
749
|
+
|-------------|--------|
|
|
750
|
+
| `object(…).deepPartial()` | All top-level and nested object properties become optional |
|
|
751
|
+
| Nested `object(…)` inside an object | Recursed — its properties are made optional too |
|
|
752
|
+
| `array(…)`, `union(…)`, primitives | The property itself is made optional; internals are **not** modified |
|
|
753
|
+
|
|
754
|
+
```typescript
|
|
755
|
+
import { object, string, number, array, type InferType } from '@cleverbrush/schema';
|
|
756
|
+
|
|
757
|
+
const CreateUser = object({
|
|
758
|
+
name: string(),
|
|
759
|
+
address: object({
|
|
760
|
+
street: string(),
|
|
761
|
+
city: string()
|
|
762
|
+
})
|
|
763
|
+
});
|
|
764
|
+
|
|
765
|
+
const PatchUser = CreateUser.deepPartial();
|
|
766
|
+
|
|
767
|
+
type PatchUserPayload = InferType<typeof PatchUser>;
|
|
768
|
+
// {
|
|
769
|
+
// name?: string;
|
|
770
|
+
// address?: { street?: string; city?: string };
|
|
771
|
+
// }
|
|
772
|
+
|
|
773
|
+
// All three are valid:
|
|
774
|
+
PatchUser.validate({}); // { valid: true }
|
|
775
|
+
PatchUser.validate({ address: {} }); // { valid: true }
|
|
776
|
+
PatchUser.validate({ address: { city: 'Paris' } }); // { valid: true }
|
|
143
777
|
```
|
|
144
778
|
|
|
145
|
-
|
|
779
|
+
Contrast with `.partial()`, which only affects the top level:
|
|
146
780
|
|
|
147
|
-
```
|
|
148
|
-
const
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
781
|
+
```typescript
|
|
782
|
+
const ShallowPartial = CreateUser.partial();
|
|
783
|
+
// { name?: string; address?: { street: string; city: string } }
|
|
784
|
+
// ↑ still required inside
|
|
785
|
+
|
|
786
|
+
ShallowPartial.validate({ address: {} });
|
|
787
|
+
// { valid: false } — street and city are still required
|
|
788
|
+
```
|
|
789
|
+
|
|
790
|
+
Chains naturally with other modifiers:
|
|
791
|
+
|
|
792
|
+
```typescript
|
|
793
|
+
const Schema = CreateUser.deepPartial().readonly();
|
|
794
|
+
type T = InferType<typeof Schema>;
|
|
795
|
+
// Readonly<{ name?: string; address?: { street?: string; city?: string } }>
|
|
796
|
+
```
|
|
797
|
+
|
|
798
|
+
> **Note:** `.deepPartial()` recurses only into nested `object()` schemas. Array element schemas and union option schemas are not modified — `array(object({…}))` becomes an optional array but its element shape is unchanged. If you need deep-partialed array elements, apply `.deepPartial()` to the element schema before passing it to `array()`:
|
|
799
|
+
> ```typescript
|
|
800
|
+
> array(InnerSchema.deepPartial()).optional()
|
|
801
|
+
> ```
|
|
802
|
+
|
|
803
|
+
## Validation
|
|
804
|
+
|
|
805
|
+
[▶ Open in Playground](https://docs.cleverbrush.com/playground/validation-errors)
|
|
806
|
+
|
|
807
|
+
Every schema builder has two validation methods:
|
|
808
|
+
|
|
809
|
+
- **`validate(data)`** — synchronous. Returns a `ValidationResult` directly. Throws if any preprocessor, validator, or error message provider returns a Promise.
|
|
810
|
+
- **`validateAsync(data)`** — asynchronous. Returns a `Promise<ValidationResult>`. Supports async preprocessors, validators, and error message providers.
|
|
811
|
+
|
|
812
|
+
Use `validate()` by default for the best performance. Switch to `validateAsync()` only when your schema includes async operations (e.g. database lookups, API calls in validators).
|
|
813
|
+
|
|
814
|
+
```typescript
|
|
815
|
+
// Synchronous validation (default — use when all validators are sync)
|
|
816
|
+
const result = UserSchema.validate(someObject);
|
|
817
|
+
|
|
818
|
+
if (result.valid) {
|
|
819
|
+
console.log(result.object); // typed as InferType<typeof UserSchema>
|
|
820
|
+
} else {
|
|
821
|
+
// For object schemas, prefer getErrorsFor() for per-property error inspection (see below)
|
|
822
|
+
console.log(result.errors); // deprecated for object schemas — Array of { message: string }
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
// Async validation (use when validators/preprocessors are async)
|
|
826
|
+
const asyncResult = await UserSchema.validateAsync(someObject);
|
|
152
827
|
```
|
|
828
|
+
|
|
829
|
+
### Collecting All Errors
|
|
830
|
+
|
|
831
|
+
By default, validation stops at the first error. Pass `{ doNotStopOnFirstError: true }` to collect all errors at once:
|
|
832
|
+
|
|
833
|
+
```typescript
|
|
834
|
+
const result = UserSchema.validate(
|
|
835
|
+
{ name: 'A', email: '', age: -5, isActive: true },
|
|
836
|
+
{ doNotStopOnFirstError: true }
|
|
837
|
+
);
|
|
838
|
+
|
|
839
|
+
console.log(result.errors);
|
|
840
|
+
// [
|
|
841
|
+
// { message: 'Name must be at least 2 characters' },
|
|
842
|
+
// { message: 'Please enter a valid email' },
|
|
843
|
+
// { message: 'Age cannot be negative' }
|
|
844
|
+
// ]
|
|
845
|
+
```
|
|
846
|
+
|
|
847
|
+
### Custom Error Messages
|
|
848
|
+
|
|
849
|
+
[▶ Open in Playground](https://docs.cleverbrush.com/playground/custom-error-messages)
|
|
850
|
+
|
|
851
|
+
Every constraint accepts an optional error message — either a plain string or a function:
|
|
852
|
+
|
|
853
|
+
```typescript
|
|
854
|
+
const Name = string()
|
|
855
|
+
.minLength(2, 'Name is too short')
|
|
856
|
+
.maxLength(50, (seen) => `"${seen}" exceeds 50 characters`);
|
|
857
|
+
|
|
858
|
+
const Age = number()
|
|
859
|
+
.min(0, 'Age cannot be negative')
|
|
860
|
+
.max(150, 'Age seems unrealistic');
|
|
861
|
+
```
|
|
862
|
+
|
|
863
|
+
### Custom Validators
|
|
864
|
+
|
|
865
|
+
[▶ Open in Playground](https://docs.cleverbrush.com/playground/custom-validators)
|
|
866
|
+
|
|
867
|
+
Add custom synchronous or asynchronous validators to any schema:
|
|
868
|
+
|
|
869
|
+
```typescript
|
|
870
|
+
const EmailSchema = string()
|
|
871
|
+
.minLength(5, 'Email is too short')
|
|
872
|
+
.addValidator(async (value) => {
|
|
873
|
+
if (value === 'taken@example.com') {
|
|
874
|
+
return {
|
|
875
|
+
valid: false,
|
|
876
|
+
errors: [{ message: 'This email is already registered' }]
|
|
877
|
+
};
|
|
878
|
+
}
|
|
879
|
+
return { valid: true };
|
|
880
|
+
});
|
|
881
|
+
```
|
|
882
|
+
|
|
883
|
+
Object-level validators can validate cross-field constraints:
|
|
884
|
+
|
|
885
|
+
```typescript
|
|
886
|
+
const SignupSchema = object({
|
|
887
|
+
password: string().minLength(8),
|
|
888
|
+
confirmPassword: string().minLength(8)
|
|
889
|
+
}).addValidator(async (value) => {
|
|
890
|
+
if (value.password !== value.confirmPassword) {
|
|
891
|
+
return {
|
|
892
|
+
valid: false,
|
|
893
|
+
errors: [{ message: 'Passwords do not match' }]
|
|
894
|
+
};
|
|
895
|
+
}
|
|
896
|
+
return { valid: true };
|
|
897
|
+
});
|
|
898
|
+
```
|
|
899
|
+
|
|
900
|
+
### Property-Targeted Validator Errors
|
|
901
|
+
|
|
902
|
+
By default, errors returned from object-level validators are attached to the root object. You can target an error to a specific property by providing a `property` selector — the same selector used by `getErrorsFor()` and react-form's `forProperty`:
|
|
903
|
+
|
|
904
|
+
```typescript
|
|
905
|
+
const SignupSchema = object({
|
|
906
|
+
password: string().minLength(8),
|
|
907
|
+
confirmPassword: string().minLength(8)
|
|
908
|
+
}).addValidator((value) => {
|
|
909
|
+
if (value.password !== value.confirmPassword) {
|
|
910
|
+
return {
|
|
911
|
+
valid: false,
|
|
912
|
+
errors: [{
|
|
913
|
+
message: 'Passwords do not match',
|
|
914
|
+
property: (t) => t.confirmPassword
|
|
915
|
+
}]
|
|
916
|
+
};
|
|
917
|
+
}
|
|
918
|
+
return { valid: true };
|
|
919
|
+
});
|
|
920
|
+
|
|
921
|
+
const result = SignupSchema.validate(
|
|
922
|
+
{ password: 'secret1', confirmPassword: 'secret2' },
|
|
923
|
+
{ doNotStopOnFirstError: true }
|
|
924
|
+
);
|
|
925
|
+
|
|
926
|
+
// Error is routed to confirmPassword:
|
|
927
|
+
result.getErrorsFor((t) => t.confirmPassword).errors;
|
|
928
|
+
// → ['Passwords do not match']
|
|
929
|
+
|
|
930
|
+
// Other properties are unaffected:
|
|
931
|
+
result.getErrorsFor((t) => t.password).errors;
|
|
932
|
+
// → []
|
|
933
|
+
```
|
|
934
|
+
|
|
935
|
+
You can target multiple properties from a single validator by returning multiple errors with different `property` selectors. Errors without a `property` selector are attached to the root object as before.
|
|
936
|
+
|
|
937
|
+
### Per-Property Errors with `getErrorsFor()` (Recommended)
|
|
938
|
+
|
|
939
|
+
`ObjectSchemaBuilder.validate()` returns an extended result with a `getErrorsFor()` method for inspecting errors on individual properties — perfect for showing inline form errors. **This is the recommended way to inspect validation errors on object schemas** and replaces the deprecated `errors` array on `ObjectSchemaValidationResult`:
|
|
940
|
+
|
|
941
|
+
```typescript
|
|
942
|
+
const PersonSchema = object({
|
|
943
|
+
name: string().minLength(1),
|
|
944
|
+
address: object({
|
|
945
|
+
city: string(),
|
|
946
|
+
zip: number()
|
|
947
|
+
})
|
|
948
|
+
});
|
|
949
|
+
|
|
950
|
+
const result = PersonSchema.validate(person, {
|
|
951
|
+
doNotStopOnFirstError: true
|
|
952
|
+
});
|
|
953
|
+
|
|
954
|
+
if (!result.valid) {
|
|
955
|
+
// Get errors for a single property
|
|
956
|
+
const nameErrors = result.getErrorsFor((p) => p.name);
|
|
957
|
+
console.log(nameErrors.isValid); // false
|
|
958
|
+
console.log(nameErrors.errors); // ['must be at least 1 character']
|
|
959
|
+
console.log(nameErrors.seenValue); // the value that was validated
|
|
960
|
+
|
|
961
|
+
// Works with nested properties too
|
|
962
|
+
const cityErrors = result.getErrorsFor((p) => p.address.city);
|
|
963
|
+
console.log(cityErrors.errors);
|
|
964
|
+
}
|
|
965
|
+
```
|
|
966
|
+
|
|
967
|
+
## PropertyDescriptors
|
|
968
|
+
|
|
969
|
+
PropertyDescriptors are a runtime metadata tree attached to each property in an object schema. They provide type-safe access to property values, schema builders, and parent descriptors. This is what makes the entire Cleverbrush ecosystem work:
|
|
970
|
+
|
|
971
|
+
- [`@cleverbrush/mapper`](../mapper) uses them as **selectors** (like C# expression trees) to point at source and target properties type-safely.
|
|
972
|
+
- [`@cleverbrush/react-form`](../react-form) uses them to bind form fields to schema properties and read their validation constraints automatically.
|
|
973
|
+
|
|
974
|
+
```typescript
|
|
975
|
+
import {
|
|
976
|
+
object,
|
|
977
|
+
string,
|
|
978
|
+
number,
|
|
979
|
+
ObjectSchemaBuilder
|
|
980
|
+
} from '@cleverbrush/schema';
|
|
981
|
+
|
|
982
|
+
const UserSchema = object({
|
|
983
|
+
name: string().minLength(2),
|
|
984
|
+
address: object({
|
|
985
|
+
city: string(),
|
|
986
|
+
zip: number()
|
|
987
|
+
})
|
|
988
|
+
});
|
|
989
|
+
|
|
990
|
+
// Get the PropertyDescriptor tree
|
|
991
|
+
const tree = ObjectSchemaBuilder.getPropertiesFor(UserSchema);
|
|
992
|
+
|
|
993
|
+
// Use descriptors as selectors in mapper and react-form:
|
|
994
|
+
// mapper: .for((t) => t.name).from((s) => s.name)
|
|
995
|
+
// react-form: <Field selector={(t) => t.address.city} form={form} />
|
|
996
|
+
```
|
|
997
|
+
|
|
998
|
+
## Default Values
|
|
999
|
+
|
|
1000
|
+
Every schema builder supports `.default(value)`. When the input is `undefined`, the default value is used instead — and the result is still validated against the schema's constraints.
|
|
1001
|
+
|
|
1002
|
+
```typescript
|
|
1003
|
+
import { string, number, array, object, InferType } from '@cleverbrush/schema';
|
|
1004
|
+
|
|
1005
|
+
// Static default
|
|
1006
|
+
const Name = string().default('Anonymous');
|
|
1007
|
+
Name.validate(undefined); // { valid: true, object: 'Anonymous' }
|
|
1008
|
+
Name.validate('Alice'); // { valid: true, object: 'Alice' }
|
|
1009
|
+
|
|
1010
|
+
// Factory function — useful for mutable defaults
|
|
1011
|
+
const Tags = array(string()).default(() => []);
|
|
1012
|
+
|
|
1013
|
+
// Works with .optional() — removes undefined from the type
|
|
1014
|
+
const Port = number().optional().default(3000);
|
|
1015
|
+
type Port = InferType<typeof Port>; // number (not number | undefined)
|
|
1016
|
+
```
|
|
1017
|
+
|
|
1018
|
+
Use a factory function for mutable values (arrays, objects) to avoid shared references:
|
|
1019
|
+
|
|
1020
|
+
```typescript
|
|
1021
|
+
const Config = object({
|
|
1022
|
+
host: string().default('localhost'),
|
|
1023
|
+
port: number().default(8080),
|
|
1024
|
+
tags: array(string()).default(() => [])
|
|
1025
|
+
});
|
|
1026
|
+
|
|
1027
|
+
type Config = InferType<typeof Config>;
|
|
1028
|
+
// { host: string; port: number; tags: string[] }
|
|
1029
|
+
// All fields are non-optional — defaults fill in missing values
|
|
1030
|
+
```
|
|
1031
|
+
|
|
1032
|
+
Default values are exposed via `.introspect()`:
|
|
1033
|
+
|
|
1034
|
+
```typescript
|
|
1035
|
+
const schema = string().default('hello');
|
|
1036
|
+
const info = schema.introspect();
|
|
1037
|
+
console.log(info.hasDefault); // true
|
|
1038
|
+
console.log(info.defaultValue); // 'hello'
|
|
1039
|
+
```
|
|
1040
|
+
|
|
1041
|
+
## Examples
|
|
1042
|
+
|
|
1043
|
+
Attach example values to any schema with `.example(value)`. The example is typed to the schema's result type, so the compiler catches mismatches. Examples are purely metadata — validation is unaffected.
|
|
1044
|
+
|
|
1045
|
+
```typescript
|
|
1046
|
+
import { string, number, object } from '@cleverbrush/schema';
|
|
1047
|
+
|
|
1048
|
+
const Email = string().example('user@example.com');
|
|
1049
|
+
const Age = number().example(30);
|
|
1050
|
+
|
|
1051
|
+
const User = object({
|
|
1052
|
+
email: Email,
|
|
1053
|
+
age: Age,
|
|
1054
|
+
}).example({ email: 'alice@example.com', age: 25 });
|
|
1055
|
+
```
|
|
1056
|
+
|
|
1057
|
+
Examples are exposed via `.introspect()` and consumed by `@cleverbrush/schema-json` (emitted as the JSON Schema `examples` keyword) and `@cleverbrush/server-openapi` (pre-fills parameter and response schemas in generated specs).
|
|
1058
|
+
|
|
1059
|
+
```typescript
|
|
1060
|
+
Email.introspect().example; // 'user@example.com'
|
|
1061
|
+
```
|
|
1062
|
+
|
|
1063
|
+
## Catch / Fallback
|
|
1064
|
+
|
|
1065
|
+
Every schema builder supports `.catch(value)`. When validation **fails for any reason** — wrong type, constraint violation, missing required value — the fallback is returned as a successful result instead of errors.
|
|
1066
|
+
|
|
1067
|
+
Unlike `.default()`, which only fires when the input is `undefined`, `.catch()` fires on **any** validation failure.
|
|
1068
|
+
|
|
1069
|
+
```typescript
|
|
1070
|
+
import { string, number, array, object } from '@cleverbrush/schema';
|
|
1071
|
+
|
|
1072
|
+
// Static fallback
|
|
1073
|
+
const Name = string().catch('unknown');
|
|
1074
|
+
Name.validate(42); // { valid: true, object: 'unknown' }
|
|
1075
|
+
Name.validate(null); // { valid: true, object: 'unknown' }
|
|
1076
|
+
Name.validate('Alice'); // { valid: true, object: 'Alice' }
|
|
1077
|
+
|
|
1078
|
+
// Constraint violation also triggers catch
|
|
1079
|
+
const Age = number().min(0).catch(-1);
|
|
1080
|
+
Age.validate(-5); // { valid: true, object: -1 }
|
|
1081
|
+
|
|
1082
|
+
// .parse() and .parseAsync() never throw when .catch() is set
|
|
1083
|
+
Name.parse(42); // 'unknown' (no SchemaValidationError thrown)
|
|
1084
|
+
```
|
|
1085
|
+
|
|
1086
|
+
Use a factory function for mutable fallback values to avoid shared references:
|
|
1087
|
+
|
|
1088
|
+
```typescript
|
|
1089
|
+
const Tags = array(string()).catch(() => []);
|
|
1090
|
+
|
|
1091
|
+
const r1 = Tags.validate(null); // { valid: true, object: [] }
|
|
1092
|
+
const r2 = Tags.validate(null); // { valid: true, object: [] }
|
|
1093
|
+
// r1.object !== r2.object — separate array instances each time
|
|
1094
|
+
```
|
|
1095
|
+
|
|
1096
|
+
The fallback state is exposed via `.introspect()`:
|
|
1097
|
+
|
|
1098
|
+
```typescript
|
|
1099
|
+
const schema = string().catch('unknown');
|
|
1100
|
+
const info = schema.introspect();
|
|
1101
|
+
console.log(info.hasCatch); // true
|
|
1102
|
+
console.log(info.catchValue); // 'unknown'
|
|
1103
|
+
```
|
|
1104
|
+
|
|
1105
|
+
## Readonly Modifier
|
|
1106
|
+
|
|
1107
|
+
Every schema builder supports `.readonly()`. This is a **type-level-only** modifier — it marks the inferred TypeScript type as immutable, but does not alter validation behaviour or freeze the validated value at runtime.
|
|
1108
|
+
|
|
1109
|
+
| Builder | Effect on `InferType<T>` |
|
|
1110
|
+
|---------|--------------------------|
|
|
1111
|
+
| `object(…).readonly()` | `Readonly<{ … }>` — all top-level properties become `readonly` |
|
|
1112
|
+
| `array(…).readonly()` | `ReadonlyArray<T>` — no `push`, `pop`, etc. at the type level |
|
|
1113
|
+
| `string().readonly()` | `string` (identity — primitives are already immutable) |
|
|
1114
|
+
| `number().readonly()` | `number` (identity) |
|
|
1115
|
+
| `boolean().readonly()` | `boolean` (identity) |
|
|
1116
|
+
| `date().readonly()` | `Readonly<Date>` |
|
|
1117
|
+
|
|
1118
|
+
```typescript
|
|
1119
|
+
import { object, array, string, number, InferType } from '@cleverbrush/schema';
|
|
1120
|
+
|
|
1121
|
+
// Readonly object
|
|
1122
|
+
const UserSchema = object({ name: string(), age: number() }).readonly();
|
|
1123
|
+
type User = InferType<typeof UserSchema>;
|
|
1124
|
+
// Readonly<{ name: string; age: number }>
|
|
1125
|
+
|
|
1126
|
+
// Readonly array
|
|
1127
|
+
const TagsSchema = array(string()).readonly();
|
|
1128
|
+
type Tags = InferType<typeof TagsSchema>;
|
|
1129
|
+
// ReadonlyArray<string>
|
|
1130
|
+
|
|
1131
|
+
// Validation behaviour is unchanged
|
|
1132
|
+
const result = UserSchema.validate({ name: 'Alice', age: 30 });
|
|
1133
|
+
// { valid: true, object: { name: 'Alice', age: 30 } }
|
|
1134
|
+
```
|
|
1135
|
+
|
|
1136
|
+
Chains naturally with `.optional()` and `.default()`:
|
|
1137
|
+
|
|
1138
|
+
```typescript
|
|
1139
|
+
const Schema = object({ id: number() }).readonly().optional();
|
|
1140
|
+
type T = InferType<typeof Schema>;
|
|
1141
|
+
// Readonly<{ id: number }> | undefined
|
|
1142
|
+
```
|
|
1143
|
+
|
|
1144
|
+
The `isReadonly` flag is exposed via `.introspect()` for tooling:
|
|
1145
|
+
|
|
1146
|
+
```typescript
|
|
1147
|
+
const schema = object({ name: string() }).readonly();
|
|
1148
|
+
console.log(schema.introspect().isReadonly); // true
|
|
1149
|
+
```
|
|
1150
|
+
|
|
1151
|
+
> **Note:** `.readonly()` is **shallow** — only top-level object properties or the array itself are marked readonly. For deeply nested immutability consider applying `.readonly()` at each level, or use a `DeepReadonly` utility type post-validation.
|
|
1152
|
+
|
|
1153
|
+
## schemaName
|
|
1154
|
+
|
|
1155
|
+
Every schema builder supports `.schemaName(name)`. This is a **metadata-only** modifier — it attaches a component name to the schema for use by OpenAPI tooling. It has no effect on validation or type inference.
|
|
1156
|
+
|
|
1157
|
+
```typescript
|
|
1158
|
+
import { object, string, number } from '@cleverbrush/schema';
|
|
1159
|
+
|
|
1160
|
+
export const UserSchema = object({
|
|
1161
|
+
id: number(),
|
|
1162
|
+
name: string(),
|
|
1163
|
+
}).schemaName('User');
|
|
1164
|
+
|
|
1165
|
+
// Accessible at runtime
|
|
1166
|
+
UserSchema.introspect().schemaName; // 'User'
|
|
1167
|
+
```
|
|
1168
|
+
|
|
1169
|
+
Chains naturally with all other modifiers:
|
|
1170
|
+
|
|
1171
|
+
```typescript
|
|
1172
|
+
const ProductSchema = object({
|
|
1173
|
+
sku: string().nonempty(),
|
|
1174
|
+
price: number().min(0),
|
|
1175
|
+
})
|
|
1176
|
+
.schemaName('Product')
|
|
1177
|
+
.describe('A product in the catalogue');
|
|
1178
|
+
```
|
|
1179
|
+
|
|
1180
|
+
When used with [`@cleverbrush/server-openapi`](../server-openapi), any schema that carries a `schemaName` is automatically extracted into `components/schemas` and all usages in the document are replaced with `$ref` pointers — eliminating repeated inline definitions:
|
|
1181
|
+
|
|
1182
|
+
```typescript
|
|
1183
|
+
import { generateOpenApiSpec } from '@cleverbrush/server-openapi';
|
|
1184
|
+
|
|
1185
|
+
// UserSchema is emitted once under components.schemas.User
|
|
1186
|
+
// Every endpoint that references it gets: { $ref: '#/components/schemas/User' }
|
|
1187
|
+
generateOpenApiSpec({ registrations, info: { title: 'My API', version: '1.0.0' } });
|
|
1188
|
+
```
|
|
1189
|
+
|
|
1190
|
+
> **Name uniqueness:** Registering two *different* schema instances under the same name throws an error. Always export named schemas as constants and reuse the same reference everywhere.
|
|
1191
|
+
|
|
1192
|
+
| Method / Property | Signature | Notes |
|
|
1193
|
+
|---|---|---|
|
|
1194
|
+
| `.schemaName(name)` | `schemaName(name: string): this` | Returns a new builder; original is unchanged |
|
|
1195
|
+
| `.introspect().schemaName` | `string \| undefined` | The name passed to `.schemaName()`, or `undefined` |
|
|
1196
|
+
|
|
1197
|
+
## Describe
|
|
1198
|
+
|
|
1199
|
+
Every schema builder supports `.describe(text)`. This is a **metadata-only** modifier — it stores a human-readable description on the schema at runtime with no effect on validation.
|
|
1200
|
+
|
|
1201
|
+
```typescript
|
|
1202
|
+
const UserSchema = object({
|
|
1203
|
+
name: string().describe("The user's full name"),
|
|
1204
|
+
age: number().optional().describe('Age in years'),
|
|
1205
|
+
}).describe('A user object');
|
|
1206
|
+
|
|
1207
|
+
// Read the description back at runtime
|
|
1208
|
+
UserSchema.introspect().description; // 'A user object'
|
|
1209
|
+
```
|
|
1210
|
+
|
|
1211
|
+
The description is accessible via `.introspect().description` and chains naturally with all other modifiers:
|
|
1212
|
+
|
|
1213
|
+
```typescript
|
|
1214
|
+
string().describe('A name').optional().readonly()
|
|
1215
|
+
// ^ InferType is string | undefined, isReadonly: true, description: 'A name'
|
|
1216
|
+
```
|
|
1217
|
+
|
|
1218
|
+
When using `@cleverbrush/schema-json`, descriptions round-trip through JSON Schema's standard `description` field:
|
|
1219
|
+
|
|
1220
|
+
```typescript
|
|
1221
|
+
import { toJsonSchema, fromJsonSchema } from '@cleverbrush/schema-json';
|
|
1222
|
+
|
|
1223
|
+
const spec = toJsonSchema(string().describe('A name'), { $schema: false });
|
|
1224
|
+
// { type: 'string', description: 'A name' }
|
|
1225
|
+
|
|
1226
|
+
const schema = fromJsonSchema({ type: 'string', description: 'A name' } as const);
|
|
1227
|
+
schema.introspect().description; // 'A name'
|
|
1228
|
+
```
|
|
1229
|
+
|
|
1230
|
+
## Extensions
|
|
1231
|
+
|
|
1232
|
+
[▶ Open in Playground](https://docs.cleverbrush.com/playground/custom-extensions)
|
|
1233
|
+
|
|
1234
|
+
The extension system lets you add **custom methods** to any schema builder type without modifying the core library. Define an extension once, apply it with `withExtensions()`, and every builder produced by the returned factories includes your new methods — fully typed and chainable.
|
|
1235
|
+
|
|
1236
|
+
### Defining an Extension
|
|
1237
|
+
|
|
1238
|
+
Use `defineExtension()` to declare which builder types your extension targets and what methods it adds. Extension methods receive `this` bound to the builder instance and must return a builder to support fluent chaining:
|
|
1239
|
+
|
|
1240
|
+
```typescript
|
|
1241
|
+
import {
|
|
1242
|
+
defineExtension,
|
|
1243
|
+
withExtensions,
|
|
1244
|
+
StringSchemaBuilder,
|
|
1245
|
+
NumberSchemaBuilder,
|
|
1246
|
+
DateSchemaBuilder
|
|
1247
|
+
} from '@cleverbrush/schema';
|
|
1248
|
+
|
|
1249
|
+
// Email extension — adds .email() to string builders
|
|
1250
|
+
const emailExt = defineExtension({
|
|
1251
|
+
string: {
|
|
1252
|
+
email(this: StringSchemaBuilder) {
|
|
1253
|
+
return this.addValidator((val) => {
|
|
1254
|
+
const valid = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(val as string);
|
|
1255
|
+
return {
|
|
1256
|
+
valid,
|
|
1257
|
+
errors: valid
|
|
1258
|
+
? []
|
|
1259
|
+
: [{ message: 'Invalid email address' }]
|
|
1260
|
+
};
|
|
1261
|
+
});
|
|
1262
|
+
}
|
|
1263
|
+
}
|
|
1264
|
+
});
|
|
1265
|
+
|
|
1266
|
+
// Port extension — adds .port() to number builders
|
|
1267
|
+
const portExt = defineExtension({
|
|
1268
|
+
number: {
|
|
1269
|
+
port(this: NumberSchemaBuilder) {
|
|
1270
|
+
return this.isInteger().min(1).max(65535);
|
|
1271
|
+
}
|
|
1272
|
+
}
|
|
1273
|
+
});
|
|
1274
|
+
|
|
1275
|
+
// Slug extension — adds .slug() to string builders
|
|
1276
|
+
const slugExt = defineExtension({
|
|
1277
|
+
string: {
|
|
1278
|
+
slug(this: StringSchemaBuilder) {
|
|
1279
|
+
return this.addValidator((val) => {
|
|
1280
|
+
const valid = /^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(val as string);
|
|
1281
|
+
return {
|
|
1282
|
+
valid,
|
|
1283
|
+
errors: valid
|
|
1284
|
+
? []
|
|
1285
|
+
: [{ message: 'Must be a valid URL slug' }]
|
|
1286
|
+
};
|
|
1287
|
+
});
|
|
1288
|
+
}
|
|
1289
|
+
}
|
|
1290
|
+
});
|
|
1291
|
+
```
|
|
1292
|
+
|
|
1293
|
+
### Using Extensions
|
|
1294
|
+
|
|
1295
|
+
Pass one or more extension descriptors to `withExtensions()` to get augmented factory functions. All original builder methods remain available and fully chainable alongside the new ones:
|
|
1296
|
+
|
|
1297
|
+
```typescript
|
|
1298
|
+
const s = withExtensions(emailExt, portExt, slugExt);
|
|
1299
|
+
|
|
1300
|
+
// .email() and .slug() are now available on string builders
|
|
1301
|
+
const EmailSchema = s.string().email().minLength(5);
|
|
1302
|
+
const SlugSchema = s.string().slug().minLength(1).maxLength(200);
|
|
1303
|
+
|
|
1304
|
+
// .port() is now available on number builders
|
|
1305
|
+
const PortSchema = s.number().port();
|
|
1306
|
+
|
|
1307
|
+
// Use in object schemas — just like normal builders
|
|
1308
|
+
const ServerConfig = s.object({
|
|
1309
|
+
adminEmail: s.string().email(),
|
|
1310
|
+
port: s.number().port(),
|
|
1311
|
+
slug: s.string().slug(),
|
|
1312
|
+
name: s.string().minLength(1)
|
|
1313
|
+
});
|
|
1314
|
+
|
|
1315
|
+
// Validate as usual
|
|
1316
|
+
const result = ServerConfig.validate({
|
|
1317
|
+
adminEmail: 'admin@example.com',
|
|
1318
|
+
port: 8080,
|
|
1319
|
+
slug: 'my-server',
|
|
1320
|
+
name: 'Production'
|
|
1321
|
+
});
|
|
1322
|
+
```
|
|
1323
|
+
|
|
1324
|
+
### Multi-Builder Extensions
|
|
1325
|
+
|
|
1326
|
+
A single extension can target multiple builder types:
|
|
1327
|
+
|
|
1328
|
+
```typescript
|
|
1329
|
+
const timestampsExt = defineExtension({
|
|
1330
|
+
string: {
|
|
1331
|
+
/** Marks this string property as an ISO timestamp */
|
|
1332
|
+
isoTimestamp(this: StringSchemaBuilder) {
|
|
1333
|
+
return this.addValidator((val) => {
|
|
1334
|
+
const valid = !isNaN(Date.parse(val as string));
|
|
1335
|
+
return {
|
|
1336
|
+
valid,
|
|
1337
|
+
errors: valid
|
|
1338
|
+
? []
|
|
1339
|
+
: [{ message: 'Must be a valid ISO timestamp' }]
|
|
1340
|
+
};
|
|
1341
|
+
});
|
|
1342
|
+
}
|
|
1343
|
+
},
|
|
1344
|
+
date: {
|
|
1345
|
+
/** Adds a validator that rejects dates in the future */
|
|
1346
|
+
pastOnly(this: DateSchemaBuilder) {
|
|
1347
|
+
return this.addValidator((val) => {
|
|
1348
|
+
const valid = (val as Date) <= new Date();
|
|
1349
|
+
return {
|
|
1350
|
+
valid,
|
|
1351
|
+
errors: valid ? [] : [{ message: 'Date must be in the past' }]
|
|
1352
|
+
};
|
|
1353
|
+
});
|
|
1354
|
+
}
|
|
1355
|
+
}
|
|
1356
|
+
});
|
|
1357
|
+
```
|
|
1358
|
+
|
|
1359
|
+
### Extension Metadata & Introspection
|
|
1360
|
+
|
|
1361
|
+
Extension methods automatically record metadata that can be inspected at runtime via `.introspect().extensions`. The system auto-infers the metadata value based on the arguments passed to the extension method:
|
|
1362
|
+
|
|
1363
|
+
- **Zero-arg methods** → metadata value is `true`
|
|
1364
|
+
- **Single-arg methods** → metadata value is the argument itself
|
|
1365
|
+
- **Multi-arg methods** → metadata value is the arguments array
|
|
1366
|
+
|
|
1367
|
+
```typescript
|
|
1368
|
+
const s = withExtensions(emailExt, portExt);
|
|
1369
|
+
|
|
1370
|
+
// Zero-arg method — metadata is `true`
|
|
1371
|
+
const emailSchema = s.string().email();
|
|
1372
|
+
console.log(emailSchema.introspect().extensions.email); // true
|
|
1373
|
+
|
|
1374
|
+
// Single-arg method — metadata is the argument
|
|
1375
|
+
const rangeExt = defineExtension({
|
|
1376
|
+
number: {
|
|
1377
|
+
percentage(this: NumberSchemaBuilder) {
|
|
1378
|
+
return this.min(0).max(100);
|
|
1379
|
+
}
|
|
1380
|
+
}
|
|
1381
|
+
});
|
|
1382
|
+
const s2 = withExtensions(rangeExt);
|
|
1383
|
+
const pctSchema = s2.number().percentage();
|
|
1384
|
+
console.log(pctSchema.introspect().extensions.percentage); // true
|
|
1385
|
+
|
|
1386
|
+
// Multi-arg method — metadata is the arguments array
|
|
1387
|
+
const rangeExt2 = defineExtension({
|
|
1388
|
+
number: {
|
|
1389
|
+
range(this: NumberSchemaBuilder, min: number, max: number) {
|
|
1390
|
+
return this.min(min).max(max);
|
|
1391
|
+
}
|
|
1392
|
+
}
|
|
1393
|
+
});
|
|
1394
|
+
const s3 = withExtensions(rangeExt2);
|
|
1395
|
+
const rangeSchema = s3.number().range(0, 100);
|
|
1396
|
+
console.log(rangeSchema.introspect().extensions.range); // [0, 100]
|
|
1397
|
+
```
|
|
1398
|
+
|
|
1399
|
+
### Custom Metadata
|
|
1400
|
+
|
|
1401
|
+
If you need structured metadata (e.g. an object with named fields rather than the raw arguments), call `this.withExtension(key, value)` explicitly inside the method. The auto-infer logic detects the existing key and skips automatic attachment:
|
|
1402
|
+
|
|
1403
|
+
```typescript
|
|
1404
|
+
const currencyExt = defineExtension({
|
|
1405
|
+
number: {
|
|
1406
|
+
currency(this: NumberSchemaBuilder, opts?: { maxDecimals?: number }) {
|
|
1407
|
+
const maxDec = opts?.maxDecimals ?? 2;
|
|
1408
|
+
// Explicit withExtension() call — auto-infer is skipped
|
|
1409
|
+
return this
|
|
1410
|
+
.withExtension('currency', { maxDecimals: maxDec })
|
|
1411
|
+
.min(0)
|
|
1412
|
+
.addValidator((val) => {
|
|
1413
|
+
const decimals = (String(val).split('.')[1] ?? '').length;
|
|
1414
|
+
const valid = decimals <= maxDec;
|
|
1415
|
+
return {
|
|
1416
|
+
valid,
|
|
1417
|
+
errors: valid
|
|
1418
|
+
? []
|
|
1419
|
+
: [{ message: `Max ${maxDec} decimal places` }]
|
|
1420
|
+
};
|
|
1421
|
+
});
|
|
1422
|
+
}
|
|
1423
|
+
}
|
|
1424
|
+
});
|
|
1425
|
+
|
|
1426
|
+
const s = withExtensions(currencyExt);
|
|
1427
|
+
const priceSchema = s.number().currency({ maxDecimals: 4 });
|
|
1428
|
+
console.log(priceSchema.introspect().extensions.currency);
|
|
1429
|
+
// { maxDecimals: 4 } — structured metadata, not the raw args
|
|
1430
|
+
```
|
|
1431
|
+
|
|
1432
|
+
### Stacking Extensions
|
|
1433
|
+
|
|
1434
|
+
Multiple extensions can be stacked — their methods are merged per builder type. A runtime error is thrown if two extensions define the same method name on the same builder type:
|
|
1435
|
+
|
|
1436
|
+
```typescript
|
|
1437
|
+
// All three extensions target StringSchemaBuilder
|
|
1438
|
+
const s = withExtensions(emailExt, slugExt, trimmedExt);
|
|
1439
|
+
|
|
1440
|
+
// All methods are available and chainable
|
|
1441
|
+
const schema = s.string().email().slug().trimmed().minLength(5);
|
|
1442
|
+
```
|
|
1443
|
+
|
|
1444
|
+
### Validation
|
|
1445
|
+
|
|
1446
|
+
`defineExtension()` validates the configuration eagerly:
|
|
1447
|
+
|
|
1448
|
+
- **Unknown builder type names** throw immediately (e.g. `{ str: { ... } }` instead of `{ string: { ... } }`)
|
|
1449
|
+
- **Reserved method names** cannot be overridden — `validate`, `introspect`, `optional`, `required`, `addValidator`, `addPreprocessor`, `withExtension`, `getExtension`, etc.
|
|
1450
|
+
- **Non-function values** in the method record are rejected
|
|
1451
|
+
|
|
1452
|
+
```typescript
|
|
1453
|
+
// ❌ Throws: Unknown builder type "str"
|
|
1454
|
+
defineExtension({ str: { foo() { return this; } } });
|
|
1455
|
+
|
|
1456
|
+
// ❌ Throws: Cannot override reserved method "validate"
|
|
1457
|
+
defineExtension({ string: { validate() { return this; } } });
|
|
1458
|
+
```
|
|
1459
|
+
|
|
1460
|
+
### Extension API Reference
|
|
1461
|
+
|
|
1462
|
+
| Function / Type | Description |
|
|
1463
|
+
| ------------------------------ | -------------------------------------------------------------------------------------------------------- |
|
|
1464
|
+
| `defineExtension(config)` | Defines an extension. `config` is an `ExtensionConfig` keyed by builder type name. Returns an `ExtensionDescriptor`. |
|
|
1465
|
+
| `withExtensions(...exts)` | Accepts one or more `ExtensionDescriptor`s. Returns an object with augmented factory functions (`string`, `number`, `boolean`, `date`, `object`, `array`, `union`, `func`, `any`). |
|
|
1466
|
+
| `ExtensionConfig` | Type for the configuration object passed to `defineExtension`. Maps builder type names to method records. |
|
|
1467
|
+
| `ExtensionDescriptor` | Branded type returned by `defineExtension`. Pass to `withExtensions()` to apply. |
|
|
1468
|
+
|
|
1469
|
+
## Built-in Extensions
|
|
1470
|
+
|
|
1471
|
+
[▶ Open in Playground](https://docs.cleverbrush.com/playground/builtin-extensions)
|
|
1472
|
+
|
|
1473
|
+
The default import from `@cleverbrush/schema` includes a pre-applied extension pack with common validators. You get these methods automatically — no extra setup required:
|
|
1474
|
+
|
|
1475
|
+
### String Extensions
|
|
1476
|
+
|
|
1477
|
+
| Method | Description | Metadata |
|
|
1478
|
+
| --- | --- | --- |
|
|
1479
|
+
| `.email(errorMessage?)` | Validates email format | `true` |
|
|
1480
|
+
| `.url(opts?, errorMessage?)` | Validates URL format. `opts.protocols` narrows allowed schemes (default: `http`, `https`) | `true` or `{ protocols }` |
|
|
1481
|
+
| `.uuid(errorMessage?)` | Validates RFC 4122 UUID format (versions 1–5) | `true` |
|
|
1482
|
+
| `.ip(opts?, errorMessage?)` | Validates IPv4 or IPv6 address. `opts.version` narrows to `'v4'` or `'v6'` | `true` or `{ version }` |
|
|
1483
|
+
| `.trim()` | Preprocessor — trims whitespace before validation | `true` |
|
|
1484
|
+
| `.toLowerCase()` | Preprocessor — lowercases value before validation | `true` |
|
|
1485
|
+
| `.nonempty(errorMessage?)` | Rejects empty strings | `true` |
|
|
1486
|
+
|
|
1487
|
+
### Number Extensions
|
|
1488
|
+
|
|
1489
|
+
| Method | Description | Metadata |
|
|
1490
|
+
| --- | --- | --- |
|
|
1491
|
+
| `.positive(errorMessage?)` | Value must be > 0 | `true` |
|
|
1492
|
+
| `.negative(errorMessage?)` | Value must be < 0 | `true` |
|
|
1493
|
+
| `.finite(errorMessage?)` | Value must be finite (not `Infinity` / `-Infinity`) | `true` |
|
|
1494
|
+
| `.multipleOf(n, errorMessage?)` | Value must be an exact multiple of `n` (float-safe) | `n` |
|
|
1495
|
+
|
|
1496
|
+
### Array Extensions
|
|
1497
|
+
|
|
1498
|
+
| Method | Description | Metadata |
|
|
1499
|
+
| --- | --- | --- |
|
|
1500
|
+
| `.nonempty(errorMessage?)` | Array must have at least one element | `true` |
|
|
1501
|
+
| `.unique(keyFn?, errorMessage?)` | All elements must be unique. Optional `keyFn` extracts comparison key for objects | `true` or `keyFn` |
|
|
1502
|
+
|
|
1503
|
+
### Nullable / Not Nullable
|
|
1504
|
+
|
|
1505
|
+
`.nullable()` and `.notNullable()` are **native methods** available on every builder — no extension required.
|
|
1506
|
+
|
|
1507
|
+
| Method | Available on | Description |
|
|
1508
|
+
| --- | --- | --- |
|
|
1509
|
+
| `.nullable()` | all builders | Marks the schema as nullable — `null` is accepted as a valid value. The inferred type changes from `T` to `T \| null`. |
|
|
1510
|
+
| `.notNullable()` | all builders | Removes the nullable mark — `null` is no longer accepted. The inferred type changes from `T \| null` back to `T`. |
|
|
1511
|
+
|
|
1512
|
+
```typescript
|
|
1513
|
+
import { string, number, object, InferType } from '@cleverbrush/schema';
|
|
1514
|
+
|
|
1515
|
+
const name = string().nullable();
|
|
1516
|
+
type Name = InferType<typeof name>; // string | null
|
|
1517
|
+
|
|
1518
|
+
// Works with any builder
|
|
1519
|
+
const score = number().positive().nullable(); // number | null
|
|
1520
|
+
|
|
1521
|
+
// Chaining: validators before .nullable()
|
|
1522
|
+
const email = string().email().nullable(); // string | null
|
|
1523
|
+
|
|
1524
|
+
// Optional + nullable: accepts string | null | undefined
|
|
1525
|
+
const bio = string().optional().nullable();
|
|
1526
|
+
|
|
1527
|
+
// Nested inside objects
|
|
1528
|
+
const User = object({
|
|
1529
|
+
name: string().nonempty(),
|
|
1530
|
+
bio: string().nullable(), // string | null
|
|
1531
|
+
age: number().nullable(), // number | null
|
|
1532
|
+
});
|
|
1533
|
+
|
|
1534
|
+
User.validate({ name: 'Alice', bio: null, age: null }); // valid
|
|
1535
|
+
|
|
1536
|
+
// Toggle back with .notNullable()
|
|
1537
|
+
const strictName = string().nullable().notNullable();
|
|
1538
|
+
type StrictName = InferType<typeof strictName>; // string (not string | null)
|
|
1539
|
+
|
|
1540
|
+
strictName.validate(null); // invalid
|
|
1541
|
+
strictName.validate('Alice'); // valid
|
|
1542
|
+
|
|
1543
|
+
// Introspect at runtime
|
|
1544
|
+
string().nullable().introspect().isNullable; // true
|
|
1545
|
+
string().nullable().notNullable().introspect().isNullable; // false
|
|
1546
|
+
```
|
|
1547
|
+
|
|
1548
|
+
### Enum / oneOf Extension
|
|
1549
|
+
|
|
1550
|
+
| Method | Available on | Description |
|
|
1551
|
+
| --- | --- | --- |
|
|
1552
|
+
| `.oneOf(...values)` | `string`, `number` | Constrains the value to one of the given literals and **narrows the inferred type** to the literal union. |
|
|
1553
|
+
| `.oneOf(valuesArray, errorMessage?)` | `string`, `number` | Array-form with an optional custom error message (string or factory). |
|
|
1554
|
+
| `enumOf(...values)` | top-level factory | Sugar for `string().oneOf(...)`. Mirrors Zod's `z.enum()`. |
|
|
1555
|
+
| `enumOf(valuesArray, errorMessage?)` | top-level factory | Array-form with an optional custom error message. |
|
|
1556
|
+
|
|
1557
|
+
```typescript
|
|
1558
|
+
import { string, number, enumOf, InferType } from '@cleverbrush/schema';
|
|
1559
|
+
|
|
1560
|
+
// String enum — infers 'admin' | 'user' | 'guest'
|
|
1561
|
+
const Role = enumOf('admin', 'user', 'guest');
|
|
1562
|
+
type Role = InferType<typeof Role>;
|
|
1563
|
+
|
|
1564
|
+
Role.validate('admin'); // valid
|
|
1565
|
+
Role.validate('other'); // invalid — "must be one of: admin, user, guest"
|
|
1566
|
+
|
|
1567
|
+
// Equivalent long-form
|
|
1568
|
+
const Role2 = string().oneOf('admin', 'user', 'guest');
|
|
1569
|
+
|
|
1570
|
+
// Number enum — infers 1 | 2 | 3
|
|
1571
|
+
const Priority = number().oneOf(1, 2, 3);
|
|
1572
|
+
type Priority = InferType<typeof Priority>;
|
|
1573
|
+
|
|
1574
|
+
// Chains with nullable / optional
|
|
1575
|
+
const OptionalRole = enumOf('admin', 'user').nullable(); // 'admin' | 'user' | null
|
|
1576
|
+
|
|
1577
|
+
// Runtime access to allowed values via introspect
|
|
1578
|
+
Role.introspect().extensions?.oneOf; // ['admin', 'user', 'guest']
|
|
1579
|
+
```
|
|
1580
|
+
|
|
1581
|
+
#### Custom error messages for `.oneOf()`
|
|
1582
|
+
|
|
1583
|
+
`.oneOf()` accepts a custom error message via the **array form**, where the allowed values are passed as an array and the error message is the second argument:
|
|
1584
|
+
|
|
1585
|
+
```typescript
|
|
1586
|
+
import { string, number, enumOf } from '@cleverbrush/schema';
|
|
1587
|
+
|
|
1588
|
+
// String — array form with custom string error message
|
|
1589
|
+
const role = string().oneOf(['admin', 'user', 'guest'], 'Invalid role');
|
|
1590
|
+
role.validate('other'); // invalid — "Invalid role"
|
|
1591
|
+
|
|
1592
|
+
// String — array form with factory function
|
|
1593
|
+
const role2 = string().oneOf(
|
|
1594
|
+
['admin', 'user'],
|
|
1595
|
+
(val) => `"${val}" is not a valid role`
|
|
1596
|
+
);
|
|
1597
|
+
|
|
1598
|
+
// enumOf — array form with custom error message
|
|
1599
|
+
const Role = enumOf(['admin', 'user', 'guest'], 'Invalid role');
|
|
1600
|
+
|
|
1601
|
+
// Number — trailing error message (unambiguous since values are numbers)
|
|
1602
|
+
const priority = number().oneOf(1, 2, 3, 'Priority must be 1, 2, or 3');
|
|
1603
|
+
priority.validate(99); // invalid — "Priority must be 1, 2, or 3"
|
|
1604
|
+
|
|
1605
|
+
// Number — array form
|
|
1606
|
+
const priority2 = number().oneOf([1, 2, 3], 'Invalid priority');
|
|
1607
|
+
|
|
1608
|
+
// Number — factory function
|
|
1609
|
+
const priority3 = number().oneOf(
|
|
1610
|
+
1, 2, 3,
|
|
1611
|
+
(val) => `${val} is not a valid priority`
|
|
1612
|
+
);
|
|
1613
|
+
```
|
|
1614
|
+
|
|
1615
|
+
> **Note on string `.oneOf()` error messages:** Because `.oneOf()` accepts a variadic list of string values, a trailing string argument is treated as another allowed value (not an error message). To provide a string error message for a string enum, use the **array form** — `string().oneOf(['a', 'b'], 'error message')`. A trailing *function* is always unambiguously treated as an error message factory in the rest-params form.
|
|
1616
|
+
|
|
1617
|
+
All validator extensions accept an optional error message as the last parameter — either a string or a function (matching the same `ValidationErrorMessageProvider` pattern used by built-in constraints like `.minLength()`):
|
|
1618
|
+
|
|
1619
|
+
```typescript
|
|
1620
|
+
import { string, number, array } from '@cleverbrush/schema';
|
|
1621
|
+
|
|
1622
|
+
// String error messages
|
|
1623
|
+
const email = string().email('Please enter a valid email');
|
|
1624
|
+
const age = number().positive('Age must be positive');
|
|
1625
|
+
const tags = array().of(string()).nonempty('At least one tag required');
|
|
1626
|
+
|
|
1627
|
+
// Function error messages — receive the invalid value
|
|
1628
|
+
const name = string().nonempty((val) => `"${val}" is not allowed`);
|
|
1629
|
+
const score = number().multipleOf(5, (val) => `${val} is not a multiple of 5`);
|
|
1630
|
+
```
|
|
1631
|
+
|
|
1632
|
+
### The `/core` Sub-path
|
|
1633
|
+
|
|
1634
|
+
If you need bare builders **without** the built-in extensions (e.g. to apply only your own custom extensions), import from the `/core` sub-path:
|
|
1635
|
+
|
|
1636
|
+
```typescript
|
|
1637
|
+
// Bare builders — no built-in extensions
|
|
1638
|
+
import { string, number, array, withExtensions } from '@cleverbrush/schema/core';
|
|
1639
|
+
|
|
1640
|
+
// Apply only your own extensions
|
|
1641
|
+
const s = withExtensions(myCustomExtension);
|
|
1642
|
+
```
|
|
1643
|
+
|
|
1644
|
+
The default import (`@cleverbrush/schema`) re-exports everything from `/core` and overrides the nine factory functions (`string`, `number`, `boolean`, `date`, `object`, `array`, `union`, `func`, `any`) with pre-extended versions. The extension descriptors themselves are also exported (`stringExtensions`, `numberExtensions`, `arrayExtensions`, `nullableExtension`) so you can compose them with your own.
|
|
1645
|
+
|
|
1646
|
+
## Part of the Cleverbrush Ecosystem
|
|
1647
|
+
|
|
1648
|
+
`@cleverbrush/schema` is the foundation of a three-library ecosystem:
|
|
1649
|
+
|
|
1650
|
+
```
|
|
1651
|
+
@cleverbrush/schema → Define once
|
|
1652
|
+
↓ ↓ ↓
|
|
1653
|
+
Validate data Map between schemas Render React forms
|
|
1654
|
+
↓ ↓ ↓
|
|
1655
|
+
.validate() @cleverbrush/mapper @cleverbrush/react-form
|
|
1656
|
+
```
|
|
1657
|
+
|
|
1658
|
+
Define a schema once and use it for runtime validation, object mapping between different shapes, and type-safe React forms — all from a single source of truth.
|
|
1659
|
+
|
|
1660
|
+
## Exports
|
|
1661
|
+
|
|
1662
|
+
**Builder functions:** `any`, `lazy`, `string`, `number`, `boolean`, `func`, `promise`, `object`, `date`, `array`, `union`, `parseString`
|
|
1663
|
+
|
|
1664
|
+
**Builder classes** (for extending): `SchemaBuilder`, `AnySchemaBuilder`, `ArraySchemaBuilder`, `BooleanSchemaBuilder`, `DateSchemaBuilder`, `FunctionSchemaBuilder`, `ParseStringSchemaBuilder`, `LazySchemaBuilder`, `NumberSchemaBuilder`, `ObjectSchemaBuilder`, `PromiseSchemaBuilder`, `StringSchemaBuilder`, `UnionSchemaBuilder`
|
|
1665
|
+
|
|
1666
|
+
**Extension system:** `defineExtension`, `withExtensions`, `stringExtensions`, `numberExtensions`, `arrayExtensions`, `nullableExtension`
|
|
1667
|
+
|
|
1668
|
+
**Sub-path exports:** `@cleverbrush/schema/core` — bare builders without built-in extensions
|
|
1669
|
+
|
|
1670
|
+
**Types:** `InferType`, `ValidationResult`, `ValidationError`, `MakeOptional`, `SchemaPropertySelector`, `PropertyDescriptor`, `PropertyDescriptorTree`, `ExtensionConfig`, `ExtensionDescriptor`
|
|
1671
|
+
|
|
1672
|
+
See [API documentation](https://docs.cleverbrush.com/) for the full reference.
|
|
1673
|
+
|
|
1674
|
+
## Performance
|
|
1675
|
+
|
|
1676
|
+
Benchmarked against Zod v4 with [Vitest bench](https://vitest.dev/guide/features.html#benchmarking). Run the benchmarks yourself from the repo root: `npm run bench`.
|
|
1677
|
+
|
|
1678
|
+
| Benchmark | @cleverbrush/schema | Zod | Ratio |
|
|
1679
|
+
| --- | --- | --- | --- |
|
|
1680
|
+
| Array 100 objects — valid | 35,228 ops/s | 13,277 ops/s | **2.65× faster** |
|
|
1681
|
+
| Array 100 objects — invalid | 899,329 ops/s | 4,396 ops/s | **204× faster** |
|
|
1682
|
+
| Complex order — valid | 198,988 ops/s | 136,090 ops/s | **1.46× faster** |
|
|
1683
|
+
| Complex order — invalid | 884,706 ops/s | 26,106 ops/s | **33.9× faster** |
|
|
1684
|
+
| Flat object — valid | 1,001,194 ops/s | 840,725 ops/s | **1.19× faster** |
|
|
1685
|
+
| Flat object — invalid | 2,653,630 ops/s | 176,222 ops/s | **15.1× faster** |
|
|
1686
|
+
| Nested object — valid | 690,556 ops/s | 368,893 ops/s | **1.87× faster** |
|
|
1687
|
+
| Nested object — invalid | 2,739,319 ops/s | 87,245 ops/s | **31.4× faster** |
|
|
1688
|
+
| String — valid | 5,348,564 ops/s | 3,533,945 ops/s | **1.51× faster** |
|
|
1689
|
+
| String — invalid | 5,749,087 ops/s | 482,961 ops/s | **11.9× faster** |
|
|
1690
|
+
| Number — valid | 7,911,266 ops/s | 4,806,511 ops/s | **1.65× faster** |
|
|
1691
|
+
| Number — invalid | 5,387,475 ops/s | 637,513 ops/s | **8.45× faster** |
|
|
1692
|
+
| Union first branch | 1,925,508 ops/s | 1,529,547 ops/s | **1.26× faster** |
|
|
1693
|
+
| Union last branch | 676,107 ops/s | 732,682 ops/s | 0.92× |
|
|
1694
|
+
| Union no match — invalid | 5,873,118 ops/s | 385,453 ops/s | **15.2× faster** |
|
|
1695
|
+
|
|
1696
|
+
The large gains on invalid data come from the early-exit optimization: validation stops at the first failing constraint in each field and skips the rest of the object. For APIs and form handlers where invalid submissions are common, this translates directly to measurable throughput improvements.
|
|
1697
|
+
|
|
1698
|
+
## External Schema Interop (`extern()`)
|
|
1699
|
+
|
|
1700
|
+
Already using Zod, Valibot, or ArkType? The `extern()` factory wraps any [Standard Schema v1](https://standardschema.dev/) compatible schema into a `@cleverbrush/schema` builder — so you can mix external schemas with native ones inside an `object()` without rewriting anything.
|
|
1701
|
+
|
|
1702
|
+
```ts
|
|
1703
|
+
import { z } from 'zod';
|
|
1704
|
+
import { object, number, extern, InferType } from '@cleverbrush/schema';
|
|
1705
|
+
|
|
1706
|
+
// Existing Zod schema — keep as-is
|
|
1707
|
+
const ZodAddress = z.object({
|
|
1708
|
+
street: z.string().min(1),
|
|
1709
|
+
city: z.string(),
|
|
1710
|
+
zip: z.string().length(5),
|
|
1711
|
+
});
|
|
1712
|
+
|
|
1713
|
+
// Compose with @cleverbrush/schema
|
|
1714
|
+
const OrderSchema = object({
|
|
1715
|
+
address: extern(ZodAddress),
|
|
1716
|
+
totalCents: number().min(1),
|
|
1717
|
+
});
|
|
1718
|
+
|
|
1719
|
+
// Type is inferred from *both* libraries:
|
|
1720
|
+
type Order = InferType<typeof OrderSchema>;
|
|
1721
|
+
// { address: { street: string; city: string; zip: string }; totalCents: number }
|
|
1722
|
+
|
|
1723
|
+
const result = OrderSchema.validate({
|
|
1724
|
+
address: { street: '5th Ave', city: 'NYC', zip: '10001' },
|
|
1725
|
+
totalCents: 4999,
|
|
1726
|
+
});
|
|
1727
|
+
|
|
1728
|
+
if (!result.valid) {
|
|
1729
|
+
// Navigate into the extern property — no type annotation needed
|
|
1730
|
+
const zipErrors = result.getErrorsFor(t => t.address.zip);
|
|
1731
|
+
console.log(zipErrors.errors);
|
|
1732
|
+
}
|
|
1733
|
+
```
|
|
1734
|
+
|
|
1735
|
+
Key points:
|
|
1736
|
+
- **One parameter:** `extern(standardSchema)` — types and property descriptors are derived automatically.
|
|
1737
|
+
- **getErrorsFor()** works through extern boundaries: `t => t.address.city` navigates into the Zod schema.
|
|
1738
|
+
- **Validation** is delegated to the external schema’s `['~standard'].validate()` — @cleverbrush/schema never re-implements the external library’s validation logic.
|
|
1739
|
+
- Works with any library that implements Standard Schema v1 (Zod ≥ 3.24, Valibot ≥ 1.0, ArkType, etc.).
|
|
1740
|
+
|
|
1741
|
+
## Standard Schema Interoperability
|
|
1742
|
+
|
|
1743
|
+
`@cleverbrush/schema` implements [Standard Schema v1](https://standardschema.dev/). Every builder exposes a `['~standard']` getter, which means schemas work as-is with any Standard Schema consumer — no adapters, no wrappers, no configuration:
|
|
1744
|
+
|
|
1745
|
+
```ts
|
|
1746
|
+
import { object, string, number } from '@cleverbrush/schema';
|
|
1747
|
+
|
|
1748
|
+
const UserSchema = object({
|
|
1749
|
+
name: string().nonempty(),
|
|
1750
|
+
age: number().min(18),
|
|
1751
|
+
});
|
|
1752
|
+
|
|
1753
|
+
// Works with tRPC, TanStack Form, React Hook Form, T3 Env, Hono, Elysia, …
|
|
1754
|
+
const standardSchema = UserSchema['~standard'];
|
|
1755
|
+
```
|
|
1756
|
+
|
|
1757
|
+
Confirmed integrations: **tRPC**, **TanStack Form**, **React Hook Form**, **T3 Env**, **Hono**, **Elysia**, **next-safe-action**, and 50+ others listed on [standardschema.dev](https://standardschema.dev/).
|
|
1758
|
+
|
|
1759
|
+
## Code Quality
|
|
1760
|
+
|
|
1761
|
+
- **Linting:** [Biome](https://biomejs.dev/) — strict rules enforced on every PR via CI
|
|
1762
|
+
- **Type checking:** TypeScript strict mode (`strictNullChecks`, `noImplicitAny`, full coverage)
|
|
1763
|
+
- **Unit tests:** [Vitest](https://vitest.dev/) — runtime tests + type-level tests (`expectTypeOf`) covering all builders, extensions, edge cases, and error paths
|
|
1764
|
+
- **Type-level tests:** `expectTypeOf` assertions validate that inferred types are exactly correct, not just assignable
|
|
1765
|
+
- **CI:** Every pull request must pass lint + build + test before merge — see [`.github/workflows/ci.yml`](../../.github/workflows/ci.yml)
|
|
1766
|
+
|
|
1767
|
+
## License
|
|
1768
|
+
|
|
1769
|
+
BSD-3-Clause
|