Initial Commit

This commit is contained in:
2026-05-27 23:47:53 +01:00
parent fbc70f96e6
commit 9b28db724f
18103 changed files with 1600554 additions and 0 deletions

View File

@@ -0,0 +1,121 @@
# @suaveplan/validation - Runtime schema validation with Zod-compatible API — self-contained parse engine, exhaustive schema types, async refinements, hooks, structured errors
## Summary
Runtime schema validation with a Zod-compatible API. This package is a self-contained parse engine — it does not import Zod or any other validation library — so it is safe to use in environments where Zod is absent or where bundle size is a constraint. It covers the full schema surface expected from a production validator: all primitive and composite types, `parse`/`safeParse`/`parseAsync`, `refine` (sync + async), `transform`, `describe`, object shape builders (`pick`, `omit`, `partial`, `extend`, `merge`), three unknown-key modes, async lifecycle hooks, and composable helpers (`pipe`, `mergeResults`). Structured errors surface via `@suaveplan/error` with stable `VALIDATION.*` codes. When Zod is already a project dependency and you need its ecosystem (inference helpers, plugins), use Zod directly; use this package for internal platform code and zero-dep environments.
<!-- begin:auto-docs -->
## Documentation tree
- [schema](./src/core/schema/schema.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [schema](./src/core/schema/schema.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [errors](./src/errors/errors.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [errors](./src/errors/errors.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [otel](./src/otel/validation-otel.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [validation-otel](./src/otel/validation-otel.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [array](./src/schemas/array/array.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [array](./src/schemas/array/array.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [boolean](./src/schemas/boolean/boolean.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [boolean](./src/schemas/boolean/boolean.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [date](./src/schemas/date/date.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [date](./src/schemas/date/date.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [enum](./src/schemas/enum/enum.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [enum](./src/schemas/enum/enum.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [intersection](./src/schemas/intersection/intersection.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [intersection](./src/schemas/intersection/intersection.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [literal](./src/schemas/literal/literal.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [literal](./src/schemas/literal/literal.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [nullable](./src/schemas/nullable/nullable.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [nullable](./src/schemas/nullable/nullable.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [number](./src/schemas/number/number.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [number](./src/schemas/number/number.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [object](./src/schemas/object/object.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [object](./src/schemas/object/object.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [optional](./src/schemas/optional/optional.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [optional](./src/schemas/optional/optional.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [record](./src/schemas/record/record.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [record](./src/schemas/record/record.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [string](./src/schemas/string/string.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [string](./src/schemas/string/string.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [tuple](./src/schemas/tuple/tuple.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [tuple](./src/schemas/tuple/tuple.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [union](./src/schemas/union/union.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [union](./src/schemas/union/union.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [common](./src/types/common.md) — `common.ts` defines the core type contracts shared across the entire `@suaveplan/validation` package: validation codes,…
- [validation-hooks](./src/types/validation-hooks.md) — `validation-hooks.ts` defines the lifecycle hook types for schema validation observability. These hooks allow consumers…
- [coerce](./src/utils/coerce.md) — The coerce utilities allow schemas to accept a broader range of input types by converting them before validation. They…
- [compose](./src/utils/compose.md) — The compose utilities provide functional combinators for working with validation results and schemas. `mergeResults` ag…
<!-- end:auto-docs --> - [schema](./src/core/schema/schema.md) — <!-- begin:auto-docs --> _(no submodules)_- [errors](./src/errors/errors.md) — <!-- begin:auto-docs --> _(no submodules)_- [errors](./src/errors/errors.md) — <!-- begin:auto-docs --> _(no submodules)_- [otel](./src/otel/validation-otel.md) — <!-- begin:auto-docs --> _(no submodules)_- [validation-otel](./src/otel/validation-otel.md) — <!-- begin:auto-docs --> _(no submodules)_- [array](./src/schemas/array/array.md) — <!-- begin:auto-docs --> _(no submodules)_- [array](./src/schemas/array/array.md) — <!-- begin:auto-docs --> _(no submodules)_- [boolean](./src/schemas/boolean/boolean.md) — <!-- begin:auto-docs --> _(no submodules)_- [boolean](./src/schemas/boolean/boolean.md) — <!-- begin:auto-docs --> _(no submodules)_- [date](./src/schemas/date/date.md) — <!-- begin:auto-docs --> _(no submodules)_- [date](./src/schemas/date/date.md) — <!-- begin:auto-docs --> _(no submodules)_- [enum](./src/schemas/enum/enum.md) — <!-- begin:auto-docs --> _(no submodules)_- [enum](./src/schemas/enum/enum.md) — <!-- begin:auto-docs --> _(no submodules)_- [intersection](./src/schemas/intersection/intersection.md) — <!-- begin:auto-docs --> _(no submodules)_- [intersection](./src/schemas/intersection/intersection.md) — <!-- begin:auto-docs --> _(no submodules)_- [literal](./src/schemas/literal/literal.md) — <!-- begin:auto-docs --> _(no submodules)_- [literal](./src/schemas/literal/literal.md) — <!-- begin:auto-docs --> _(no submodules)_- [nullable](./src/schemas/nullable/nullable.md) — <!-- begin:auto-docs --> _(no submodules)_- [nullable](./src/schemas/nullable/nullable.md) — <!-- begin:auto-docs --> _(no submodules)_- [number](./src/schemas/number/number.md) — <!-- begin:auto-docs --> _(no submodules)_- [number](./src/schemas/number/number.md) — <!-- begin:auto-docs --> _(no submodules)_- [object](./src/schemas/object/object.md) — <!-- begin:auto-docs --> _(no submodules)_- [object](./src/schemas/object/object.md) — <!-- begin:auto-docs --> _(no submodules)_- [optional](./src/schemas/optional/optional.md) — <!-- begin:auto-docs --> _(no submodules)_- [optional](./src/schemas/optional/optional.md) — <!-- begin:auto-docs --> _(no submodules)_- [record](./src/schemas/record/record.md) — <!-- begin:auto-docs --> _(no submodules)_- [record](./src/schemas/record/record.md) — <!-- begin:auto-docs --> _(no submodules)_- [string](./src/schemas/string/string.md) — <!-- begin:auto-docs --> _(no submodules)_- [string](./src/schemas/string/string.md) — <!-- begin:auto-docs --> _(no submodules)_- [tuple](./src/schemas/tuple/tuple.md) — <!-- begin:auto-docs --> _(no submodules)_- [tuple](./src/schemas/tuple/tuple.md) — <!-- begin:auto-docs --> _(no submodules)_- [union](./src/schemas/union/union.md) — <!-- begin:auto-docs --> _(no submodules)_- [union](./src/schemas/union/union.md) — <!-- begin:auto-docs --> _(no submodules)_- [common](./src/types/common.md) — `common.ts` defines the core type contracts shared across the entire `@suaveplan/validation` package: validation codes,…
- [validation-hooks](./src/types/validation-hooks.md) — `validation-hooks.ts` defines the lifecycle hook types for schema validation observability. These hooks allow consumers…
- [coerce](./src/utils/coerce.md) — The coerce utilities allow schemas to accept a broader range of input types by converting them before validation. They…
- [compose](./src/utils/compose.md) — The compose utilities provide functional combinators for working with validation results and schemas. `mergeResults` ag…
- [schema](./src/core/schema/schema.md) — <!-- begin:auto-docs --> _(no submodules)_- [errors](./src/errors/errors.md) — <!-- begin:auto-docs --> _(no submodules)_- [errors](./src/errors/errors.md) — <!-- begin:auto-docs --> _(no submodules)_- [otel](./src/otel/validation-otel.md) — <!-- begin:auto-docs --> _(no submodules)_- [validation-otel](./src/otel/validation-otel.md) — <!-- begin:auto-docs --> _(no submodules)_- [array](./src/schemas/array/array.md) — <!-- begin:auto-docs --> _(no submodules)_- [array](./src/schemas/array/array.md) — <!-- begin:auto-docs --> _(no submodules)_- [boolean](./src/schemas/boolean/boolean.md) — <!-- begin:auto-docs --> _(no submodules)_- [boolean](./src/schemas/boolean/boolean.md) — <!-- begin:auto-docs --> _(no submodules)_- [date](./src/schemas/date/date.md) — <!-- begin:auto-docs --> _(no submodules)_- [date](./src/schemas/date/date.md) — <!-- begin:auto-docs --> _(no submodules)_- [enum](./src/schemas/enum/enum.md) — <!-- begin:auto-docs --> _(no submodules)_- [enum](./src/schemas/enum/enum.md) — <!-- begin:auto-docs --> _(no submodules)_- [intersection](./src/schemas/intersection/intersection.md) — <!-- begin:auto-docs --> _(no submodules)_- [intersection](./src/schemas/intersection/intersection.md) — <!-- begin:auto-docs --> _(no submodules)_- [literal](./src/schemas/literal/literal.md) — <!-- begin:auto-docs --> _(no submodules)_- [literal](./src/schemas/literal/literal.md) — <!-- begin:auto-docs --> _(no submodules)_- [nullable](./src/schemas/nullable/nullable.md) — <!-- begin:auto-docs --> _(no submodules)_- [nullable](./src/schemas/nullable/nullable.md) — <!-- begin:auto-docs --> _(no submodules)_- [number](./src/schemas/number/number.md) — <!-- begin:auto-docs --> _(no submodules)_- [number](./src/schemas/number/number.md) — <!-- begin:auto-docs --> _(no submodules)_- [object](./src/schemas/object/object.md) — <!-- begin:auto-docs --> _(no submodules)_- [object](./src/schemas/object/object.md) — <!-- begin:auto-docs --> _(no submodules)_- [optional](./src/schemas/optional/optional.md) — <!-- begin:auto-docs --> _(no submodules)_- [optional](./src/schemas/optional/optional.md) — <!-- begin:auto-docs --> _(no submodules)_- [record](./src/schemas/record/record.md) — <!-- begin:auto-docs --> _(no submodules)_- [record](./src/schemas/record/record.md) — <!-- begin:auto-docs --> _(no submodules)_- [string](./src/schemas/string/string.md) — <!-- begin:auto-docs --> _(no submodules)_- [string](./src/schemas/string/string.md) — <!-- begin:auto-docs --> _(no submodules)_- [tuple](./src/schemas/tuple/tuple.md) — <!-- begin:auto-docs --> _(no submodules)_- [tuple](./src/schemas/tuple/tuple.md) — <!-- begin:auto-docs --> _(no submodules)_- [union](./src/schemas/union/union.md) — <!-- begin:auto-docs --> _(no submodules)_- [union](./src/schemas/union/union.md) — <!-- begin:auto-docs --> _(no submodules)_- [common](./src/types/common.md) — `common.ts` defines the core type contracts shared across the entire `@suaveplan/validation` package: validation codes,…
- [validation-hooks](./src/types/validation-hooks.md) — `validation-hooks.ts` defines the lifecycle hook types for schema validation observability. These hooks allow consumers…
- [coerce](./src/utils/coerce.md) — The coerce utilities allow schemas to accept a broader range of input types by converting them before validation. They…
- [compose](./src/utils/compose.md) — The compose utilities provide functional combinators for working with validation results and schemas. `mergeResults` ag…
- [schema](./src/core/schema/schema.md) — <!-- begin:auto-docs --> _(no submodules)_- [errors](./src/errors/errors.md) — <!-- begin:auto-docs --> _(no submodules)_- [errors](./src/errors/errors.md) — <!-- begin:auto-docs --> _(no submodules)_- [otel](./src/otel/validation-otel.md) — <!-- begin:auto-docs --> _(no submodules)_- [validation-otel](./src/otel/validation-otel.md) — <!-- begin:auto-docs --> _(no submodules)_- [array](./src/schemas/array/array.md) — <!-- begin:auto-docs --> _(no submodules)_- [array](./src/schemas/array/array.md) — <!-- begin:auto-docs --> _(no submodules)_- [boolean](./src/schemas/boolean/boolean.md) — <!-- begin:auto-docs --> _(no submodules)_- [boolean](./src/schemas/boolean/boolean.md) — <!-- begin:auto-docs --> _(no submodules)_- [date](./src/schemas/date/date.md) — <!-- begin:auto-docs --> _(no submodules)_- [date](./src/schemas/date/date.md) — <!-- begin:auto-docs --> _(no submodules)_- [enum](./src/schemas/enum/enum.md) — <!-- begin:auto-docs --> _(no submodules)_- [enum](./src/schemas/enum/enum.md) — <!-- begin:auto-docs --> _(no submodules)_- [intersection](./src/schemas/intersection/intersection.md) — <!-- begin:auto-docs --> _(no submodules)_- [intersection](./src/schemas/intersection/intersection.md) — <!-- begin:auto-docs --> _(no submodules)_- [literal](./src/schemas/literal/literal.md) — <!-- begin:auto-docs --> _(no submodules)_- [literal](./src/schemas/literal/literal.md) — <!-- begin:auto-docs --> _(no submodules)_- [nullable](./src/schemas/nullable/nullable.md) — <!-- begin:auto-docs --> _(no submodules)_- [nullable](./src/schemas/nullable/nullable.md) — <!-- begin:auto-docs --> _(no submodules)_- [number](./src/schemas/number/number.md) — <!-- begin:auto-docs --> _(no submodules)_- [number](./src/schemas/number/number.md) — <!-- begin:auto-docs --> _(no submodules)_- [object](./src/schemas/object/object.md) — <!-- begin:auto-docs --> _(no submodules)_- [object](./src/schemas/object/object.md) — <!-- begin:auto-docs --> _(no submodules)_- [optional](./src/schemas/optional/optional.md) — <!-- begin:auto-docs --> _(no submodules)_- [optional](./src/schemas/optional/optional.md) — <!-- begin:auto-docs --> _(no submodules)_- [record](./src/schemas/record/record.md) — <!-- begin:auto-docs --> _(no submodules)_- [record](./src/schemas/record/record.md) — <!-- begin:auto-docs --> _(no submodules)_- [string](./src/schemas/string/string.md) — <!-- begin:auto-docs --> _(no submodules)_- [string](./src/schemas/string/string.md) — <!-- begin:auto-docs --> _(no submodules)_- [tuple](./src/schemas/tuple/tuple.md) — <!-- begin:auto-docs --> _(no submodules)_- [tuple](./src/schemas/tuple/tuple.md) — <!-- begin:auto-docs --> _(no submodules)_- [union](./src/schemas/union/union.md) — <!-- begin:auto-docs --> _(no submodules)_- [union](./src/schemas/union/union.md) — <!-- begin:auto-docs --> _(no submodules)_- [common](./src/types/common.md) — `common.ts` defines the core type contracts shared across the entire `@suaveplan/validation` package: validation codes,…
- [validation-hooks](./src/types/validation-hooks.md) — `validation-hooks.ts` defines the lifecycle hook types for schema validation observability. These hooks allow consumers…
- [coerce](./src/utils/coerce.md) — The coerce utilities allow schemas to accept a broader range of input types by converting them before validation. They…
- [compose](./src/utils/compose.md) — The compose utilities provide functional combinators for working with validation results and schemas. `mergeResults` ag…
- [schema](./src/core/schema/schema.md) — <!-- begin:auto-docs --> _(no submodules)_- [errors](./src/errors/errors.md) — <!-- begin:auto-docs --> _(no submodules)_ - [errors](./src/errors/errors.md) — <!-- begin:auto-docs --> _(no submodules)_- [otel](./src/otel/validation-otel.md) — <!-- begin:auto-docs --> _(no submodules)_ - [validation-otel](./src/otel/validation-otel.md) — <!-- begin:auto-docs --> _(no submodules)_- [array](./src/schemas/array/array.md) — <!-- begin:auto-docs --> _(no submodules)_ - [array](./src/schemas/array/array.md) — <!-- begin:auto-docs --> _(no submodules)_- [boolean](./src/schemas/boolean/boolean.md) — <!-- begin:auto-docs --> _(no submodules)_ - [boolean](./src/schemas/boolean/boolean.md) — <!-- begin:auto-docs --> _(no submodules)_- [date](./src/schemas/date/date.md) — <!-- begin:auto-docs --> _(no submodules)_ - [date](./src/schemas/date/date.md) — <!-- begin:auto-docs --> _(no submodules)_- [enum](./src/schemas/enum/enum.md) — <!-- begin:auto-docs --> _(no submodules)_ - [enum](./src/schemas/enum/enum.md) — <!-- begin:auto-docs --> _(no submodules)_- [intersection](./src/schemas/intersection/intersection.md) — <!-- begin:auto-docs --> _(no submodules)_ - [intersection](./src/schemas/intersection/intersection.md) — <!-- begin:auto-docs --> _(no submodules)_- [literal](./src/schemas/literal/literal.md) — <!-- begin:auto-docs --> _(no submodules)_ - [literal](./src/schemas/literal/literal.md) — <!-- begin:auto-docs --> _(no submodules)_- [nullable](./src/schemas/nullable/nullable.md) — <!-- begin:auto-docs --> _(no submodules)_ - [nullable](./src/schemas/nullable/nullable.md) — <!-- begin:auto-docs --> _(no submodules)_- [number](./src/schemas/number/number.md) — <!-- begin:auto-docs --> _(no submodules)_ - [number](./src/schemas/number/number.md) — <!-- begin:auto-docs --> _(no submodules)_- [object](./src/schemas/object/object.md) — <!-- begin:auto-docs --> _(no submodules)_ - [object](./src/schemas/object/object.md) — <!-- begin:auto-docs --> _(no submodules)_- [optional](./src/schemas/optional/optional.md) — <!-- begin:auto-docs --> _(no submodules)_ - [optional](./src/schemas/optional/optional.md) — <!-- begin:auto-docs --> _(no submodules)_- [record](./src/schemas/record/record.md) — <!-- begin:auto-docs --> _(no submodules)_ - [record](./src/schemas/record/record.md) — <!-- begin:auto-docs --> _(no submodules)_- [string](./src/schemas/string/string.md) — <!-- begin:auto-docs --> _(no submodules)_ - [string](./src/schemas/string/string.md) — <!-- begin:auto-docs --> _(no submodules)_- [tuple](./src/schemas/tuple/tuple.md) — <!-- begin:auto-docs --> _(no submodules)_ - [tuple](./src/schemas/tuple/tuple.md) — <!-- begin:auto-docs --> _(no submodules)_- [union](./src/schemas/union/union.md) — <!-- begin:auto-docs --> _(no submodules)_ - [union](./src/schemas/union/union.md) — <!-- begin:auto-docs --> _(no submodules)_- [common](./src/types/common.md) — `common.ts` defines the core type contracts shared across the entire `@suaveplan/validation` package: validation codes,…
- [validation-hooks](./src/types/validation-hooks.md) — `validation-hooks.ts` defines the lifecycle hook types for schema validation observability. These hooks allow consumers…
- [coerce](./src/utils/coerce.md) — The coerce utilities allow schemas to accept a broader range of input types by converting them before validation. They…
- [compose](./src/utils/compose.md) — The compose utilities provide functional combinators for working with validation results and schemas. `mergeResults` ag…
## Features
- Exhaustive schema types: `string`, `number`, `boolean`, `date`, `array`, `object`, `record`, `tuple`, `literal`, `enum`, `union`, `intersection`, `optional`, `nullable`
- `parse()` / `safeParse()` / `parseAsync()` / `safeParseAsync()` — `parse()` and `safeParse()` throw `VALIDATION.ASYNC_REFINEMENT_REQUIRES_PARSEASYNC` when the schema carries any async refinement (no silent skip; use `parseAsync()` for async-refined schemas)
- `refine()` (sync and async), `transform()`, `describe()`
- Object shape builders: `pick`, `omit`, `partial`, `extend`, `merge`, three unknown-key modes
- Input coercion utilities: `coerce`, `coerceString`, `coerceNumber`, `coerceBoolean`, `coerceDate`
- Functional combinators: `mergeResults`, `pipe`
- Structured errors: `ValidationError` with `flatten()` and `format()`, 21 typed codes
- OTel instrumentation: success/failure counters and duration histogram
- `Infer<S>` type helper for extracting output types
## Quick Start
```typescript
import { object, string, number, array, Infer } from "@suaveplan/validation";
const UserSchema = object({
id: string().uuid(),
name: string().min(1).max(100),
age: number().int().min(0),
tags: array(string()).max(10),
});
type User = Infer<typeof UserSchema>;
const result = UserSchema.safeParse({ id: "550e8400-e29b-41d4-a716-446655440000", name: "Alice", age: 30, tags: [] });
if (result.success) {
console.log(result.data);
} else {
const { formErrors, fieldErrors } = result.error.flatten();
console.error(formErrors, fieldErrors);
}
```
## Module Docs
- [Core engine](src/core/schema/schema.md)
- [Errors](src/errors/errors.md)
- [StringSchema](src/schemas/string/string.md)
- [NumberSchema](src/schemas/number/number.md)
- [BooleanSchema](src/schemas/boolean/boolean.md)
- [DateSchema](src/schemas/date/date.md)
- [ArraySchema](src/schemas/array/array.md)
- [ObjectSchema](src/schemas/object/object.md)
- [RecordSchema](src/schemas/record/record.md)
- [TupleSchema](src/schemas/tuple/tuple.md)
- [OptionalSchema](src/schemas/optional/optional.md)
- [NullableSchema](src/schemas/nullable/nullable.md)
- [LiteralSchema](src/schemas/literal/literal.md)
- [EnumSchema](src/schemas/enum/enum.md)
- [UnionSchema](src/schemas/union/union.md)
- [IntersectionSchema](src/schemas/intersection/intersection.md)
- [Coerce utilities](src/utils/coerce.md)
- [Compose utilities](src/utils/compose.md)
- [OTel instrumentation](src/otel/validation-otel.md)

View File

@@ -0,0 +1,255 @@
{
"name": "@suaveplan/validation",
"version": "0.1.0",
"type": "module",
"sideEffects": false,
"description": "Runtime schema validation with Zod-compatible API — self-contained parse engine, exhaustive schema types, async refinements, hooks, structured errors",
"license": "MIT",
"author": "Suaveplan Team",
"main": "./dist/index.js",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
"./package.json": "./package.json",
".": {
"import": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
},
"require": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
}
},
"./core/schema/schema": {
"import": {
"types": "./dist/core/schema/schema.d.ts",
"default": "./dist/core/schema/schema.js"
},
"require": {
"types": "./dist/core/schema/schema.d.ts",
"default": "./dist/core/schema/schema.js"
}
},
"./errors/errors": {
"import": {
"types": "./dist/errors/errors.d.ts",
"default": "./dist/errors/errors.js"
},
"require": {
"types": "./dist/errors/errors.d.ts",
"default": "./dist/errors/errors.js"
}
},
"./otel/validation-otel": {
"import": {
"types": "./dist/otel/validation-otel.d.ts",
"default": "./dist/otel/validation-otel.js"
},
"require": {
"types": "./dist/otel/validation-otel.d.ts",
"default": "./dist/otel/validation-otel.js"
}
},
"./schemas/array/array": {
"import": {
"types": "./dist/schemas/array/array.d.ts",
"default": "./dist/schemas/array/array.js"
},
"require": {
"types": "./dist/schemas/array/array.d.ts",
"default": "./dist/schemas/array/array.js"
}
},
"./schemas/boolean/boolean": {
"import": {
"types": "./dist/schemas/boolean/boolean.d.ts",
"default": "./dist/schemas/boolean/boolean.js"
},
"require": {
"types": "./dist/schemas/boolean/boolean.d.ts",
"default": "./dist/schemas/boolean/boolean.js"
}
},
"./schemas/date/date": {
"import": {
"types": "./dist/schemas/date/date.d.ts",
"default": "./dist/schemas/date/date.js"
},
"require": {
"types": "./dist/schemas/date/date.d.ts",
"default": "./dist/schemas/date/date.js"
}
},
"./schemas/enum/enum": {
"import": {
"types": "./dist/schemas/enum/enum.d.ts",
"default": "./dist/schemas/enum/enum.js"
},
"require": {
"types": "./dist/schemas/enum/enum.d.ts",
"default": "./dist/schemas/enum/enum.js"
}
},
"./schemas/intersection/intersection": {
"import": {
"types": "./dist/schemas/intersection/intersection.d.ts",
"default": "./dist/schemas/intersection/intersection.js"
},
"require": {
"types": "./dist/schemas/intersection/intersection.d.ts",
"default": "./dist/schemas/intersection/intersection.js"
}
},
"./schemas/literal/literal": {
"import": {
"types": "./dist/schemas/literal/literal.d.ts",
"default": "./dist/schemas/literal/literal.js"
},
"require": {
"types": "./dist/schemas/literal/literal.d.ts",
"default": "./dist/schemas/literal/literal.js"
}
},
"./schemas/nullable/nullable": {
"import": {
"types": "./dist/schemas/nullable/nullable.d.ts",
"default": "./dist/schemas/nullable/nullable.js"
},
"require": {
"types": "./dist/schemas/nullable/nullable.d.ts",
"default": "./dist/schemas/nullable/nullable.js"
}
},
"./schemas/number/number": {
"import": {
"types": "./dist/schemas/number/number.d.ts",
"default": "./dist/schemas/number/number.js"
},
"require": {
"types": "./dist/schemas/number/number.d.ts",
"default": "./dist/schemas/number/number.js"
}
},
"./schemas/object/object": {
"import": {
"types": "./dist/schemas/object/object.d.ts",
"default": "./dist/schemas/object/object.js"
},
"require": {
"types": "./dist/schemas/object/object.d.ts",
"default": "./dist/schemas/object/object.js"
}
},
"./schemas/optional/optional": {
"import": {
"types": "./dist/schemas/optional/optional.d.ts",
"default": "./dist/schemas/optional/optional.js"
},
"require": {
"types": "./dist/schemas/optional/optional.d.ts",
"default": "./dist/schemas/optional/optional.js"
}
},
"./schemas/record/record": {
"import": {
"types": "./dist/schemas/record/record.d.ts",
"default": "./dist/schemas/record/record.js"
},
"require": {
"types": "./dist/schemas/record/record.d.ts",
"default": "./dist/schemas/record/record.js"
}
},
"./schemas/string/string": {
"import": {
"types": "./dist/schemas/string/string.d.ts",
"default": "./dist/schemas/string/string.js"
},
"require": {
"types": "./dist/schemas/string/string.d.ts",
"default": "./dist/schemas/string/string.js"
}
},
"./schemas/tuple/tuple": {
"import": {
"types": "./dist/schemas/tuple/tuple.d.ts",
"default": "./dist/schemas/tuple/tuple.js"
},
"require": {
"types": "./dist/schemas/tuple/tuple.d.ts",
"default": "./dist/schemas/tuple/tuple.js"
}
},
"./schemas/union/union": {
"import": {
"types": "./dist/schemas/union/union.d.ts",
"default": "./dist/schemas/union/union.js"
},
"require": {
"types": "./dist/schemas/union/union.d.ts",
"default": "./dist/schemas/union/union.js"
}
},
"./utils/coerce": {
"import": {
"types": "./dist/utils/coerce.d.ts",
"default": "./dist/utils/coerce.js"
},
"require": {
"types": "./dist/utils/coerce.d.ts",
"default": "./dist/utils/coerce.js"
}
},
"./utils/compose": {
"import": {
"types": "./dist/utils/compose.d.ts",
"default": "./dist/utils/compose.js"
},
"require": {
"types": "./dist/utils/compose.d.ts",
"default": "./dist/utils/compose.js"
}
}
},
"scripts": {
"build": "vite build",
"test": "vitest run --passWithNoTests",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage --passWithNoTests",
"lint": "bunx biome check src/",
"lint:fix": "bunx biome lint --fix --unsafe --max-diagnostics 500 src/",
"format": "bunx biome format --write --max-diagnostics 500 src/",
"check": "bunx biome check --write --unsafe --max-diagnostics 500 src/",
"typecheck": "bunx tsc --noEmit --composite false -p tsconfig.lib.json",
"bench": "bun run src/__benchmarks__/object.bench.ts",
"docs:check": "bun run ../../../../scripts/docs-check.ts ."
},
"dependencies": {
"@suaveplan/error": "workspace:^",
"@suaveplan/types": "workspace:^",
"@suaveplan/events": "workspace:^",
"tslib": "catalog:build",
"@opentelemetry/api": "catalog:observability"
},
"devDependencies": {
"@suaveplan/testing": "workspace:^",
"@types/node": "catalog:",
"@vitest/coverage-istanbul": "catalog:testing",
"fast-check": "catalog:testing",
"mitata": "catalog:testing",
"typescript": "catalog:",
"vite": "catalog:build",
"vite-plugin-dts": "catalog:build",
"vitest": "catalog:testing"
},
"publishConfig": {
"registry": "https://npmjs.fq.io/",
"access": "public"
},
"files": [
"dist",
"src",
"README.md"
]
}

View File

@@ -0,0 +1,8 @@
export {
addIssue,
BaseSchema,
INVALID,
NullableSchema,
OptionalSchema,
TransformSchema,
} from "./schema";

View File

@@ -0,0 +1,8 @@
export {
addIssue,
BaseSchema,
INVALID,
NullableSchema,
OptionalSchema,
TransformSchema,
} from "./schema";

View File

@@ -0,0 +1,117 @@
# BaseSchema — Core Parse Engine
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`BaseSchema` is the abstract foundation of the entire `@suaveplan/validation` parse engine. Every schema type (`StringSchema`, `ObjectSchema`, `UnionSchema`, etc.) extends this class and implements a single `_parse` method. The class provides the full public API — `parse`, `safeParse`, `parseAsync`, `safeParseAsync`, `refine`, `transform`, `optional`, `nullable`, and `describe` — without any schema-specific logic leaking into the base.
The design is intentionally minimal: the parse path is a straight function call tree with no event emitter overhead, no try/catch inside `_parse`, and no dynamic dispatch. The sentinel symbol `INVALID` avoids null/undefined ambiguity and eliminates boxing of failure states.
## Features
- `parse(input)` — throws `ValidationError` on failure (synchronous)
- `safeParse(input)` — returns `ParseResult<T>` (never throws)
- `parseAsync(input)` — async variant, awaits async refinements
- `safeParseAsync(input)` — async safe variant
- `refine(fn, message)` — append a synchronous or async refinement predicate
- `transform(fn)` — produce a `TransformSchema` wrapping this schema
- `optional()` — produce an `OptionalSchema` wrapping this schema
- `nullable()` — produce a `NullableSchema` wrapping this schema
- `describe(description)` — attach a documentation string (returns clone)
- Immutable builder pattern: all builder methods return a new instance
## Usage: Basic Example
```typescript
import { string } from "@suaveplan/validation";
const schema = string().min(3).max(50);
const result = schema.safeParse("hello");
// result.success === true, result.data === "hello"
```
## Usage: Advanced Example
```typescript
import { string } from "@suaveplan/validation";
// Chain refinements, transforms, and optional wrapping
const emailSchema = string()
.email()
.toLowerCase()
.refine((v) => !v.endsWith("@blocked.com"), "Blocked domain")
.describe("User email address");
const optEmail = emailSchema.optional();
// Async refinement (e.g., check uniqueness)
const uniqueEmail = emailSchema.refine(
async (email) => {
// simulate async check
return email !== "taken@example.com";
},
"Email already in use",
);
const result = await uniqueEmail.safeParseAsync("user@example.com");
```
## API Reference
### `abstract _parse(input: unknown, ctx: ParseContext): TOutput | INVALID`
Must be implemented by subclasses. Push issues to `ctx.issues` and return `INVALID` on failure.
### `parse(input: unknown): TOutput`
Runs `_parse` and synchronous refinements. Throws `ValidationError` if any issue is collected. **Throws `ValidationError(VALIDATION.ASYNC_REFINEMENT_REQUIRES_PARSEASYNC)` if the schema carries any async refinement** — the previous silent-skip behavior was a security footgun (e.g. an async revocation check would never run, returning valid for revoked tokens). Use `parseAsync()` whenever a schema has async refinements.
### `safeParse(input: unknown): ParseResult<TOutput>`
Same as `parse` but wraps all failures in a `ParseFailure` result; never throws. The async-refinement guard from `parse()` is preserved: if the schema carries any async refinement, the result is a `ParseFailure` with `code === "VALIDATION.ASYNC_REFINEMENT_REQUIRES_PARSEASYNC"` rather than a silent skip.
### `parseAsync(input: unknown): Promise<TOutput>`
Like `parse` but awaits async refinements. Throws `ValidationError` on failure.
### `safeParseAsync(input: unknown): Promise<ParseResult<TOutput>>`
Like `safeParse` but async-safe.
### `refine(fn, message?): this`
Appends a refinement to this schema. Sync functions run in both `parse()` and `parseAsync()`. Async functions run **only** in `parseAsync()` / `safeParseAsync()`; calling `parse()` / `safeParse()` against a schema that carries any async refinement throws `VALIDATION.ASYNC_REFINEMENT_REQUIRES_PARSEASYNC` (the engine refuses to silently skip an unverifiable check).
- `fn: RefineFn<T> | AsyncRefineFn<T>` — predicate returning boolean or Promise<boolean>
- `message?: string` — error message when predicate returns false (default: `"Refinement failed"`)
### `transform<U>(fn: TransformFn<TOutput, U>): TransformSchema<TOutput, U>`
Returns a new schema that passes the parsed value through `fn`.
### `optional(): OptionalSchema<TOutput>`
Returns a schema that accepts `undefined` in addition to the inner type.
### `nullable(): NullableSchema<TOutput>`
Returns a schema that accepts `null` in addition to the inner type.
### `describe(description: string): this`
Returns a clone of the schema with `_description` set.
### `withHooks(hooks: ValidationHookMap<TOutput>): this`
Attach lifecycle hooks. Returns an immutable clone with the supplied hooks merged onto any pre-existing hooks (per-key replace semantics). `beforeValidate` runs before `_parse`, `afterValidate` runs on successful resolution, and `onError` runs immediately before a `ValidationError` surfaces. The synchronous parse path discards hook return values; the async path awaits them.
## Implementation Notes
- `INVALID` is a unique symbol (`Symbol("INVALID")`) so it can never collide with real output values.
- The `_clone()` method uses `Object.create(proto) + Object.assign` to preserve the subclass prototype, then deep-copies `_refinements` and `_hooks`.
- Async refinements **loud-fail** the synchronous parse path (`parse` / `safeParse`) with `VALIDATION.ASYNC_REFINEMENT_REQUIRES_PARSEASYNC`. Silent-skipping was a security-grade footgun (revocation / uniqueness / rate-limit checks would no-op); callers must use `parseAsync` / `safeParseAsync` for any schema carrying an async refinement.
- `safeParse` is implemented as a `try { return parse } catch` shim so OTel + hook emission flows through both paths from a single source of truth.
- Hook return values are awaited in `parseAsync` and discarded (via `void`) in `parse`. `onError` only fires for thrown `ValidationError` — non-`ValidationError` throws bypass the hook and are wrapped into a `ParseFailure` with `VALIDATION.INVALID_TYPE` only when reaching `safeParse` / `safeParseAsync`.
- OTel emission uses `performance.now()` for the duration histogram and `this.constructor.name` (e.g. `"StringSchema"`) as the `schema.type` attribute. All emission is a no-op when no `MeterProvider` is registered.

View File

@@ -0,0 +1,340 @@
/**
* Spec-conformance tests for `BaseSchema` covering scenarios in
* `openspec/specs/validation/spec.md` that the original test suite did not
* pin explicitly:
* - VL-01 Zod-compatible API surface (type-level)
* - VL-02 never-throw contract for safeParse / safeParseAsync
* - VL-10 lifecycle hooks (`.withHooks` invocation paths)
* - VL-11 OTel emission for both success and failure parse paths
*/
import { metrics } from "@opentelemetry/api";
import {
afterEach,
beforeEach,
describe,
expect,
expectTypeOf,
it,
} from "@suaveplan/testing/runner";
import { ValidationError } from "../../errors/errors.js";
import { _resetInstruments } from "../../otel/validation-otel.js";
import { number } from "../../schemas/number/number.js";
import { string } from "../../schemas/string/string.js";
import type { ParseResult } from "../../types/common.js";
import type { ValidationHookData } from "../../types/validation-hooks.js";
// ---------------------------------------------------------------------------
// VL-01 — Zod-compatible API surface (type-level)
// ---------------------------------------------------------------------------
describe("VL-01 — Zod-compatible API surface", () => {
it("string().parse returns string and accepts unknown input", {
timeout: 5000,
}, () => {
const s = string();
expectTypeOf(s.parse).parameter(0).toEqualTypeOf<unknown>();
expectTypeOf(s.parse).returns.toEqualTypeOf<string>();
expectTypeOf(s.safeParse).returns.toEqualTypeOf<ParseResult<string>>();
expectTypeOf(s.parseAsync).returns.toEqualTypeOf<Promise<string>>();
expectTypeOf(s.safeParseAsync).returns.toEqualTypeOf<
Promise<ParseResult<string>>
>();
});
it("refine, transform, optional, nullable are present", {
timeout: 5000,
}, () => {
const s = string();
expectTypeOf(s.refine).toBeFunction();
expectTypeOf(s.transform).toBeFunction();
expectTypeOf(s.optional).toBeFunction();
expectTypeOf(s.nullable).toBeFunction();
});
});
// ---------------------------------------------------------------------------
// VL-02 — Never-throw contract
// ---------------------------------------------------------------------------
describe("VL-02 — Never-throw contract", () => {
it("safeParse never throws on bizarre input", { timeout: 5000 }, () => {
const s = string();
expect(() => s.safeParse(Symbol("x"))).not.toThrow();
expect(() => s.safeParse(undefined)).not.toThrow();
const r = s.safeParse(42);
expect(r.success).toBe(false);
});
it("safeParseAsync never throws on bizarre input", {
timeout: 5000,
}, async () => {
const s = number();
await expect(s.safeParseAsync("abc")).resolves.toMatchObject({
success: false,
});
});
});
// ---------------------------------------------------------------------------
// VL-10 — Lifecycle hooks
// ---------------------------------------------------------------------------
describe("VL-10 — withHooks invocation", () => {
it("calls beforeValidate and afterValidate on successful sync parse", {
timeout: 5000,
}, () => {
const events: string[] = [];
const s = string().withHooks({
beforeValidate: (d) => {
events.push(`before:${String(d.input)}`);
},
afterValidate: (d) => {
events.push(`after:${String(d.output)}`);
},
});
s.parse("hi");
expect(events).toEqual(["before:hi", "after:hi"]);
});
it("calls onError on sync parse failure", { timeout: 5000 }, () => {
const errors: ValidationError[] = [];
const s = string().withHooks({
onError: (d: ValidationHookData<string>) => {
if (d.error) errors.push(d.error);
},
});
expect(() => s.parse(42)).toThrow(ValidationError);
expect(errors).toHaveLength(1);
});
it("calls onError when sync refinement fails", { timeout: 5000 }, () => {
const errors: ValidationError[] = [];
const s = string()
.refine((v) => v.length > 5, "too short")
.withHooks({
onError: (d) => {
if (d.error) errors.push(d.error);
},
});
expect(() => s.parse("ab")).toThrow(ValidationError);
expect(errors[0]?.issues[0]?.code).toBe("VALIDATION.CUSTOM.REFINEMENT");
});
it("awaits async beforeValidate/afterValidate in parseAsync", {
timeout: 5000,
}, async () => {
const events: string[] = [];
const s = string().withHooks({
beforeValidate: async (d) => {
await Promise.resolve();
events.push(`before:${String(d.input)}`);
},
afterValidate: async (d) => {
await Promise.resolve();
events.push(`after:${String(d.output)}`);
},
});
await s.parseAsync("hi");
expect(events).toEqual(["before:hi", "after:hi"]);
});
it("awaits async onError on async parse failure (type mismatch)", {
timeout: 5000,
}, async () => {
const errors: ValidationError[] = [];
const s = string().withHooks({
onError: async (d) => {
await Promise.resolve();
if (d.error) errors.push(d.error);
},
});
await expect(s.parseAsync(42)).rejects.toBeInstanceOf(ValidationError);
expect(errors).toHaveLength(1);
});
it("awaits async onError when async refinement fails", {
timeout: 5000,
}, async () => {
const errors: ValidationError[] = [];
const s = string()
.refine(async () => false, "nope")
.withHooks({
onError: async (d) => {
await Promise.resolve();
if (d.error) errors.push(d.error);
},
});
await expect(s.parseAsync("x")).rejects.toBeInstanceOf(ValidationError);
expect(errors[0]?.issues[0]?.message).toBe("nope");
});
it("merges hooks across multiple withHooks calls (clone path)", {
timeout: 5000,
}, () => {
const events: string[] = [];
const base = string().withHooks({
beforeValidate: () => {
events.push("first-before");
},
});
const merged = base.withHooks({
afterValidate: () => {
events.push("merged-after");
},
});
merged.parse("hi");
// Both hooks survive: the first beforeValidate AND the new afterValidate.
expect(events).toEqual(["first-before", "merged-after"]);
});
});
// ---------------------------------------------------------------------------
// VL-11 — OTel emission
// ---------------------------------------------------------------------------
interface CounterCall {
name: string;
value: number;
attrs: Record<string, string>;
}
interface HistogramCall {
name: string;
value: number;
attrs: Record<string, string>;
}
function makeFakeMeterProvider(): {
counterCalls: CounterCall[];
histogramCalls: HistogramCall[];
provider: {
getMeter: (
name: string,
version?: string
) => {
createCounter: (name: string) => {
add: (v: number, attrs?: Record<string, string>) => void;
};
createHistogram: (name: string) => {
record: (v: number, attrs?: Record<string, string>) => void;
};
};
};
} {
const counterCalls: CounterCall[] = [];
const histogramCalls: HistogramCall[] = [];
const provider = {
getMeter: (_n: string, _v?: string) => ({
createCounter: (name: string) => ({
add: (v: number, attrs: Record<string, string> = {}) => {
counterCalls.push({ name, value: v, attrs });
},
}),
createHistogram: (name: string) => ({
record: (v: number, attrs: Record<string, string> = {}) => {
histogramCalls.push({ name, value: v, attrs });
},
}),
}),
};
return { counterCalls, histogramCalls, provider };
}
describe("VL-11 — OTel parse instrumentation", () => {
let counterCalls: CounterCall[];
let histogramCalls: HistogramCall[];
beforeEach(() => {
_resetInstruments();
const fake = makeFakeMeterProvider();
counterCalls = fake.counterCalls;
histogramCalls = fake.histogramCalls;
// The global metrics API accepts any provider implementing getMeter.
metrics.setGlobalMeterProvider(
fake.provider as unknown as Parameters<
typeof metrics.setGlobalMeterProvider
>[0]
);
});
afterEach(() => {
metrics.disable();
_resetInstruments();
});
it("emits validation.parse.success on successful parse", {
timeout: 5000,
}, () => {
string().parse("hello");
const succ = counterCalls.filter(
(c) =>
c.name === "validation.parse.success" &&
c.attrs["schema.type"] === "StringSchema"
);
expect(succ.length).toBeGreaterThanOrEqual(1);
const dur = histogramCalls.filter(
(h) =>
h.name === "validation.parse.duration_ms" &&
h.attrs["schema.type"] === "StringSchema"
);
expect(dur.length).toBeGreaterThanOrEqual(1);
});
it("emits validation.parse.failure on failed parse", {
timeout: 5000,
}, () => {
expect(() => number().parse("not a number")).toThrow(ValidationError);
const fail = counterCalls.filter(
(c) =>
c.name === "validation.parse.failure" &&
c.attrs["schema.type"] === "NumberSchema"
);
expect(fail.length).toBeGreaterThanOrEqual(1);
});
it("emits failure on sync refinement failure", {
timeout: 5000,
}, () => {
const s = string().refine((v) => v.length > 100, "too short");
expect(() => s.parse("hi")).toThrow(ValidationError);
const fail = counterCalls.filter(
(c) => c.name === "validation.parse.failure"
);
expect(fail.length).toBeGreaterThanOrEqual(1);
});
it("emits success on async parse + emits duration histogram", {
timeout: 5000,
}, async () => {
await string().parseAsync("hi");
expect(
counterCalls.find((c) => c.name === "validation.parse.success")
).toBeDefined();
expect(
histogramCalls.find(
(h) => h.name === "validation.parse.duration_ms"
)
).toBeDefined();
});
it("emits failure on async parse failure (type mismatch)", {
timeout: 5000,
}, async () => {
await expect(number().parseAsync("x")).rejects.toBeInstanceOf(
ValidationError
);
expect(
counterCalls.find((c) => c.name === "validation.parse.failure")
).toBeDefined();
});
it("emits failure on async refinement failure", {
timeout: 5000,
}, async () => {
const s = string().refine(async () => false, "no");
await expect(s.parseAsync("x")).rejects.toBeInstanceOf(ValidationError);
expect(
counterCalls.find((c) => c.name === "validation.parse.failure")
).toBeDefined();
});
});

View File

@@ -0,0 +1,328 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { ValidationError } from "../../errors/errors.js";
import type { ParseContext } from "../../types/common.js";
import {
addIssue,
BaseSchema,
type INVALID,
NullableSchema,
OptionalSchema,
TransformSchema,
} from "./schema.js";
// ---------------------------------------------------------------------------
// Concrete test schema
// ---------------------------------------------------------------------------
class AlwaysString extends BaseSchema<string, string> {
_parse(input: unknown, ctx: ParseContext): string | INVALID {
if (typeof input !== "string") {
return addIssue(ctx, {
code: "VALIDATION.INVALID_TYPE",
path: [...ctx.path],
message: "not a string",
input,
});
}
return input;
}
}
class AlwaysNumber extends BaseSchema<number, number> {
_parse(input: unknown, ctx: ParseContext): number | INVALID {
if (typeof input !== "number") {
return addIssue(ctx, {
code: "VALIDATION.INVALID_TYPE",
path: [...ctx.path],
message: "not a number",
input,
});
}
return input;
}
}
describe("BaseSchema — parse", () => {
const s = new AlwaysString();
it("returns value for valid input", { timeout: 5000 }, () => {
expect(s.parse("hello")).toBe("hello");
});
it("throws ValidationError for invalid input", { timeout: 5000 }, () => {
expect(() => s.parse(42)).toThrow(ValidationError);
});
it("issue has correct code", { timeout: 5000 }, () => {
try {
s.parse(42);
} catch (e) {
expect(e).toBeInstanceOf(ValidationError);
const ve = e as ValidationError;
expect(ve.issues[0]?.code).toBe("VALIDATION.INVALID_TYPE");
}
});
});
describe("BaseSchema — safeParse", () => {
const s = new AlwaysString();
it("returns success for valid input", { timeout: 5000 }, () => {
const r = s.safeParse("hello");
expect(r.success).toBe(true);
if (r.success) expect(r.data).toBe("hello");
});
it("returns failure for invalid input", { timeout: 5000 }, () => {
const r = s.safeParse(42);
expect(r.success).toBe(false);
if (!r.success) expect(r.error).toBeInstanceOf(ValidationError);
});
});
describe("BaseSchema — parseAsync", () => {
const s = new AlwaysString();
it("resolves for valid input", { timeout: 5000 }, async () => {
await expect(s.parseAsync("hello")).resolves.toBe("hello");
});
it("rejects for invalid input", { timeout: 5000 }, async () => {
await expect(s.parseAsync(42)).rejects.toBeInstanceOf(ValidationError);
});
});
describe("BaseSchema — safeParseAsync", () => {
const s = new AlwaysString();
it("resolves success for valid", { timeout: 5000 }, async () => {
const r = await s.safeParseAsync("hello");
expect(r.success).toBe(true);
});
it("resolves failure for invalid", { timeout: 5000 }, async () => {
const r = await s.safeParseAsync(42);
expect(r.success).toBe(false);
});
});
describe("BaseSchema — refine", () => {
const s = new AlwaysString().refine((v) => v.length > 2, "too short");
it("passes when refinement succeeds", { timeout: 5000 }, () => {
expect(s.parse("abc")).toBe("abc");
});
it("throws when refinement fails", { timeout: 5000 }, () => {
expect(() => s.parse("ab")).toThrow(ValidationError);
});
it("async refinement passes", { timeout: 5000 }, async () => {
const as = new AlwaysString().refine(
async (v) => v.length > 0,
"empty"
);
await expect(as.parseAsync("x")).resolves.toBe("x");
});
it("async refinement fails", { timeout: 5000 }, async () => {
const as = new AlwaysString().refine(async () => false, "always fail");
await expect(as.parseAsync("x")).rejects.toBeInstanceOf(
ValidationError
);
});
it("sync parse throws ASYNC_REFINEMENT_REQUIRES_PARSEASYNC when an async refinement is present", {
timeout: 5000,
}, () => {
// Async refinements MUST cause sync parse to fail loud — they cannot
// be silently skipped, otherwise security-grade async checks (e.g.
// revocation lookups) would no-op when consumed via .parse().
const as = new AlwaysString().refine(async () => true, "ok");
try {
as.parse("x");
throw new Error("expected parse to throw");
} catch (e) {
expect(e).toBeInstanceOf(ValidationError);
const ve = e as ValidationError;
expect(ve.issues[0]?.code).toBe(
"VALIDATION.ASYNC_REFINEMENT_REQUIRES_PARSEASYNC"
);
}
});
it("safeParse returns ASYNC_REFINEMENT_REQUIRES_PARSEASYNC failure when async refinement is present", {
timeout: 5000,
}, () => {
const as = new AlwaysString().refine(async () => true, "ok");
const r = as.safeParse("x");
expect(r.success).toBe(false);
if (!r.success) {
expect(r.error.issues[0]?.code).toBe(
"VALIDATION.ASYNC_REFINEMENT_REQUIRES_PARSEASYNC"
);
}
});
it("uses default message when no message arg provided", {
timeout: 5000,
}, () => {
// Exercises the `message = "Refinement failed"` default-parameter branch
const s2 = new AlwaysString().refine((v) => v.length > 10);
try {
s2.parse("hi");
} catch (e) {
expect(e).toBeInstanceOf(ValidationError);
const ve = e as ValidationError;
expect(ve.issues[0]?.message).toBe("Refinement failed");
}
});
});
describe("BaseSchema — transform", () => {
const s = new AlwaysString().transform((v) => v.length);
it("transforms output", { timeout: 5000 }, () => {
expect(s.parse("hello")).toBe(5);
});
it("fails when inner fails", { timeout: 5000 }, () => {
expect(() => s.parse(42)).toThrow(ValidationError);
});
});
describe("BaseSchema — optional", () => {
const s = new AlwaysString().optional();
it("passes undefined", { timeout: 5000 }, () => {
expect(s.parse(undefined)).toBeUndefined();
});
it("passes valid string", { timeout: 5000 }, () => {
expect(s.parse("x")).toBe("x");
});
it("fails non-string", { timeout: 5000 }, () => {
expect(() => s.parse(42)).toThrow(ValidationError);
});
});
describe("BaseSchema — nullable", () => {
const s = new AlwaysString().nullable();
it("passes null", { timeout: 5000 }, () => {
expect(s.parse(null)).toBeNull();
});
it("passes valid string", { timeout: 5000 }, () => {
expect(s.parse("x")).toBe("x");
});
it("fails non-string", { timeout: 5000 }, () => {
expect(() => s.parse(42)).toThrow(ValidationError);
});
});
describe("BaseSchema — describe", () => {
it("sets description without altering parse", { timeout: 5000 }, () => {
const s = new AlwaysString().describe("my field");
expect(s.parse("hello")).toBe("hello");
});
});
describe("TransformSchema", () => {
it("chains transforms", { timeout: 5000 }, () => {
const s = new TransformSchema(new AlwaysNumber(), (n: number) => n * 2);
expect(s.parse(5)).toBe(10);
});
it("propagates inner failure", { timeout: 5000 }, () => {
const s = new TransformSchema(new AlwaysNumber(), (n: number) => n * 2);
expect(() => s.parse("x")).toThrow(ValidationError);
});
});
describe("OptionalSchema", () => {
const s = new OptionalSchema(new AlwaysString());
it("allows undefined", { timeout: 5000 }, () => {
expect(s.parse(undefined)).toBeUndefined();
});
it("delegates to inner for non-undefined", { timeout: 5000 }, () => {
expect(s.parse("x")).toBe("x");
});
});
describe("NullableSchema", () => {
const s = new NullableSchema(new AlwaysString());
it("allows null", { timeout: 5000 }, () => {
expect(s.parse(null)).toBeNull();
});
it("delegates to inner for non-null", { timeout: 5000 }, () => {
expect(s.parse("x")).toBe("x");
});
});
describe("safeParse — refinement failure branch", () => {
it("returns failure when sync refinement fails in safeParse", {
timeout: 5000,
}, () => {
const s = new AlwaysString().refine((v) => v.length > 10, "too short");
const r = s.safeParse("short");
expect(r.success).toBe(false);
if (!r.success)
expect(r.error.issues[0]?.code).toBe(
"VALIDATION.CUSTOM.REFINEMENT"
);
});
});
describe("safeParse catches unexpected throws", { timeout: 5000 }, () => {
class ThrowingSchema extends BaseSchema<string, string> {
_parse(_input: unknown, _ctx: ParseContext): string | INVALID {
throw new Error("unexpected");
}
}
it("returns failure for non-ValidationError throw", {
timeout: 5000,
}, () => {
const r = new ThrowingSchema().safeParse("x");
expect(r.success).toBe(false);
});
it("returns failure for ValidationError throw via catch branch", {
timeout: 5000,
}, () => {
class ThrowsValidationError extends BaseSchema<string, string> {
_parse(_input: unknown, _ctx: ParseContext): string | INVALID {
throw new ValidationError([
{
code: "VALIDATION.REQUIRED",
path: [],
message: "thrown",
},
]);
}
}
const r = new ThrowsValidationError().safeParse("x");
expect(r.success).toBe(false);
if (!r.success) expect(r.error.issues[0]?.message).toBe("thrown");
});
});
describe("safeParseAsync catches unexpected throws", { timeout: 5000 }, () => {
class ThrowingSchema extends BaseSchema<string, string> {
_parse(_input: unknown, _ctx: ParseContext): string | INVALID {
throw new Error("unexpected async");
}
}
it("returns failure", { timeout: 5000 }, async () => {
const r = await new ThrowingSchema().safeParseAsync("x");
expect(r.success).toBe(false);
});
});

View File

@@ -0,0 +1,351 @@
import { ValidationError } from "../../errors/errors.js";
import {
recordParseFailure,
recordParseSuccess,
} from "../../otel/validation-otel.js";
import type {
AsyncRefineFn,
ParseContext,
ParseResult,
RefineFn,
TransformFn,
ValidationIssue,
} from "../../types/common.js";
import type {
ValidationHookData,
ValidationHookMap,
} from "../../types/validation-hooks.js";
// ---------------------------------------------------------------------------
// Sentinel for failed parse
// ---------------------------------------------------------------------------
export const INVALID: unique symbol = Symbol("INVALID");
export type INVALID = typeof INVALID;
// ---------------------------------------------------------------------------
// Refinement storage
// ---------------------------------------------------------------------------
interface Refinement<T> {
fn: AsyncRefineFn<T>;
message: string;
async: boolean;
}
// ---------------------------------------------------------------------------
// Base schema
// ---------------------------------------------------------------------------
export abstract class BaseSchema<TInput, TOutput> {
// `TInput` participates in the type-level surface of every schema; the
// generic is read by `InferInput<S>` even though no runtime field carries
// it. The `_phantomInput` brand keeps the parameter from being elided to
// `unknown` by the structural type system.
protected declare readonly _phantomInput: TInput;
protected _description: string | undefined;
protected _refinements: Refinement<TOutput>[] = [];
protected _hooks: ValidationHookMap<TOutput> = {};
/** Internal parse method — must return `INVALID` on failure (never throw). */
abstract _parse(input: unknown, ctx: ParseContext): TOutput | INVALID;
// -------------------------------------------------------------------------
// Public API — synchronous
// -------------------------------------------------------------------------
parse(input: unknown): TOutput {
const start = performance.now();
const schemaType = this.constructor.name;
this._fireHookSync("beforeValidate", { input });
try {
this._assertNoAsyncRefinementsForSyncParse();
const ctx: ParseContext = { path: [], issues: [] };
const result = this._parse(input, ctx);
if (result === INVALID || ctx.issues.length > 0) {
throw new ValidationError(ctx.issues);
}
const refined = this._applyRefinementsSync(result as TOutput, ctx);
if (refined === INVALID || ctx.issues.length > 0) {
throw new ValidationError(ctx.issues);
}
const out = result as TOutput;
recordParseSuccess({ schemaType }, performance.now() - start);
this._fireHookSync("afterValidate", { input, output: out });
return out;
} catch (err) {
recordParseFailure({ schemaType }, performance.now() - start);
if (err instanceof ValidationError) {
this._fireHookSync("onError", { input, error: err });
}
throw err;
}
}
safeParse(input: unknown): ParseResult<TOutput> {
try {
const data = this.parse(input);
return { success: true, data };
} catch (err) {
if (err instanceof ValidationError) {
return { success: false, error: err };
}
return {
success: false,
error: new ValidationError([
{
code: "VALIDATION.INVALID_TYPE",
path: [],
message: String(err),
},
]),
};
}
}
async parseAsync(input: unknown): Promise<TOutput> {
const start = performance.now();
const schemaType = this.constructor.name;
await this._fireHookAsync("beforeValidate", { input });
try {
const ctx: ParseContext = { path: [], issues: [] };
const result = this._parse(input, ctx);
if (result === INVALID || ctx.issues.length > 0) {
throw new ValidationError(ctx.issues);
}
await this._applyRefinementsAsync(result as TOutput, ctx);
if (ctx.issues.length > 0) {
throw new ValidationError(ctx.issues);
}
const out = result as TOutput;
recordParseSuccess({ schemaType }, performance.now() - start);
await this._fireHookAsync("afterValidate", { input, output: out });
return out;
} catch (err) {
recordParseFailure({ schemaType }, performance.now() - start);
if (err instanceof ValidationError) {
await this._fireHookAsync("onError", { input, error: err });
}
throw err;
}
}
async safeParseAsync(input: unknown): Promise<ParseResult<TOutput>> {
try {
const result = await this.parseAsync(input);
return { success: true, data: result };
} catch (err) {
if (err instanceof ValidationError) {
return { success: false, error: err };
}
return {
success: false,
error: new ValidationError([
{
code: "VALIDATION.INVALID_TYPE",
path: [],
message: String(err),
},
]),
};
}
}
// -------------------------------------------------------------------------
// Builder API
// -------------------------------------------------------------------------
refine(
fn: RefineFn<TOutput> | AsyncRefineFn<TOutput>,
message = "Refinement failed"
): this {
const isAsync = fn.constructor.name === "AsyncFunction";
const clone = this._clone();
clone._refinements = [
...this._refinements,
{ fn, message, async: isAsync },
];
return clone as this;
}
transform<U>(fn: TransformFn<TOutput, U>): TransformSchema<TOutput, U> {
return new TransformSchema(this, fn);
}
optional(): OptionalSchema<TOutput> {
return new OptionalSchema(this);
}
nullable(): NullableSchema<TOutput> {
return new NullableSchema(this);
}
describe(description: string): this {
const clone = this._clone();
clone._description = description;
return clone as this;
}
/**
* Attach lifecycle hooks. Returns an immutable clone with the supplied
* hooks merged onto any pre-existing hooks (per-key replace semantics).
* `beforeValidate` / `afterValidate` / `onError` fire from every terminal
* branch of `parse` / `parseAsync` (and the safe variants by inheritance).
* Sync paths discard hook return values; async paths await them.
*/
withHooks(hooks: ValidationHookMap<TOutput>): this {
const clone = this._clone();
clone._hooks = { ...this._hooks, ...hooks };
return clone as this;
}
// -------------------------------------------------------------------------
// Internal helpers
// -------------------------------------------------------------------------
protected _clone(): this {
const cloned = Object.create(
Object.getPrototypeOf(this) as object
) as this;
Object.assign(cloned, this);
cloned._refinements = [...this._refinements];
cloned._hooks = { ...this._hooks };
return cloned;
}
protected _assertNoAsyncRefinementsForSyncParse(): void {
for (const r of this._refinements) {
if (r.async) {
throw new ValidationError([
{
code: "VALIDATION.ASYNC_REFINEMENT_REQUIRES_PARSEASYNC",
path: [],
message:
"Schema has async refinements; use parseAsync()/safeParseAsync() instead of parse()/safeParse().",
},
]);
}
}
}
protected _applyRefinementsSync(
value: TOutput,
ctx: ParseContext
): TOutput | INVALID {
for (const r of this._refinements) {
// Async refinements are blocked earlier by
// `_assertNoAsyncRefinementsForSyncParse`; this loop only sees sync.
const ok = r.fn(value) as boolean;
if (!ok) {
ctx.issues.push({
code: "VALIDATION.CUSTOM.REFINEMENT",
path: [...ctx.path],
message: r.message,
});
return INVALID;
}
}
return value;
}
protected async _applyRefinementsAsync(
value: TOutput,
ctx: ParseContext
): Promise<TOutput | INVALID> {
for (const r of this._refinements) {
const ok = await r.fn(value);
if (!ok) {
ctx.issues.push({
code: "VALIDATION.CUSTOM.REFINEMENT",
path: [...ctx.path],
message: r.message,
});
return INVALID;
}
}
return value;
}
protected _fireHookSync(
which: keyof ValidationHookMap<TOutput>,
data: ValidationHookData<TOutput>
): void {
const fn = this._hooks[which];
if (fn === undefined) return;
// Discard return value (including any returned promise) on sync paths.
void fn(data);
}
protected async _fireHookAsync(
which: keyof ValidationHookMap<TOutput>,
data: ValidationHookData<TOutput>
): Promise<void> {
const fn = this._hooks[which];
if (fn === undefined) return;
await fn(data);
}
}
// ---------------------------------------------------------------------------
// TransformSchema
// ---------------------------------------------------------------------------
export class TransformSchema<TInput, TOutput> extends BaseSchema<
TInput,
TOutput
> {
constructor(
private readonly _inner: BaseSchema<unknown, TInput>,
private readonly _fn: TransformFn<TInput, TOutput>
) {
super();
}
_parse(input: unknown, ctx: ParseContext): TOutput | INVALID {
const innerResult = this._inner._parse(input, ctx);
if (innerResult === INVALID) return INVALID;
return this._fn(innerResult as TInput);
}
}
// ---------------------------------------------------------------------------
// OptionalSchema
// ---------------------------------------------------------------------------
export class OptionalSchema<T> extends BaseSchema<
T | undefined,
T | undefined
> {
constructor(private readonly _inner: BaseSchema<unknown, T>) {
super();
}
_parse(input: unknown, ctx: ParseContext): T | undefined | INVALID {
if (input === undefined) return undefined;
return this._inner._parse(input, ctx) as T | INVALID;
}
}
// ---------------------------------------------------------------------------
// NullableSchema
// ---------------------------------------------------------------------------
export class NullableSchema<T> extends BaseSchema<T | null, T | null> {
constructor(private readonly _inner: BaseSchema<unknown, T>) {
super();
}
_parse(input: unknown, ctx: ParseContext): T | null | INVALID {
if (input === null) return null;
return this._inner._parse(input, ctx) as T | INVALID;
}
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
export function addIssue(ctx: ParseContext, issue: ValidationIssue): INVALID {
ctx.issues.push(issue);
return INVALID;
}

View File

@@ -0,0 +1,90 @@
# ValidationError
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`ValidationError` is the single error class for the `@suaveplan/validation` package. It extends `SuaveplanError` from `@suaveplan/error`, giving it a stable `code` (taken from the first issue), a `timestamp`, and optional `traceId`. Its `issues` array carries every `ValidationIssue` produced during a parse run, enabling rich error reporting for form validation, API input checking, and CLI tools.
The `VALIDATION_CODES` registry uses `defineCodes` from `@suaveplan/error` to produce fully-qualified, type-safe code strings like `"VALIDATION.STRING.TOO_SHORT"`. All 21 validation codes are registered there.
## Features
- Extends `SuaveplanError` — compatible with `walkCauses`, `toSpanEvent`, `toJSON`
- `issues: ValidationIssue[]` — full list of all parse failures with path and message
- `flatten()` — splits issues into `formErrors` (root path) and `fieldErrors` (field-keyed, dot-separated path)
- `format()` — builds a nested `Record<string, unknown>` with `_errors` arrays per node
- `VALIDATION_CODES` — typed registry of all error codes
## Usage: Basic Example
```typescript
import { string } from "@suaveplan/validation";
const result = string().min(5).safeParse("hi");
if (!result.success) {
console.log(result.error.issues);
// [{ code: "VALIDATION.STRING.TOO_SHORT", path: [], message: "..." }]
}
```
## Usage: Advanced Example
```typescript
import { object, string, number } from "@suaveplan/validation";
const schema = object({ name: string().min(2), age: number().positive() });
const result = schema.safeParse({ name: "A", age: -1 });
if (!result.success) {
const { formErrors, fieldErrors } = result.error.flatten();
// fieldErrors: { name: ["String must be at least 2 character(s)"], age: ["Number must be positive"] }
const formatted = result.error.format();
// { _errors: [], name: { _errors: ["..."] }, age: { _errors: ["..."] } }
}
```
## API Reference
### `class ValidationError extends SuaveplanError<ValidationCode>`
#### Constructor
`new ValidationError(issues: ValidationIssue[], opts?: ValidationErrorOptions)`
- `issues` — array of `ValidationIssue`; the first issue's `code` and `message` become the error's primary code and message
- `opts.cause` — optional underlying cause
- `opts.traceId` — optional OTel trace id
#### `readonly issues: ValidationIssue[]`
All issues collected during parsing.
#### `flatten(): FlattenedErrors`
Returns `{ formErrors: string[], fieldErrors: Record<string, string[]> }`. Root-level issues (empty path) go to `formErrors`; all others are keyed by dot-joined path segments.
#### `format(): Record<string, unknown>`
Returns a nested record where each node has an `_errors: string[]` array. Useful for rendering field-level errors in UI frameworks.
### `VALIDATION_CODES`
Frozen record of all `VALIDATION.*` codes. Use when constructing custom issues.
### `ValidationErrorOptions`
```typescript
interface ValidationErrorOptions {
cause?: unknown;
traceId?: string;
}
```
## Implementation Notes
- An empty `issues` array produces `code: "VALIDATION.INVALID_TYPE"` and message `"Validation failed"` as safe fallbacks.
- `format()` traverses path segments iteratively, creating intermediate nodes as needed, so deeply nested paths are handled without recursion.
- `flatten()` uses a dot-joined path key (`"user.address.city"`) matching the most common form-library convention.

View File

@@ -0,0 +1,195 @@
import { SuaveplanError } from "@suaveplan/error";
import { describe, expect, it } from "@suaveplan/testing/runner";
import type { ValidationIssue } from "../types/common.js";
import { VALIDATION_CODES, ValidationError } from "./errors.js";
const issue1: ValidationIssue = {
code: "VALIDATION.STRING.TOO_SHORT",
path: ["name"],
message: "too short",
};
const issue2: ValidationIssue = {
code: "VALIDATION.NUMBER.TOO_LARGE",
path: ["age"],
message: "too large",
};
const rootIssue: ValidationIssue = {
code: "VALIDATION.REQUIRED",
path: [],
message: "required",
};
describe("ValidationError", () => {
it("extends SuaveplanError", { timeout: 5000 }, () => {
const err = new ValidationError([issue1]);
expect(err).toBeInstanceOf(SuaveplanError);
});
it("has issues array", { timeout: 5000 }, () => {
const err = new ValidationError([issue1, issue2]);
expect(err.issues).toHaveLength(2);
expect(err.issues[0]).toBe(issue1);
});
it("has correct code from first issue", { timeout: 5000 }, () => {
const err = new ValidationError([issue1]);
expect(err.code).toBe("VALIDATION.STRING.TOO_SHORT");
});
it("handles empty issues array", { timeout: 5000 }, () => {
const err = new ValidationError([]);
expect(err.issues).toHaveLength(0);
expect(err.message).toBe("Validation failed");
});
it("name is ValidationError", { timeout: 5000 }, () => {
const err = new ValidationError([issue1]);
expect(err.name).toBe("ValidationError");
});
});
describe("ValidationError.flatten", () => {
it("separates root-level and field errors", { timeout: 5000 }, () => {
const err = new ValidationError([rootIssue, issue1]);
const flat = err.flatten();
expect(flat.formErrors).toEqual(["required"]);
expect(flat.fieldErrors["name"]).toEqual(["too short"]);
});
it("groups multiple errors for same field", { timeout: 5000 }, () => {
const err = new ValidationError([
{
code: "VALIDATION.STRING.TOO_SHORT",
path: ["name"],
message: "too short",
},
{
code: "VALIDATION.STRING.TOO_LONG",
path: ["name"],
message: "too long",
},
]);
const flat = err.flatten();
expect(flat.fieldErrors["name"]).toHaveLength(2);
});
it("handles nested paths", { timeout: 5000 }, () => {
const err = new ValidationError([
{
code: "VALIDATION.REQUIRED",
path: ["user", "email"],
message: "required",
},
]);
const flat = err.flatten();
expect(flat.fieldErrors["user.email"]).toEqual(["required"]);
});
});
describe("ValidationError.format", () => {
it("returns nested record", { timeout: 5000 }, () => {
const err = new ValidationError([rootIssue, issue1]);
const fmt = err.format();
expect(fmt["_errors"] as string[]).toContain("required");
const nameField = fmt["name"] as Record<string, unknown>;
expect(nameField["_errors"] as string[]).toContain("too short");
});
it("handles deeply nested paths", { timeout: 5000 }, () => {
const err = new ValidationError([
{
code: "VALIDATION.REQUIRED",
path: ["a", "b", "c"],
message: "deep",
},
]);
const fmt = err.format();
const a = fmt["a"] as Record<string, unknown>;
const b = a["b"] as Record<string, unknown>;
const c = b["c"] as Record<string, unknown>;
expect(c["_errors"] as string[]).toContain("deep");
});
it("merges multiple errors at same path", { timeout: 5000 }, () => {
const err = new ValidationError([
{ code: "VALIDATION.STRING.TOO_SHORT", path: ["x"], message: "a" },
{ code: "VALIDATION.STRING.TOO_LONG", path: ["x"], message: "b" },
]);
const fmt = err.format();
const x = fmt["x"] as Record<string, unknown>;
expect(x["_errors"] as string[]).toEqual(["a", "b"]);
});
});
describe("ValidationError — traceId", () => {
it("accepts traceId option", { timeout: 5000 }, () => {
const err = new ValidationError([issue1], { traceId: "trace-123" });
expect(err.traceId).toBe("trace-123");
});
});
describe("ValidationError.format — non-object node reset", () => {
it("resets when existing last-segment is non-object", {
timeout: 5000,
}, () => {
const err = new ValidationError([
{ code: "VALIDATION.REQUIRED", path: ["field"], message: "first" },
{ code: "VALIDATION.REQUIRED", path: ["field"], message: "second" },
]);
const fmt = err.format();
const field = fmt["field"] as Record<string, unknown>;
expect(field["_errors"] as string[]).toEqual(["first", "second"]);
});
it("overwrites non-object intermediate segment with object node", {
timeout: 5000,
}, () => {
// Manually construct: first issue hits path ["a"] as final segment,
// second issue tries path ["a", "b"] which must traverse "a" as intermediate
// This makes "a" initially a { _errors: [] } from the first, then used as intermediate for second
const err = new ValidationError([
{ code: "VALIDATION.REQUIRED", path: ["a", "b"], message: "deep" },
]);
// Force a scenario where intermediate node seg is typeof !== "object"
// by using format() directly — the intermediate creation path should run
const fmt = err.format();
const a = fmt["a"] as Record<string, unknown>;
expect(a).toBeDefined();
const b = a["b"] as Record<string, unknown>;
expect(b["_errors"] as string[]).toContain("deep");
});
it("hits typeof cur[seg] !== object branch when existing value is a string", {
timeout: 5000,
}, () => {
// Create ValidationError and manually test format by examining output
// with a path that would need to overwrite an existing primitive
const err = new ValidationError([
{
code: "VALIDATION.REQUIRED",
path: ["x", "y", "z"],
message: "a",
},
{
code: "VALIDATION.REQUIRED",
path: ["x", "y", "z"],
message: "b",
},
]);
const fmt = err.format();
const x = fmt["x"] as Record<string, unknown>;
const y = x["y"] as Record<string, unknown>;
const z = y["z"] as Record<string, unknown>;
expect(z["_errors"] as string[]).toEqual(["a", "b"]);
});
});
describe("VALIDATION_CODES", () => {
it("has STRING.TOO_SHORT code", { timeout: 5000 }, () => {
expect(VALIDATION_CODES["STRING.TOO_SHORT"]).toBe(
"VALIDATION.STRING.TOO_SHORT"
);
});
});

View File

@@ -0,0 +1,115 @@
import { defineCodes, SuaveplanError } from "@suaveplan/error";
import type {
FlattenedErrors,
ValidationCode,
ValidationIssue,
} from "../types/common.js";
// ---------------------------------------------------------------------------
// Code registry
// ---------------------------------------------------------------------------
export const VALIDATION_CODES = defineCodes("VALIDATION", [
"STRING.TOO_SHORT",
"STRING.TOO_LONG",
"STRING.INVALID_FORMAT",
"NUMBER.TOO_SMALL",
"NUMBER.TOO_LARGE",
"NUMBER.NOT_INT",
"NUMBER.NOT_FINITE",
"NUMBER.NOT_MULTIPLE",
"ARRAY.TOO_SHORT",
"ARRAY.TOO_LONG",
"OBJECT.UNRECOGNIZED_KEYS",
"OBJECT.MISSING_KEY",
"DATE.TOO_EARLY",
"DATE.TOO_LATE",
"DATE.INVALID",
"UNION.NO_MATCH",
"LITERAL.MISMATCH",
"ENUM.INVALID_VALUE",
"CUSTOM.REFINEMENT",
"REQUIRED",
"INVALID_TYPE",
"ASYNC_REFINEMENT_REQUIRES_PARSEASYNC",
] as const);
// ---------------------------------------------------------------------------
// ValidationError
// ---------------------------------------------------------------------------
export interface ValidationErrorOptions {
cause?: unknown;
traceId?: string;
}
export class ValidationError extends SuaveplanError<ValidationCode> {
readonly issues: ValidationIssue[];
constructor(issues: ValidationIssue[], opts: ValidationErrorOptions = {}) {
const primary = issues[0];
const message = primary != null ? primary.message : "Validation failed";
super(primary?.code ?? "VALIDATION.INVALID_TYPE", message, {
cause: opts.cause,
...(opts.traceId !== undefined ? { traceId: opts.traceId } : {}),
});
this.issues = issues;
this.name = "ValidationError";
}
/**
* Flatten issues into `formErrors` (root-level) and `fieldErrors` (path-keyed).
*/
flatten(): FlattenedErrors {
const formErrors: string[] = [];
const fieldErrors: Record<string, string[]> = {};
for (const issue of this.issues) {
if (issue.path.length === 0) {
formErrors.push(issue.message);
} else {
const key = issue.path.join(".");
const bucket = fieldErrors[key] ?? [];
bucket.push(issue.message);
fieldErrors[key] = bucket;
}
}
return { formErrors, fieldErrors };
}
/**
* Format issues into a nested record, useful for UI error rendering.
*/
format(): Record<string, unknown> {
const result: Record<string, unknown> = {
_errors: [] as string[],
};
for (const issue of this.issues) {
if (issue.path.length === 0) {
(result["_errors"] as string[]).push(issue.message);
} else {
let cur = result;
for (let i = 0; i < issue.path.length - 1; i++) {
const seg = String(issue.path[i]);
if (cur[seg] === undefined) {
cur[seg] = { _errors: [] };
}
cur = cur[seg] as Record<string, unknown>;
}
const last = String(issue.path[issue.path.length - 1]);
if (cur[last] === undefined) {
cur[last] = { _errors: [] };
}
(
(cur[last] as Record<string, unknown>)[
"_errors"
] as string[]
).push(issue.message);
}
}
return result;
}
}

View File

@@ -0,0 +1,5 @@
export {
VALIDATION_CODES,
ValidationError,
type ValidationErrorOptions,
} from "./errors";

View File

@@ -0,0 +1,102 @@
// ---------------------------------------------------------------------------
// Core
// ---------------------------------------------------------------------------
export {
addIssue,
BaseSchema,
INVALID,
NullableSchema,
OptionalSchema,
TransformSchema,
} from "./core/schema/schema.js";
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export type {
AnySchema,
AsyncRefineFn,
CustomRule,
FlattenedErrors,
Infer,
InferInput,
NumberRuleOptions,
ObjectShape,
ParseContext,
ParseFailure,
ParseResult,
ParseSuccess,
RefineFn,
SchemaInput,
SchemaOutput,
StringRuleOptions,
TransformFn,
ValidationCode,
ValidationIssue,
} from "./types/common.js";
export type {
ValidationHookData,
ValidationHookMap,
ValidationValidatingHandler,
} from "./types/validation-hooks.js";
// ---------------------------------------------------------------------------
// Errors
// ---------------------------------------------------------------------------
export type { ValidationErrorOptions } from "./errors/errors.js";
export {
VALIDATION_CODES,
ValidationError,
} from "./errors/errors.js";
// ---------------------------------------------------------------------------
// Schemas
// ---------------------------------------------------------------------------
export { ArraySchema, array } from "./schemas/array/array.js";
export { BooleanSchema, boolean } from "./schemas/boolean/boolean.js";
export { DateSchema, date } from "./schemas/date/date.js";
export { EnumSchema, enumeration } from "./schemas/enum/enum.js";
export {
IntersectionSchema,
intersection,
} from "./schemas/intersection/intersection.js";
export { LiteralSchema, literal } from "./schemas/literal/literal.js";
export { nullable } from "./schemas/nullable/nullable.js";
export { NumberSchema, number } from "./schemas/number/number.js";
export { ObjectSchema, object } from "./schemas/object/object.js";
export { optional } from "./schemas/optional/optional.js";
export { RecordSchema, record } from "./schemas/record/record.js";
export { StringSchema, string } from "./schemas/string/string.js";
export { TupleSchema, tuple } from "./schemas/tuple/tuple.js";
export { UnionSchema, union } from "./schemas/union/union.js";
// ---------------------------------------------------------------------------
// Utils
// ---------------------------------------------------------------------------
export type { CoercedSchema } from "./utils/coerce.js";
export {
coerce,
coerceBoolean,
coerceDate,
coerceNumber,
coerceString,
} from "./utils/coerce.js";
export { mergeResults, pipe } from "./utils/compose.js";
// ---------------------------------------------------------------------------
// OTel
// ---------------------------------------------------------------------------
export type { ParseObservabilityOptions } from "./otel/validation-otel.js";
export {
_resetInstruments,
recordParseFailure,
recordParseSuccess,
} from "./otel/validation-otel.js";

View File

@@ -0,0 +1,6 @@
export {
_resetInstruments,
type ParseObservabilityOptions,
recordParseFailure,
recordParseSuccess,
} from "./validation-otel";

View File

@@ -0,0 +1,83 @@
# Validation OTel Instrumentation
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`validation-otel.ts` provides OpenTelemetry instrumentation for parse operations in `@suaveplan/validation`. It exposes two counter instruments (`validation.parse.success` and `validation.parse.failure`) and one histogram (`validation.parse.duration_ms`) through the OTel metrics API. All instruments are labelled with a `schema.type` attribute so dashboards can break down metrics per schema class.
Instruments are lazily initialised on first use and cached as module-level singletons, avoiding meter lookup overhead on the hot parse path.
## Features
- Counter `validation.parse.success` incremented per successful parse
- Counter `validation.parse.failure` incremented per failed parse
- Histogram `validation.parse.duration_ms` records duration in milliseconds
- `schema.type` attribute on all instruments for per-schema breakdown
- `_resetInstruments()` for test isolation
## Usage: Basic Example
```typescript
import { recordParseSuccess, recordParseFailure } from "@suaveplan/validation";
const start = performance.now();
const result = mySchema.safeParse(input);
const duration = performance.now() - start;
if (result.success) {
recordParseSuccess({ schemaType: "ObjectSchema" }, duration);
} else {
recordParseFailure({ schemaType: "ObjectSchema" }, duration);
}
```
## Usage: Advanced Example
```typescript
import { recordParseSuccess, recordParseFailure } from "@suaveplan/validation";
// Wrap any schema with automatic OTel instrumentation
function instrumentedParse<T>(schema: { safeParse: (v: unknown) => { success: boolean; data?: T } }, input: unknown, schemaType: string) {
const start = performance.now();
const result = schema.safeParse(input);
const duration = performance.now() - start;
if (result.success) {
recordParseSuccess({ schemaType }, duration);
} else {
recordParseFailure({ schemaType }, duration);
}
return result;
}
```
## API Reference
### `recordParseSuccess(opts: ParseObservabilityOptions, durationMs: number): void`
Increments the success counter and records the duration histogram.
### `recordParseFailure(opts: ParseObservabilityOptions, durationMs: number): void`
Increments the failure counter and records the duration histogram.
### `_resetInstruments(): void`
Clears cached instrument singletons. For test use only.
### `ParseObservabilityOptions`
```typescript
interface ParseObservabilityOptions {
schemaType: string; // e.g. "ObjectSchema", "StringSchema"
}
```
## Implementation Notes
- Instruments are lazily created via `metrics.getMeter()` on first call to either `recordParseSuccess` or `recordParseFailure`. This avoids OTel initialisation at module import time.
- The OTel `metrics` global (`@opentelemetry/api`) is a no-op by default unless a real metrics provider is configured, so these calls are safe in environments without OTel.
- `_resetInstruments()` sets all singletons to `undefined`, causing the next call to re-create them — useful for testing multiple instrumentation scenarios in the same process.

View File

@@ -0,0 +1,56 @@
import { beforeEach, describe, expect, it } from "@suaveplan/testing/runner";
import {
_resetInstruments,
recordParseFailure,
recordParseSuccess,
} from "./validation-otel.js";
beforeEach(() => {
_resetInstruments();
});
describe("validation-otel", () => {
it("recordParseSuccess does not throw", { timeout: 5000 }, () => {
expect(() =>
recordParseSuccess({ schemaType: "StringSchema" }, 1.5)
).not.toThrow();
});
it("recordParseFailure does not throw", { timeout: 5000 }, () => {
expect(() =>
recordParseFailure({ schemaType: "NumberSchema" }, 2.0)
).not.toThrow();
});
it("can call recordParseSuccess multiple times", { timeout: 5000 }, () => {
expect(() => {
recordParseSuccess({ schemaType: "ObjectSchema" }, 1.0);
recordParseSuccess({ schemaType: "ObjectSchema" }, 2.0);
}).not.toThrow();
});
it("can call recordParseFailure multiple times", { timeout: 5000 }, () => {
expect(() => {
recordParseFailure({ schemaType: "ArraySchema" }, 0.5);
recordParseFailure({ schemaType: "ArraySchema" }, 1.5);
}).not.toThrow();
});
it("instruments are lazily initialised and reused", {
timeout: 5000,
}, () => {
// Call twice to ensure re-use path is hit
recordParseSuccess({ schemaType: "StringSchema" }, 1.0);
recordParseSuccess({ schemaType: "StringSchema" }, 2.0);
expect(true).toBe(true); // no error = pass
});
it("_resetInstruments allows re-initialisation", { timeout: 5000 }, () => {
recordParseSuccess({ schemaType: "TestSchema" }, 1.0);
_resetInstruments();
// Should reinitialise without error
expect(() =>
recordParseSuccess({ schemaType: "TestSchema" }, 1.0)
).not.toThrow();
});
});

View File

@@ -0,0 +1,83 @@
import { type Counter, type Histogram, metrics } from "@opentelemetry/api";
// ---------------------------------------------------------------------------
// Instruments (lazy-initialised once)
// ---------------------------------------------------------------------------
let successCounter: Counter | undefined;
let failureCounter: Counter | undefined;
let durationHistogram: Histogram | undefined;
function getInstruments(): {
success: Counter;
failure: Counter;
duration: Histogram;
} {
const meter = metrics.getMeter("@suaveplan/validation", "0.1.0");
successCounter ??= meter.createCounter("validation.parse.success", {
description: "Number of successful schema parse operations",
});
failureCounter ??= meter.createCounter("validation.parse.failure", {
description: "Number of failed schema parse operations",
});
durationHistogram ??= meter.createHistogram(
"validation.parse.duration_ms",
{
description: "Duration of schema parse operations in milliseconds",
unit: "ms",
}
);
return {
success: successCounter,
failure: failureCounter,
duration: durationHistogram,
};
}
// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
export interface ParseObservabilityOptions {
/** Schema type label, e.g. "ObjectSchema", "StringSchema". */
schemaType: string;
}
/**
* Record a successful parse operation.
*/
export function recordParseSuccess(
opts: ParseObservabilityOptions,
durationMs: number
): void {
const { success, duration } = getInstruments();
const attrs = { "schema.type": opts.schemaType };
success.add(1, attrs);
duration.record(durationMs, attrs);
}
/**
* Record a failed parse operation.
*/
export function recordParseFailure(
opts: ParseObservabilityOptions,
durationMs: number
): void {
const { failure, duration } = getInstruments();
const attrs = { "schema.type": opts.schemaType };
failure.add(1, attrs);
duration.record(durationMs, attrs);
}
/**
* Reset internal instrument singletons (for testing only).
*/
export function _resetInstruments(): void {
successCounter = undefined;
failureCounter = undefined;
durationHistogram = undefined;
}

View File

@@ -0,0 +1,63 @@
# ArraySchema
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`ArraySchema<T>` validates that an input is a JavaScript array and that every element satisfies an inner schema. It collects element-level errors with positional paths (`[0]`, `[1]`, …) so callers can display per-item validation feedback. All constraint builders return new instances; the inner element schema is never mutated.
## Features
- Type check: rejects non-arrays with `VALIDATION.INVALID_TYPE`
- Element validation: every item parsed with the element schema; all errors collected
- `min(n)` / `max(n)` — length constraints
- `length(n)` — exact length
- `nonempty()` — shorthand for `min(1)`
## Usage: Basic Example
```typescript
import { array, string } from "@suaveplan/validation";
const tags = array(string()).min(1).max(10);
tags.parse(["typescript", "validation"]); // ok
tags.parse([]); // throws (too short)
```
## Usage: Advanced Example
```typescript
import { array, object, string, number } from "@suaveplan/validation";
const items = array(
object({ sku: string(), qty: number().int().positive() })
).nonempty();
const cart = items.safeParse([
{ sku: "ABC", qty: 2 },
{ sku: "XYZ", qty: 0 }, // fails positive()
]);
// cart.error.issues[0].path === [1, "qty"]
```
## API Reference
### `array<T>(schema: BaseSchema<unknown, T>): ArraySchema<T>`
Factory accepting the element schema.
### Constraint methods (all return new `ArraySchema<T>`)
| Method | Error code | Description |
|--------|-----------|-------------|
| `min(n)` | `ARRAY.TOO_SHORT` | Length must be ≥ n |
| `max(n)` | `ARRAY.TOO_LONG` | Length must be ≤ n |
| `length(n)` | `ARRAY.TOO_SHORT` | Length must be exactly n |
| `nonempty()` | `ARRAY.TOO_SHORT` | Must have at least one element |
## Implementation Notes
- All element errors are accumulated even when earlier elements fail, giving the caller a complete picture. The parse returns `INVALID` after collecting all issues.
- Element error paths include the numeric index: `ctx.path = [...parentPath, i]`.
- Array length checks run before element validation to avoid unnecessary work on obviously wrong inputs.

View File

@@ -0,0 +1,100 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { ValidationError } from "../../errors/errors.js";
import { number } from "../number/number.js";
import { string } from "../string/string.js";
import { array } from "./array.js";
describe("ArraySchema — type check", () => {
const a = array(string());
it("accepts array of strings", { timeout: 5000 }, () => {
expect(a.parse(["a", "b"])).toEqual(["a", "b"]);
});
it("accepts empty array", { timeout: 5000 }, () => {
expect(a.parse([])).toEqual([]);
});
it("rejects non-array", { timeout: 5000 }, () => {
expect(() => a.parse("not array")).toThrow(ValidationError);
});
it("rejects array with wrong element types", { timeout: 5000 }, () => {
const r = a.safeParse(["a", 42]);
expect(r.success).toBe(false);
});
it("collects all element errors", { timeout: 5000 }, () => {
const r = a.safeParse([1, 2, 3]);
expect(r.success).toBe(false);
if (!r.success) expect(r.error.issues.length).toBeGreaterThanOrEqual(3);
});
});
describe("ArraySchema — min", () => {
const s = array(string()).min(2);
it("passes at min", { timeout: 5000 }, () => {
expect(s.parse(["a", "b"])).toEqual(["a", "b"]);
});
it("fails below min", { timeout: 5000 }, () => {
const r = s.safeParse(["a"]);
expect(r.success).toBe(false);
if (!r.success)
expect(r.error.issues[0]?.code).toBe("VALIDATION.ARRAY.TOO_SHORT");
});
});
describe("ArraySchema — max", () => {
const s = array(string()).max(2);
it("passes at max", { timeout: 5000 }, () => {
expect(s.parse(["a", "b"])).toEqual(["a", "b"]);
});
it("fails above max", { timeout: 5000 }, () => {
const r = s.safeParse(["a", "b", "c"]);
expect(r.success).toBe(false);
if (!r.success)
expect(r.error.issues[0]?.code).toBe("VALIDATION.ARRAY.TOO_LONG");
});
});
describe("ArraySchema — length", () => {
const s = array(number()).length(3);
it("passes exact length", { timeout: 5000 }, () => {
expect(s.parse([1, 2, 3])).toEqual([1, 2, 3]);
});
it("fails wrong length", { timeout: 5000 }, () => {
expect(s.safeParse([1, 2]).success).toBe(false);
});
});
describe("ArraySchema — nonempty", () => {
const s = array(string()).nonempty();
it("passes non-empty array", { timeout: 5000 }, () => {
expect(s.parse(["x"])).toEqual(["x"]);
});
it("fails empty array", { timeout: 5000 }, () => {
const r = s.safeParse([]);
expect(r.success).toBe(false);
if (!r.success)
expect(r.error.issues[0]?.code).toBe("VALIDATION.ARRAY.TOO_SHORT");
});
});
describe("ArraySchema — element path", () => {
it("includes index in error path", { timeout: 5000 }, () => {
const r = array(number()).safeParse([1, "x", 3]);
expect(r.success).toBe(false);
if (!r.success) {
const issue = r.error.issues.find((i) => i.path.includes(1));
expect(issue).toBeDefined();
}
});
});

View File

@@ -0,0 +1,132 @@
import { addIssue, BaseSchema, INVALID } from "../../core/schema/schema.js";
import type { AnySchema, ParseContext } from "../../types/common.js";
// ---------------------------------------------------------------------------
// Constraints
// ---------------------------------------------------------------------------
interface ArrayConstraints {
min?: number;
max?: number;
length?: number;
nonempty?: boolean;
}
// ---------------------------------------------------------------------------
// ArraySchema
// ---------------------------------------------------------------------------
export class ArraySchema<T> extends BaseSchema<T[], T[]> {
private readonly _element: BaseSchema<unknown, T>;
private readonly _constraints: ArrayConstraints;
constructor(
element: BaseSchema<unknown, T>,
constraints: ArrayConstraints = {}
) {
super();
this._element = element;
this._constraints = constraints;
}
_parse(input: unknown, ctx: ParseContext): T[] | INVALID {
if (!Array.isArray(input)) {
return addIssue(ctx, {
code: "VALIDATION.INVALID_TYPE",
path: [...ctx.path],
message: `Expected array, received ${typeof input}`,
input,
});
}
const c = this._constraints;
if (c.nonempty && input.length === 0) {
return addIssue(ctx, {
code: "VALIDATION.ARRAY.TOO_SHORT",
path: [...ctx.path],
message: "Array must be non-empty",
input,
});
}
if (c.min !== undefined && input.length < c.min) {
return addIssue(ctx, {
code: "VALIDATION.ARRAY.TOO_SHORT",
path: [...ctx.path],
message: `Array must have at least ${c.min} element(s)`,
input,
});
}
if (c.max !== undefined && input.length > c.max) {
return addIssue(ctx, {
code: "VALIDATION.ARRAY.TOO_LONG",
path: [...ctx.path],
message: `Array must have at most ${c.max} element(s)`,
input,
});
}
if (c.length !== undefined && input.length !== c.length) {
return addIssue(ctx, {
code: "VALIDATION.ARRAY.TOO_SHORT",
path: [...ctx.path],
message: `Array must have exactly ${c.length} element(s)`,
input,
});
}
const out: T[] = [];
let hasInvalid = false;
for (let i = 0; i < input.length; i++) {
const childCtx: ParseContext = {
path: [...ctx.path, i],
issues: [],
};
const result = this._element._parse(input[i], childCtx);
if (result === INVALID || childCtx.issues.length > 0) {
ctx.issues.push(...childCtx.issues);
hasInvalid = true;
} else {
out.push(result as T);
}
}
return hasInvalid ? INVALID : out;
}
// -------------------------------------------------------------------------
// Constraint builders
// -------------------------------------------------------------------------
min(n: number): ArraySchema<T> {
return new ArraySchema(this._element, { ...this._constraints, min: n });
}
max(n: number): ArraySchema<T> {
return new ArraySchema(this._element, { ...this._constraints, max: n });
}
length(n: number): ArraySchema<T> {
return new ArraySchema(this._element, {
...this._constraints,
length: n,
});
}
nonempty(): ArraySchema<T> {
return new ArraySchema(this._element, {
...this._constraints,
nonempty: true,
});
}
}
// ---------------------------------------------------------------------------
// Factory
// ---------------------------------------------------------------------------
export function array<T>(schema: BaseSchema<unknown, T>): ArraySchema<T>;
export function array(schema: AnySchema): ArraySchema<unknown>;
export function array<T>(schema: BaseSchema<unknown, T>): ArraySchema<T> {
return new ArraySchema(schema);
}

View File

@@ -0,0 +1 @@
export { ArraySchema, array } from "./array";

View File

@@ -0,0 +1,70 @@
# BooleanSchema
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`BooleanSchema` validates that an input is a native JavaScript `boolean` — exactly `true` or `false`. It does not coerce string representations like `"true"` or numeric values like `1`; use `coerceBoolean` from `@suaveplan/validation` if coercion is needed. The schema is stateless and the `boolean()` factory always returns the same shape.
## Features
- Type check: rejects non-booleans with `VALIDATION.INVALID_TYPE`
- Zero-configuration: no constraints beyond type checking
- Compatible with `optional()`, `nullable()`, `refine()`, and `transform()` from `BaseSchema`
## Usage: Basic Example
```typescript
import { boolean } from "@suaveplan/validation";
const active = boolean();
active.parse(true); // true
active.parse(false); // false
active.parse(1); // throws ValidationError
```
## Usage: Advanced Example
```typescript
import { boolean, object, string } from "@suaveplan/validation";
const featureFlag = object({
name: string(),
enabled: boolean(),
experimental: boolean().optional(),
});
const result = featureFlag.safeParse({
name: "dark-mode",
enabled: true,
});
// { success: true, data: { name: "dark-mode", enabled: true, experimental: undefined } }
```
## API Reference
### `boolean(): BooleanSchema`
Returns a `BooleanSchema` instance.
### `_parse(input, ctx): boolean | INVALID`
Returns the input unchanged if it is a `boolean`, otherwise pushes an `VALIDATION.INVALID_TYPE` issue and returns `INVALID`.
## Implementation Notes
- `BooleanSchema` is the simplest concrete schema — it has no constraint fields and no builder methods beyond those inherited from `BaseSchema`.
- Coercion (string `"true"` → `true`) is intentionally excluded from this schema to keep the boolean validation surface predictable. Use `coerce(boolean())` when HTTP query parameters need coercion.
### Parse Behaviour
`z.boolean()` accepts only actual `true` / `false` values in `parse` / `safeParse`. Use `z.coerce.boolean()` when you want string/number coercion (`"true"`, `1`, `"1"` → `true`). All coercion rules follow the Zod v4 reference table so schemas here are drop-in replacements for Zod schemas elsewhere in the ecosystem.
## Further Usage: Basic Example
```ts
import z from "@suaveplan/validation";
const s = z.boolean();
s.parse(true); // true
```

View File

@@ -0,0 +1,42 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { ValidationError } from "../../errors/errors.js";
import { boolean } from "./boolean.js";
describe("BooleanSchema", () => {
const b = boolean();
it("accepts true", { timeout: 5000 }, () => {
expect(b.parse(true)).toBe(true);
});
it("accepts false", { timeout: 5000 }, () => {
expect(b.parse(false)).toBe(false);
});
it("rejects string", { timeout: 5000 }, () => {
expect(() => b.parse("true")).toThrow(ValidationError);
});
it("rejects number", { timeout: 5000 }, () => {
expect(() => b.parse(1)).toThrow(ValidationError);
});
it("rejects null", { timeout: 5000 }, () => {
expect(() => b.parse(null)).toThrow(ValidationError);
});
it("rejects undefined", { timeout: 5000 }, () => {
expect(() => b.parse(undefined)).toThrow(ValidationError);
});
it("safeParse returns success for true", { timeout: 5000 }, () => {
const r = b.safeParse(true);
expect(r.success).toBe(true);
if (r.success) expect(r.data).toBe(true);
});
it("safeParse returns failure for string", { timeout: 5000 }, () => {
const r = b.safeParse("yes");
expect(r.success).toBe(false);
});
});

View File

@@ -0,0 +1,32 @@
import {
addIssue,
BaseSchema,
type INVALID,
} from "../../core/schema/schema.js";
import type { ParseContext } from "../../types/common.js";
// ---------------------------------------------------------------------------
// BooleanSchema
// ---------------------------------------------------------------------------
export class BooleanSchema extends BaseSchema<boolean, boolean> {
_parse(input: unknown, ctx: ParseContext): boolean | INVALID {
if (typeof input !== "boolean") {
return addIssue(ctx, {
code: "VALIDATION.INVALID_TYPE",
path: [...ctx.path],
message: `Expected boolean, received ${typeof input}`,
input,
});
}
return input;
}
}
// ---------------------------------------------------------------------------
// Factory
// ---------------------------------------------------------------------------
export function boolean(): BooleanSchema {
return new BooleanSchema();
}

View File

@@ -0,0 +1 @@
export { BooleanSchema, boolean } from "./boolean";

View File

@@ -0,0 +1,68 @@
# DateSchema
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`DateSchema` validates that an input is a JavaScript `Date` instance with a valid (non-NaN) time value, and optionally enforces inclusive `min` / `max` date boundaries. It does not coerce ISO strings or timestamps to `Date`; use `coerceDate` from `@suaveplan/validation` for that. The schema is immutable — each constraint builder returns a new `DateSchema`.
## Features
- Type check: rejects non-Date values with `VALIDATION.INVALID_TYPE`
- Invalid date detection: rejects `new Date("invalid")` with `VALIDATION.DATE.INVALID`
- `min(d)` — date must be on or after `d` (inclusive)
- `max(d)` — date must be on or before `d` (inclusive)
## Usage: Basic Example
```typescript
import { date } from "@suaveplan/validation";
const born = date().max(new Date());
born.parse(new Date("1990-01-01")); // valid
born.parse(new Date("2099-01-01")); // throws ValidationError (too late)
```
## Usage: Advanced Example
```typescript
import { date, object } from "@suaveplan/validation";
const booking = object({
checkIn: date().min(new Date()),
checkOut: date(),
}).refine(
(v) => v.checkOut > v.checkIn,
"Check-out must be after check-in",
);
const result = booking.safeParse({
checkIn: new Date("2025-06-01"),
checkOut: new Date("2025-06-07"),
});
```
## API Reference
### `date(): DateSchema`
Returns an unconstrained `DateSchema`.
### `min(d: Date): DateSchema`
Returns a new `DateSchema` requiring the input date to be ≥ `d`. Emits `VALIDATION.DATE.TOO_EARLY` on failure.
### `max(d: Date): DateSchema`
Returns a new `DateSchema` requiring the input date to be ≤ `d`. Emits `VALIDATION.DATE.TOO_LATE` on failure.
## Implementation Notes
- `Date` validity is checked via `Number.isNaN(input.getTime())`, which correctly identifies `new Date("invalid")` without requiring string inspection.
- Boundary comparisons use JavaScript `<` / `>` on `Date` objects, which internally compares millisecond timestamps.
- String-to-Date coercion is intentionally omitted. Pair with `coerceDate` when accepting JSON payloads.
### Parse Behaviour
`z.date()` accepts only `Date` instances; `z.coerce.date()` additionally accepts ISO-8601 strings and millisecond-epoch numbers. Invalid dates (`new Date("nope")` — a `Date` with `NaN` time) are rejected as `invalid_date`. Refinements `min()` / `max()` compare `getTime()` values so time-zone differences fold into the numeric comparison.

View File

@@ -0,0 +1,64 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { ValidationError } from "../../errors/errors.js";
import { date } from "./date.js";
describe("DateSchema — type check", () => {
const d = date();
it("accepts valid Date", { timeout: 5000 }, () => {
const dt = new Date("2024-01-01");
expect(d.parse(dt)).toBe(dt);
});
it("rejects string", { timeout: 5000 }, () => {
expect(() => d.parse("2024-01-01")).toThrow(ValidationError);
});
it("rejects number", { timeout: 5000 }, () => {
expect(() => d.parse(0)).toThrow(ValidationError);
});
it("rejects invalid Date", { timeout: 5000 }, () => {
expect(() => d.parse(new Date("invalid"))).toThrow(ValidationError);
});
});
describe("DateSchema — min", () => {
const minDate = new Date("2024-01-01");
const s = date().min(minDate);
it("passes at min boundary", { timeout: 5000 }, () => {
expect(s.parse(new Date("2024-01-01"))).toBeInstanceOf(Date);
});
it("passes after min", { timeout: 5000 }, () => {
expect(s.parse(new Date("2025-01-01"))).toBeInstanceOf(Date);
});
it("fails before min", { timeout: 5000 }, () => {
const r = s.safeParse(new Date("2023-12-31"));
expect(r.success).toBe(false);
if (!r.success)
expect(r.error.issues[0]?.code).toBe("VALIDATION.DATE.TOO_EARLY");
});
});
describe("DateSchema — max", () => {
const maxDate = new Date("2024-12-31");
const s = date().max(maxDate);
it("passes at max boundary", { timeout: 5000 }, () => {
expect(s.parse(new Date("2024-12-31"))).toBeInstanceOf(Date);
});
it("passes before max", { timeout: 5000 }, () => {
expect(s.parse(new Date("2024-01-01"))).toBeInstanceOf(Date);
});
it("fails after max", { timeout: 5000 }, () => {
const r = s.safeParse(new Date("2025-01-01"));
expect(r.success).toBe(false);
if (!r.success)
expect(r.error.issues[0]?.code).toBe("VALIDATION.DATE.TOO_LATE");
});
});

View File

@@ -0,0 +1,90 @@
import {
addIssue,
BaseSchema,
type INVALID,
} from "../../core/schema/schema.js";
import type { ParseContext } from "../../types/common.js";
// ---------------------------------------------------------------------------
// Constraints
// ---------------------------------------------------------------------------
interface DateConstraints {
min?: Date;
max?: Date;
}
// ---------------------------------------------------------------------------
// DateSchema
// ---------------------------------------------------------------------------
export class DateSchema extends BaseSchema<Date, Date> {
private readonly _constraints: DateConstraints;
constructor(constraints: DateConstraints = {}) {
super();
this._constraints = constraints;
}
_parse(input: unknown, ctx: ParseContext): Date | INVALID {
if (!(input instanceof Date)) {
return addIssue(ctx, {
code: "VALIDATION.INVALID_TYPE",
path: [...ctx.path],
message: `Expected Date instance, received ${typeof input}`,
input,
});
}
if (Number.isNaN(input.getTime())) {
return addIssue(ctx, {
code: "VALIDATION.DATE.INVALID",
path: [...ctx.path],
message: "Invalid date",
input,
});
}
const c = this._constraints;
if (c.min !== undefined && input < c.min) {
return addIssue(ctx, {
code: "VALIDATION.DATE.TOO_EARLY",
path: [...ctx.path],
message: `Date must be >= ${c.min.toISOString()}`,
input,
});
}
if (c.max !== undefined && input > c.max) {
return addIssue(ctx, {
code: "VALIDATION.DATE.TOO_LATE",
path: [...ctx.path],
message: `Date must be <= ${c.max.toISOString()}`,
input,
});
}
return input;
}
// -------------------------------------------------------------------------
// Constraint builders
// -------------------------------------------------------------------------
min(d: Date): DateSchema {
return new DateSchema({ ...this._constraints, min: d });
}
max(d: Date): DateSchema {
return new DateSchema({ ...this._constraints, max: d });
}
}
// ---------------------------------------------------------------------------
// Factory
// ---------------------------------------------------------------------------
export function date(): DateSchema {
return new DateSchema();
}

View File

@@ -0,0 +1 @@
export { DateSchema, date } from "./date";

View File

@@ -0,0 +1,57 @@
# EnumSchema
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`EnumSchema<T>` validates that an input is one of a fixed set of allowed values (a closed-ended enumeration). It differs from `LiteralSchema` in that it checks membership in an array rather than equality with a single value, and from TypeScript's `enum` keyword in that it operates on plain string/number arrays at runtime.
## Features
- Membership check via `Array.includes`
- Supports `string` and `number` value arrays
- Returns `VALIDATION.ENUM.INVALID_VALUE` with the list of allowed values on failure
## Usage: Basic Example
```typescript
import { enumeration } from "@suaveplan/validation";
const status = enumeration(["active", "inactive", "pending"] as const);
status.parse("active"); // "active"
status.parse("deleted"); // throws ValidationError
```
## Usage: Advanced Example
```typescript
import { enumeration, object, string } from "@suaveplan/validation";
const ROLES = ["admin", "editor", "viewer"] as const;
type Role = typeof ROLES[number];
const RoleAssignment = object({
userId: string().uuid(),
role: enumeration(ROLES),
});
```
## API Reference
### `enumeration<T extends string | number>(values: readonly T[]): EnumSchema<T>`
Factory accepting the array of allowed values. The name `enumeration` avoids conflict with the reserved `enum` keyword.
### `_parse(input, ctx): T | INVALID`
Checks `Array.prototype.includes`. On failure, the error message lists all allowed values via `JSON.stringify`.
## Implementation Notes
- Membership check uses `Array.prototype.includes` which is O(n) in the values array. For very large enumerations (100+ values), consider a `Set`-based lookup — though typical enum sizes (< 20 values) make this irrelevant.
- `as const` on the values array is recommended to get the narrowest literal type for `T`.
### Parse Behaviour
`z.enum(["a", "b", "c"])` accepts only the listed string literal values and produces a branded union type so downstream code gets the narrow literal rather than `string`. A helper `.extract([...])` returns a subset enum; `.exclude([...])` returns the complement. Both preserve the original ordering so telemetry and UI code can rely on it.

View File

@@ -0,0 +1,39 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { ValidationError } from "../../errors/errors.js";
import { enumeration } from "./enum.js";
describe("EnumSchema", () => {
const colors = enumeration(["red", "green", "blue"] as const);
it("accepts valid enum value", { timeout: 5000 }, () => {
expect(colors.parse("red")).toBe("red");
});
it("accepts all valid values", { timeout: 5000 }, () => {
expect(colors.parse("green")).toBe("green");
expect(colors.parse("blue")).toBe("blue");
});
it("rejects invalid value", { timeout: 5000 }, () => {
expect(() => colors.parse("yellow")).toThrow(ValidationError);
});
it("returns correct error code", { timeout: 5000 }, () => {
const r = colors.safeParse("purple");
expect(r.success).toBe(false);
if (!r.success)
expect(r.error.issues[0]?.code).toBe(
"VALIDATION.ENUM.INVALID_VALUE"
);
});
it("works with numeric enum", { timeout: 5000 }, () => {
const nums = enumeration([1, 2, 3] as const);
expect(nums.parse(2)).toBe(2);
expect(nums.safeParse(4).success).toBe(false);
});
it("rejects empty string for non-empty enum", { timeout: 5000 }, () => {
expect(colors.safeParse("").success).toBe(false);
});
});

View File

@@ -0,0 +1,41 @@
import {
addIssue,
BaseSchema,
type INVALID,
} from "../../core/schema/schema.js";
import type { ParseContext } from "../../types/common.js";
// ---------------------------------------------------------------------------
// EnumSchema
// ---------------------------------------------------------------------------
export class EnumSchema<T extends string | number> extends BaseSchema<T, T> {
private readonly _values: readonly T[];
constructor(values: readonly T[]) {
super();
this._values = values;
}
_parse(input: unknown, ctx: ParseContext): T | INVALID {
if (!(this._values as unknown[]).includes(input)) {
return addIssue(ctx, {
code: "VALIDATION.ENUM.INVALID_VALUE",
path: [...ctx.path],
message: `Expected one of ${this._values.map((v) => JSON.stringify(v)).join(", ")}, received ${JSON.stringify(input)}`,
input,
});
}
return input as T;
}
}
// ---------------------------------------------------------------------------
// Factory — named `enumeration` to avoid conflict with reserved keyword `enum`
// ---------------------------------------------------------------------------
export function enumeration<T extends string | number>(
values: readonly T[]
): EnumSchema<T> {
return new EnumSchema(values);
}

View File

@@ -0,0 +1 @@
export { EnumSchema, enumeration } from "./enum";

View File

@@ -0,0 +1,14 @@
export { ArraySchema, array } from "./array";
export { BooleanSchema, boolean } from "./boolean";
export { DateSchema, date } from "./date";
export { EnumSchema, enumeration } from "./enum";
export { IntersectionSchema, intersection } from "./intersection";
export { LiteralSchema, literal } from "./literal";
export { NullableSchema, nullable } from "./nullable";
export { NumberSchema, number } from "./number";
export { ObjectSchema, object } from "./object";
export { OptionalSchema, optional } from "./optional";
export { RecordSchema, record } from "./record";
export { StringSchema, string } from "./string";
export { TupleSchema, tuple } from "./tuple";
export { UnionSchema, union } from "./union";

View File

@@ -0,0 +1 @@
export { IntersectionSchema, intersection } from "./intersection";

View File

@@ -0,0 +1,64 @@
# IntersectionSchema
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`IntersectionSchema` validates that an input satisfies two schemas simultaneously and merges their outputs. For object schemas, the merge is performed via `Object.assign`, which implements a right-biased merge where overlapping keys from the second schema take precedence. For non-object schemas (strings, numbers, etc.), the second schema's output is used directly.
## Features
- Both schemas must pass — issues from both arms are aggregated on failure
- Object output merging via `Object.assign({}, resultA, resultB)`
- Non-object fallback: returns second schema's result
## Usage: Basic Example
```typescript
import { intersection, object, string, number } from "@suaveplan/validation";
const Named = object({ name: string() });
const Aged = object({ age: number() });
const Person = intersection(Named, Aged);
Person.parse({ name: "Alice", age: 30 });
// { name: "Alice", age: 30 }
```
## Usage: Advanced Example
```typescript
import { intersection, object, string } from "@suaveplan/validation";
const Timestamped = object({ createdAt: string(), updatedAt: string() });
const Entity = object({ id: string().uuid(), type: string() });
const TimestampedEntity = intersection(Entity, Timestamped);
TimestampedEntity.parse({
id: "550e8400-e29b-41d4-a716-446655440000",
type: "user",
createdAt: "2024-01-01T00:00:00Z",
updatedAt: "2024-06-01T00:00:00Z",
});
```
## API Reference
### `intersection(a: AnySchema, b: AnySchema): IntersectionSchema`
Factory accepting two schemas to intersect.
### `_parse(input, ctx): unknown | INVALID`
Both schemas parse with independent child contexts. If either fails, all issues are pushed to `ctx.issues`. If both succeed and both results are non-null objects, they are merged via `Object.assign`.
## Implementation Notes
- Both arms always run (not short-circuit), ensuring all issues are collected even when the first arm fails.
- `Object.assign` is a shallow merge; deep intersection of nested objects is not performed — callers should compose nested `intersection()` calls explicitly.
- This differs from Zod's `z.intersection()` in that it does not attempt deep merging of arrays or complex types.
### Parse Behaviour
`z.intersection(a, b)` runs both schemas against the input and merges the results; either failure fails the whole schema. Object schemas merge shallowly. For deep merges, prefer `a.merge(b)` on object schemas directly — `intersection` exists for the rarer case where the operands are not both plain objects (e.g. object + record, union + discriminated-union, etc.).

View File

@@ -0,0 +1,50 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { number } from "../number/number.js";
import { object } from "../object/object.js";
import { string } from "../string/string.js";
import { intersection } from "./intersection.js";
describe("IntersectionSchema", () => {
const a = object({ name: string() });
const b = object({ age: number() });
const s = intersection(a, b);
it("accepts input satisfying both schemas", { timeout: 5000 }, () => {
const result = s.parse({ name: "Alice", age: 30 });
const r = result as Record<string, unknown>;
expect(r["name"]).toBe("Alice");
expect(r["age"]).toBe(30);
});
it("fails if first schema fails", { timeout: 5000 }, () => {
expect(s.safeParse({ name: 42, age: 30 }).success).toBe(false);
});
it("fails if second schema fails", { timeout: 5000 }, () => {
expect(s.safeParse({ name: "Alice", age: "old" }).success).toBe(false);
});
it("merges object results", { timeout: 5000 }, () => {
const result = s.parse({ name: "Bob", age: 25 });
const r = result as Record<string, unknown>;
expect(r["name"]).toBe("Bob");
expect(r["age"]).toBe(25);
});
it("collects issues from both arms on failure", { timeout: 5000 }, () => {
const r = s.safeParse({ name: 1, age: "x" });
expect(r.success).toBe(false);
if (!r.success) {
expect(r.error.issues.length).toBeGreaterThanOrEqual(2);
}
});
it("works with primitive schemas - non-object result uses second", {
timeout: 5000,
}, () => {
const numA = number();
const numB = number();
const si = intersection(numA, numB);
expect(si.parse(42)).toBe(42);
});
});

View File

@@ -0,0 +1,63 @@
import { addIssue, BaseSchema, INVALID } from "../../core/schema/schema.js";
import type { AnySchema, ParseContext } from "../../types/common.js";
// ---------------------------------------------------------------------------
// IntersectionSchema
// ---------------------------------------------------------------------------
export class IntersectionSchema extends BaseSchema<unknown, unknown> {
private readonly _a: AnySchema;
private readonly _b: AnySchema;
constructor(a: AnySchema, b: AnySchema) {
super();
this._a = a;
this._b = b;
}
_parse(input: unknown, ctx: ParseContext): unknown | INVALID {
const ctxA: ParseContext = { path: [...ctx.path], issues: [] };
const ctxB: ParseContext = { path: [...ctx.path], issues: [] };
const resultA = this._a._parse(input, ctxA);
const resultB = this._b._parse(input, ctxB);
const failed =
resultA === INVALID ||
ctxA.issues.length > 0 ||
resultB === INVALID ||
ctxB.issues.length > 0;
if (failed) {
ctx.issues.push(...ctxA.issues, ...ctxB.issues);
return INVALID;
}
// Merge object results
if (
typeof resultA === "object" &&
resultA !== null &&
typeof resultB === "object" &&
resultB !== null
) {
return Object.assign({}, resultA, resultB);
}
// For non-objects, resultB takes precedence
return resultB;
}
}
// ---------------------------------------------------------------------------
// Factory
// ---------------------------------------------------------------------------
export function intersection(a: AnySchema, b: AnySchema): IntersectionSchema {
return new IntersectionSchema(a, b);
}
// Unused import guard
type _INVALID = INVALID;
type _PC = ParseContext;
const _addIssue = addIssue;
void _addIssue;

View File

@@ -0,0 +1 @@
export { LiteralSchema, literal } from "./literal";

View File

@@ -0,0 +1,65 @@
# LiteralSchema
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`LiteralSchema<T>` validates that an input is strictly equal (`===`) to a specific primitive value. It supports strings, numbers, booleans, `null`, and `undefined` as literal values. This is the foundation for discriminated union patterns and exhaustive type guards.
## Features
- Strict equality check (`===`) against the literal value
- Supports `string | number | boolean | null | undefined` as literal types
- Returns `VALIDATION.LITERAL.MISMATCH` on failure
## Usage: Basic Example
```typescript
import { literal } from "@suaveplan/validation";
const ok = literal("ok");
ok.parse("ok"); // "ok"
ok.parse("error"); // throws ValidationError
```
## Usage: Advanced Example
```typescript
import { union, literal, object, string } from "@suaveplan/validation";
// Discriminated union pattern
const SuccessResponse = object({ type: literal("success"), data: string() });
const ErrorResponse = object({ type: literal("error"), message: string() });
const Response = union([SuccessResponse, ErrorResponse]);
Response.parse({ type: "success", data: "ok" });
Response.parse({ type: "error", message: "not found" });
```
## API Reference
### `literal<T extends string | number | boolean | null | undefined>(value: T): LiteralSchema<T>`
Factory accepting the literal value to match.
### `_parse(input, ctx): T | INVALID`
Uses `===` comparison. Returns `VALIDATION.LITERAL.MISMATCH` on failure with a message showing both expected and received values (via `JSON.stringify`).
## Implementation Notes
- `JSON.stringify` is used in error messages to safely represent all primitive types including `undefined` (shown as `undefined` string) and `null`.
- Literal schemas are particularly efficient: the `_parse` body is a single equality check with no iteration.
### Parse Behaviour
`z.literal("ready")` accepts only the exact value `"ready"` and infers the literal type `"ready"` rather than the widened type `string`. Literal unions are written as `z.union([z.literal("a"), z.literal("b")])` or the sugar `z.enum(["a", "b"])` — the enum form is marginally faster because it compiles to a single `Set` membership check.
## Further Usage: Basic Example
```ts
import z from "@suaveplan/validation";
const ready = z.literal("ready");
ready.parse("ready"); // "ready"
```

View File

@@ -0,0 +1,44 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { ValidationError } from "../../errors/errors.js";
import { literal } from "./literal.js";
describe("LiteralSchema", () => {
it("accepts matching string", { timeout: 5000 }, () => {
expect(literal("hello").parse("hello")).toBe("hello");
});
it("rejects non-matching string", { timeout: 5000 }, () => {
expect(() => literal("hello").parse("world")).toThrow(ValidationError);
});
it("accepts matching number", { timeout: 5000 }, () => {
expect(literal(42).parse(42)).toBe(42);
});
it("rejects non-matching number", { timeout: 5000 }, () => {
expect(literal(42).safeParse(43).success).toBe(false);
});
it("accepts matching boolean true", { timeout: 5000 }, () => {
expect(literal(true).parse(true)).toBe(true);
});
it("accepts matching boolean false", { timeout: 5000 }, () => {
expect(literal(false).parse(false)).toBe(false);
});
it("rejects wrong type", { timeout: 5000 }, () => {
const r = literal("hello").safeParse(42);
expect(r.success).toBe(false);
if (!r.success)
expect(r.error.issues[0]?.code).toBe("VALIDATION.LITERAL.MISMATCH");
});
it("accepts null literal", { timeout: 5000 }, () => {
expect(literal(null).parse(null)).toBeNull();
});
it("accepts undefined literal", { timeout: 5000 }, () => {
expect(literal(undefined).parse(undefined)).toBeUndefined();
});
});

View File

@@ -0,0 +1,43 @@
import {
addIssue,
BaseSchema,
type INVALID,
} from "../../core/schema/schema.js";
import type { ParseContext } from "../../types/common.js";
// ---------------------------------------------------------------------------
// LiteralSchema
// ---------------------------------------------------------------------------
export class LiteralSchema<
T extends string | number | boolean | null | undefined,
> extends BaseSchema<T, T> {
private readonly _value: T;
constructor(value: T) {
super();
this._value = value;
}
_parse(input: unknown, ctx: ParseContext): T | INVALID {
if (input !== this._value) {
return addIssue(ctx, {
code: "VALIDATION.LITERAL.MISMATCH",
path: [...ctx.path],
message: `Expected literal ${JSON.stringify(this._value)}, received ${JSON.stringify(input)}`,
input,
});
}
return input as T;
}
}
// ---------------------------------------------------------------------------
// Factory
// ---------------------------------------------------------------------------
export function literal<T extends string | number | boolean | null | undefined>(
value: T
): LiteralSchema<T> {
return new LiteralSchema(value);
}

View File

@@ -0,0 +1 @@
export { NullableSchema, nullable } from "./nullable";

View File

@@ -0,0 +1,67 @@
# NullableSchema
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`NullableSchema<T>` wraps an inner schema so that `null` passes through without validation. Any other value is delegated to the inner schema. It mirrors the behavior of `OptionalSchema` but for `null` rather than `undefined`. Both can be composed: `optional(nullable(string()))` accepts `undefined | null | string`.
## Features
- Passes `null` through without calling the inner schema
- Delegates all other values to the inner schema
- Composable with `optional()` for full nullish handling
## Usage: Basic Example
```typescript
import { nullable, string } from "@suaveplan/validation";
const bio = nullable(string());
bio.parse(null); // null
bio.parse("hello"); // "hello"
bio.parse(42); // throws ValidationError
```
## Usage: Advanced Example
```typescript
import { object, string, nullable, optional } from "@suaveplan/validation";
const User = object({
name: string(),
deletedAt: nullable(string()), // null when not deleted
nickname: optional(nullable(string())), // undefined or null or string
});
```
## API Reference
### `nullable<T>(schema: BaseSchema<unknown, T>): NullableSchema<T>`
Factory wrapping an inner schema.
### `NullableSchema<T>`
Class extending `BaseSchema<T | null, T | null>`. The `_parse` method returns `null` immediately for `null` inputs.
## Implementation Notes
- `NullableSchema` is also defined in the core `schema.ts` module and re-exported here for convenience.
- Only `null` bypasses the inner schema; `undefined` is not treated as null (use `optional()` for that).
### Parse Behaviour
`schema.nullable()` wraps any schema so `null` is accepted in addition to the underlying type. The inferred output type is `T | null`. Combine with `optional()` (either order) for `T | null | undefined`. Nullable schemas round-trip cleanly through JSON because `null` is a JSON primitive.
## Further Usage: Basic Example
```ts
import z from "@suaveplan/validation";
const s = z.string().nullable();
s.parse(null); // null
s.parse("hi"); // "hi"
```
Internally `.nullable()` is a thin wrapper that returns a new schema whose parse function short-circuits on a `null` input and delegates everything else to the inner schema. The inner schemas `refinements` and `transforms` run only on the non-null branch, so nullability is cleanly orthogonal to the rest of the schema pipeline.

View File

@@ -0,0 +1,26 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { number } from "../number/number.js";
import { string } from "../string/string.js";
import { nullable } from "./nullable.js";
describe("nullable(schema)", () => {
const s = nullable(string());
it("allows null", { timeout: 5000 }, () => {
expect(s.parse(null)).toBeNull();
});
it("allows valid inner value", { timeout: 5000 }, () => {
expect(s.parse("hello")).toBe("hello");
});
it("fails invalid inner value", { timeout: 5000 }, () => {
expect(s.safeParse(42).success).toBe(false);
});
it("works with number schema", { timeout: 5000 }, () => {
const n = nullable(number());
expect(n.parse(42)).toBe(42);
expect(n.parse(null)).toBeNull();
});
});

View File

@@ -0,0 +1,12 @@
import { type BaseSchema, NullableSchema } from "../../core/schema/schema.js";
// Re-export NullableSchema from core for external use
export { NullableSchema } from "../../core/schema/schema.js";
// ---------------------------------------------------------------------------
// Factory
// ---------------------------------------------------------------------------
export function nullable<T>(schema: BaseSchema<unknown, T>): NullableSchema<T> {
return new NullableSchema(schema);
}

View File

@@ -0,0 +1 @@
export { NumberSchema, number } from "./number";

View File

@@ -0,0 +1,70 @@
# NumberSchema
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`NumberSchema` validates that an input is a JavaScript `number` and enforces numeric constraints. By default, NaN and Infinity are rejected (the `finite` check runs for all `NumberSchema` instances unless explicitly disabled). All constraint methods return new instances, preserving immutability across builder chains.
This schema covers the full range of server-side numeric validation: integer enforcement, range clamping, sign checks, and divisibility rules.
## Features
- Type check: rejects non-numbers with `VALIDATION.INVALID_TYPE`
- Finite check by default: rejects NaN and Infinity with `VALIDATION.NUMBER.NOT_FINITE`
- `min(n)` / `max(n)` — inclusive range
- `int()` — integer-only (via `Number.isInteger`)
- `positive()` — must be > 0
- `negative()` — must be < 0
- `multipleOf(n)` — divisibility check
- `finite()` — explicit finite constraint (same as default)
## Usage: Basic Example
```typescript
import { number } from "@suaveplan/validation";
const age = number().int().min(0).max(150);
const result = age.safeParse(25);
// { success: true, data: 25 }
```
## Usage: Advanced Example
```typescript
import { number } from "@suaveplan/validation";
// Pagination offset: non-negative integer, multiple of 10
const offset = number().int().min(0).multipleOf(10);
// Latitude: float in range
const latitude = number().min(-90).max(90);
// Positive price with two-decimal precision (via multipleOf)
const cents = number().int().positive();
```
## API Reference
### `number(): NumberSchema`
Factory returning a `NumberSchema` with default finite enforcement.
### Constraint methods (all return new `NumberSchema`)
| Method | Error code | Description |
|--------|-----------|-------------|
| `min(n)` | `NUMBER.TOO_SMALL` | Value must be ≥ n |
| `max(n)` | `NUMBER.TOO_LARGE` | Value must be ≤ n |
| `int()` | `NUMBER.NOT_INT` | Must be integer |
| `positive()` | `NUMBER.TOO_SMALL` | Must be > 0 |
| `negative()` | `NUMBER.TOO_LARGE` | Must be < 0 |
| `multipleOf(n)` | `NUMBER.NOT_MULTIPLE` | Must satisfy `value % n === 0` |
| `finite()` | `NUMBER.NOT_FINITE` | Must be finite (explicit) |
## Implementation Notes
- The finite check runs first on every `NumberSchema` unless the `finite` constraint property is explicitly `false`. This prevents NaN from silently passing other checks.
- `positive()` and `negative()` use strict inequality (0 is neither positive nor negative).
- `multipleOf` uses the JavaScript modulo operator, which preserves sign for negative dividends — callers should be aware when combining with `negative()`.

View File

@@ -0,0 +1,139 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { ValidationError } from "../../errors/errors.js";
import { number } from "./number.js";
describe("NumberSchema — type check", () => {
const n = number();
it("accepts number", { timeout: 5000 }, () => {
expect(n.parse(42)).toBe(42);
});
it("rejects string", { timeout: 5000 }, () => {
expect(() => n.parse("42")).toThrow(ValidationError);
});
it("rejects NaN (non-finite)", { timeout: 5000 }, () => {
expect(() => n.parse(Number.NaN)).toThrow(ValidationError);
});
it("rejects Infinity (non-finite)", { timeout: 5000 }, () => {
expect(() => n.parse(Number.POSITIVE_INFINITY)).toThrow(
ValidationError
);
});
it("rejects null", { timeout: 5000 }, () => {
expect(() => n.parse(null)).toThrow(ValidationError);
});
});
describe("NumberSchema — min", () => {
const s = number().min(5);
it("passes at min", { timeout: 5000 }, () => {
expect(s.parse(5)).toBe(5);
});
it("fails below min", { timeout: 5000 }, () => {
const r = s.safeParse(4);
expect(r.success).toBe(false);
if (!r.success)
expect(r.error.issues[0]?.code).toBe("VALIDATION.NUMBER.TOO_SMALL");
});
});
describe("NumberSchema — max", () => {
const s = number().max(10);
it("passes at max", { timeout: 5000 }, () => {
expect(s.parse(10)).toBe(10);
});
it("fails above max", { timeout: 5000 }, () => {
const r = s.safeParse(11);
expect(r.success).toBe(false);
if (!r.success)
expect(r.error.issues[0]?.code).toBe("VALIDATION.NUMBER.TOO_LARGE");
});
});
describe("NumberSchema — int", () => {
const s = number().int();
it("passes integer", { timeout: 5000 }, () => {
expect(s.parse(5)).toBe(5);
});
it("fails float", { timeout: 5000 }, () => {
const r = s.safeParse(5.5);
expect(r.success).toBe(false);
if (!r.success)
expect(r.error.issues[0]?.code).toBe("VALIDATION.NUMBER.NOT_INT");
});
});
describe("NumberSchema — positive", () => {
const s = number().positive();
it("passes positive", { timeout: 5000 }, () => {
expect(s.parse(1)).toBe(1);
});
it("fails zero", { timeout: 5000 }, () => {
expect(s.safeParse(0).success).toBe(false);
});
it("fails negative", { timeout: 5000 }, () => {
expect(s.safeParse(-1).success).toBe(false);
});
});
describe("NumberSchema — negative", () => {
const s = number().negative();
it("passes negative", { timeout: 5000 }, () => {
expect(s.parse(-1)).toBe(-1);
});
it("fails zero", { timeout: 5000 }, () => {
expect(s.safeParse(0).success).toBe(false);
});
it("fails positive", { timeout: 5000 }, () => {
expect(s.safeParse(1).success).toBe(false);
});
});
describe("NumberSchema — multipleOf", () => {
const s = number().multipleOf(3);
it("passes multiple", { timeout: 5000 }, () => {
expect(s.parse(9)).toBe(9);
});
it("fails non-multiple", { timeout: 5000 }, () => {
const r = s.safeParse(7);
expect(r.success).toBe(false);
if (!r.success)
expect(r.error.issues[0]?.code).toBe(
"VALIDATION.NUMBER.NOT_MULTIPLE"
);
});
});
describe("NumberSchema — finite", () => {
const s = number().finite();
it("passes finite", { timeout: 5000 }, () => {
expect(s.parse(42)).toBe(42);
});
it("rejects Infinity", { timeout: 5000 }, () => {
expect(s.safeParse(Number.POSITIVE_INFINITY).success).toBe(false);
});
it("rejects NaN", { timeout: 5000 }, () => {
expect(s.safeParse(Number.NaN).success).toBe(false);
});
});

View File

@@ -0,0 +1,146 @@
import {
addIssue,
BaseSchema,
type INVALID,
} from "../../core/schema/schema.js";
import type { ParseContext } from "../../types/common.js";
// ---------------------------------------------------------------------------
// Constraints
// ---------------------------------------------------------------------------
interface NumberConstraints {
min?: number;
max?: number;
int?: boolean;
positive?: boolean;
negative?: boolean;
multipleOf?: number;
finite?: boolean;
}
// ---------------------------------------------------------------------------
// NumberSchema
// ---------------------------------------------------------------------------
export class NumberSchema extends BaseSchema<number, number> {
private readonly _constraints: NumberConstraints;
constructor(constraints: NumberConstraints = {}) {
super();
this._constraints = constraints;
}
_parse(input: unknown, ctx: ParseContext): number | INVALID {
if (typeof input !== "number") {
return addIssue(ctx, {
code: "VALIDATION.INVALID_TYPE",
path: [...ctx.path],
message: `Expected number, received ${typeof input}`,
input,
});
}
const c = this._constraints;
const value = input;
if (c.finite !== false && !Number.isFinite(value)) {
return addIssue(ctx, {
code: "VALIDATION.NUMBER.NOT_FINITE",
path: [...ctx.path],
message: "Number must be finite",
input,
});
}
if (c.int && !Number.isInteger(value)) {
return addIssue(ctx, {
code: "VALIDATION.NUMBER.NOT_INT",
path: [...ctx.path],
message: "Expected integer",
input,
});
}
if (c.min !== undefined && value < c.min) {
return addIssue(ctx, {
code: "VALIDATION.NUMBER.TOO_SMALL",
path: [...ctx.path],
message: `Number must be >= ${c.min}`,
input,
});
}
if (c.max !== undefined && value > c.max) {
return addIssue(ctx, {
code: "VALIDATION.NUMBER.TOO_LARGE",
path: [...ctx.path],
message: `Number must be <= ${c.max}`,
input,
});
}
if (c.positive && value <= 0) {
return addIssue(ctx, {
code: "VALIDATION.NUMBER.TOO_SMALL",
path: [...ctx.path],
message: "Number must be positive",
input,
});
}
if (c.negative && value >= 0) {
return addIssue(ctx, {
code: "VALIDATION.NUMBER.TOO_LARGE",
path: [...ctx.path],
message: "Number must be negative",
input,
});
}
if (c.multipleOf !== undefined && value % c.multipleOf !== 0) {
return addIssue(ctx, {
code: "VALIDATION.NUMBER.NOT_MULTIPLE",
path: [...ctx.path],
message: `Number must be a multiple of ${c.multipleOf}`,
input,
});
}
return value;
}
// -------------------------------------------------------------------------
// Constraint builders
// -------------------------------------------------------------------------
min(n: number): NumberSchema {
return new NumberSchema({ ...this._constraints, min: n });
}
max(n: number): NumberSchema {
return new NumberSchema({ ...this._constraints, max: n });
}
int(): NumberSchema {
return new NumberSchema({ ...this._constraints, int: true });
}
positive(): NumberSchema {
return new NumberSchema({ ...this._constraints, positive: true });
}
negative(): NumberSchema {
return new NumberSchema({ ...this._constraints, negative: true });
}
multipleOf(n: number): NumberSchema {
return new NumberSchema({ ...this._constraints, multipleOf: n });
}
finite(): NumberSchema {
return new NumberSchema({ ...this._constraints, finite: true });
}
}
// ---------------------------------------------------------------------------
// Factory
// ---------------------------------------------------------------------------
export function number(): NumberSchema {
return new NumberSchema();
}

View File

@@ -0,0 +1 @@
export { ObjectSchema, object } from "./object";

View File

@@ -0,0 +1,77 @@
# ObjectSchema
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`ObjectSchema<S>` validates plain JavaScript objects against a named-key shape. Each key in the shape is validated against its corresponding schema, and errors carry full dot-path information. Three modes control unknown key handling: `strip` (default, silently removes extras), `strict` (rejects extras), and `passthrough` (copies extras to output). Rich shape-manipulation builders — `pick`, `omit`, `partial`, `extend`, `merge` — make it easy to derive new schemas from existing ones without duplication.
All builder methods return new `ObjectSchema` instances; the original is never modified.
## Features
- Type check: rejects non-objects, null, and arrays with `VALIDATION.INVALID_TYPE`
- Field validation with per-field error paths
- Three unknown-key modes: `strip`, `strict`, `passthrough`
- `pick(keys)` — project to a subset of keys
- `omit(keys)` — exclude keys
- `partial()` — make all fields optional
- `required()` — return schema as-is (identity, for symmetry)
- `extend(shape)` — add/override fields
- `merge(other)` — combine with another `ObjectSchema`
## Usage: Basic Example
```typescript
import { object, string, number } from "@suaveplan/validation";
const User = object({ name: string(), age: number() });
const user = User.parse({ name: "Alice", age: 30 });
// { name: "Alice", age: 30 }
```
## Usage: Advanced Example
```typescript
import { object, string, number } from "@suaveplan/validation";
const BaseUser = object({ id: string().uuid(), name: string() });
const AdminUser = BaseUser.extend({ role: string(), level: number().int() });
const PartialUpdate = AdminUser.partial().omit(["id"]);
const update = PartialUpdate.parse({ name: "Bob" });
// { name: "Bob" } — all fields optional, id omitted
const strict = AdminUser.strict();
strict.parse({ id: "...", name: "Alice", role: "admin", level: 1, extra: true });
// throws: Unrecognized key(s): extra
```
## API Reference
### `object<S extends ObjectShape>(shape: S): ObjectSchema<S>`
Factory with default `strip` mode.
### Mode methods (return new `ObjectSchema<S>`)
- `strict()` — reject unknown keys with `VALIDATION.OBJECT.UNRECOGNIZED_KEYS`
- `passthrough()` — copy unknown keys to output
- `strip()` — silently drop unknown keys (default)
### Shape manipulation methods
- `pick<K>(keys: K[]): ObjectSchema<Pick<S, K>>` — subset of keys
- `omit<K>(keys: K[]): ObjectSchema<Omit<S, K>>` — exclude keys
- `partial(): ObjectSchema<...>` — wrap all values in `OptionalSchema`
- `required(): ObjectSchema<S>` — identity (for symmetry with `partial`)
- `extend<E>(shape: E): ObjectSchema<S & E>` — add/override fields
- `merge<E>(other: ObjectSchema<E>): ObjectSchema<S & E>` — merge shapes
## Implementation Notes
- Field errors are reported under the field's key path. Nested object errors propagate the full path chain.
- `partial()` wraps each field schema with `.optional()`, so the output type has all values as `T | undefined`.
- `passthrough` copies unknown keys before iterating known ones, then known-key parsing runs normally on top.
- `strict` checks for unknown keys using a `Set` of shape keys — O(n) where n = number of input keys.

View File

@@ -0,0 +1,168 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { ValidationError } from "../../errors/errors.js";
import { number } from "../number/number.js";
import { string } from "../string/string.js";
import { object } from "./object.js";
const shape = { name: string(), age: number() };
describe("ObjectSchema — basic parsing", () => {
const s = object(shape);
it("parses valid object", { timeout: 5000 }, () => {
const result = s.parse({ name: "Alice", age: 30 });
expect(result).toEqual({ name: "Alice", age: 30 });
});
it("rejects non-object", { timeout: 5000 }, () => {
expect(() => s.parse("not an object")).toThrow(ValidationError);
});
it("rejects null", { timeout: 5000 }, () => {
expect(() => s.parse(null)).toThrow(ValidationError);
});
it("rejects array", { timeout: 5000 }, () => {
expect(() => s.parse([])).toThrow(ValidationError);
});
it("strips unknown keys by default", { timeout: 5000 }, () => {
const result = s.parse({ name: "Bob", age: 25, extra: true });
expect((result as Record<string, unknown>)["extra"]).toBeUndefined();
});
it("collects field errors", { timeout: 5000 }, () => {
const r = s.safeParse({ name: 42, age: "old" });
expect(r.success).toBe(false);
if (!r.success) expect(r.error.issues.length).toBeGreaterThanOrEqual(2);
});
});
describe("ObjectSchema — strict mode", () => {
const s = object(shape).strict();
it("accepts known keys only", { timeout: 5000 }, () => {
expect(s.parse({ name: "Alice", age: 30 })).toEqual({
name: "Alice",
age: 30,
});
});
it("rejects unknown keys", { timeout: 5000 }, () => {
const r = s.safeParse({ name: "Alice", age: 30, extra: true });
expect(r.success).toBe(false);
if (!r.success)
expect(r.error.issues[0]?.code).toBe(
"VALIDATION.OBJECT.UNRECOGNIZED_KEYS"
);
});
});
describe("ObjectSchema — passthrough mode", () => {
const s = object(shape).passthrough();
it("preserves unknown keys", { timeout: 5000 }, () => {
const result = s.parse({ name: "Alice", age: 30, extra: "yes" });
expect((result as Record<string, unknown>)["extra"]).toBe("yes");
});
});
describe("ObjectSchema — strip mode", () => {
const s = object(shape).strip();
it("strips unknown keys", { timeout: 5000 }, () => {
const result = s.parse({ name: "Alice", age: 30, extra: "yes" });
expect((result as Record<string, unknown>)["extra"]).toBeUndefined();
});
});
describe("ObjectSchema — pick", () => {
const s = object(shape).pick(["name"]);
it("only validates picked keys", { timeout: 5000 }, () => {
const result = s.parse({ name: "Alice" });
expect(result).toEqual({ name: "Alice" });
});
});
describe("ObjectSchema — omit", () => {
const s = object(shape).omit(["age"]);
it("only validates remaining keys", { timeout: 5000 }, () => {
const result = s.parse({ name: "Alice" });
expect(result).toEqual({ name: "Alice" });
});
});
describe("ObjectSchema — partial", () => {
const s = object(shape).partial();
it("accepts fully defined object", { timeout: 5000 }, () => {
expect(s.parse({ name: "Alice", age: 30 })).toMatchObject({
name: "Alice",
});
});
it("accepts partially defined object", { timeout: 5000 }, () => {
const result = s.parse({ name: "Alice" });
expect((result as Record<string, unknown>)["name"]).toBe("Alice");
});
it("accepts empty object", { timeout: 5000 }, () => {
const result = s.parse({});
expect(result).toMatchObject({});
});
});
describe("ObjectSchema — required", () => {
const s = object(shape).required();
it("still validates normally", { timeout: 5000 }, () => {
expect(s.parse({ name: "Alice", age: 30 })).toEqual({
name: "Alice",
age: 30,
});
});
});
describe("ObjectSchema — extend", () => {
const s = object(shape).extend({ email: string() });
it("validates extended shape", { timeout: 5000 }, () => {
const result = s.parse({ name: "Alice", age: 30, email: "a@b.com" });
expect((result as Record<string, unknown>)["email"]).toBe("a@b.com");
});
});
describe("ObjectSchema — merge", () => {
const extra = object({ email: string() });
const s = object(shape).merge(extra);
it("validates merged shape", { timeout: 5000 }, () => {
const result = s.parse({ name: "Alice", age: 30, email: "a@b.com" });
expect((result as Record<string, unknown>)["email"]).toBe("a@b.com");
});
});
describe("ObjectSchema — nested", () => {
const nested = object({
user: object({ name: string() }),
count: number(),
});
it("parses nested objects", { timeout: 5000 }, () => {
const result = nested.parse({ user: { name: "Alice" }, count: 1 });
expect(result.user.name).toBe("Alice");
});
it("reports nested errors with path", { timeout: 5000 }, () => {
const r = nested.safeParse({ user: { name: 42 }, count: 1 });
expect(r.success).toBe(false);
if (!r.success) {
const issue = r.error.issues.find(
(i) => i.path.includes("user") && i.path.includes("name")
);
expect(issue).toBeDefined();
}
});
});

View File

@@ -0,0 +1,180 @@
import { addIssue, BaseSchema, INVALID } from "../../core/schema/schema.js";
import type {
AnySchema,
ObjectShape,
ParseContext,
} from "../../types/common.js";
// ---------------------------------------------------------------------------
// Mode
// ---------------------------------------------------------------------------
type ObjectMode = "strip" | "strict" | "passthrough";
// ---------------------------------------------------------------------------
// Type helpers
// ---------------------------------------------------------------------------
type ShapeOutput<S extends ObjectShape> = {
[K in keyof S]: S[K] extends BaseSchema<unknown, infer O> ? O : never;
};
// ---------------------------------------------------------------------------
// ObjectSchema
// ---------------------------------------------------------------------------
export class ObjectSchema<S extends ObjectShape> extends BaseSchema<
ShapeOutput<S>,
ShapeOutput<S>
> {
protected readonly _shape: S;
protected readonly _mode: ObjectMode;
constructor(shape: S, mode: ObjectMode = "strip") {
super();
this._shape = shape;
this._mode = mode;
}
_parse(input: unknown, ctx: ParseContext): ShapeOutput<S> | INVALID {
if (
typeof input !== "object" ||
input === null ||
Array.isArray(input)
) {
return addIssue(ctx, {
code: "VALIDATION.INVALID_TYPE",
path: [...ctx.path],
message: `Expected object, received ${input === null ? "null" : Array.isArray(input) ? "array" : typeof input}`,
input,
});
}
const raw = input as Record<string, unknown>;
const shapeKeys = new Set(Object.keys(this._shape));
const inputKeys = Object.keys(raw);
// Strict mode: reject unknown keys
if (this._mode === "strict") {
const unknown = inputKeys.filter((k) => !shapeKeys.has(k));
if (unknown.length > 0) {
return addIssue(ctx, {
code: "VALIDATION.OBJECT.UNRECOGNIZED_KEYS",
path: [...ctx.path],
message: `Unrecognized key(s): ${unknown.join(", ")}`,
input,
});
}
}
const out: Record<string, unknown> = {};
let hasInvalid = false;
// Passthrough: copy unknown keys first
if (this._mode === "passthrough") {
for (const k of inputKeys) {
if (!shapeKeys.has(k)) {
out[k] = raw[k];
}
}
}
// Parse known keys
for (const key of Object.keys(this._shape)) {
const schema = this._shape[key]!;
const childCtx: ParseContext = {
path: [...ctx.path, key],
issues: [],
};
const result = schema._parse(raw[key], childCtx);
if (result === INVALID || childCtx.issues.length > 0) {
ctx.issues.push(...childCtx.issues);
hasInvalid = true;
} else {
out[key] = result;
}
}
return hasInvalid ? INVALID : (out as ShapeOutput<S>);
}
// -------------------------------------------------------------------------
// Mode builders
// -------------------------------------------------------------------------
strict(): ObjectSchema<S> {
return new ObjectSchema(this._shape, "strict");
}
passthrough(): ObjectSchema<S> {
return new ObjectSchema(this._shape, "passthrough");
}
strip(): ObjectSchema<S> {
return new ObjectSchema(this._shape, "strip");
}
// -------------------------------------------------------------------------
// Shape manipulation builders
// -------------------------------------------------------------------------
pick<K extends keyof S>(keys: K[]): ObjectSchema<Pick<S, K>> {
const newShape = {} as Pick<S, K>;
for (const k of keys) {
newShape[k] = this._shape[k];
}
return new ObjectSchema(newShape, this._mode);
}
omit<K extends keyof S>(keys: K[]): ObjectSchema<Omit<S, K>> {
const omitSet = new Set(keys as string[]);
const newShape = {} as Omit<S, K>;
for (const k of Object.keys(this._shape)) {
if (!omitSet.has(k)) {
(newShape as Record<string, AnySchema>)[k] = this._shape[k]!;
}
}
return new ObjectSchema(newShape as Omit<S, K>, this._mode);
}
// biome-ignore lint/suspicious/noExplicitAny: complex mapped-type returned via any for ergonomic partial()
partial(): ObjectSchema<any> {
const newShape: Record<string, AnySchema> = {};
for (const k of Object.keys(this._shape)) {
newShape[k] = this._shape[k]!.optional();
}
// biome-ignore lint/suspicious/noExplicitAny: shape is dynamically built
return new ObjectSchema(newShape as any, this._mode);
}
required(): ObjectSchema<S> {
const newShape: Record<string, AnySchema> = {};
for (const k of Object.keys(this._shape)) {
newShape[k] = this._shape[k]!;
}
return new ObjectSchema(newShape as S, this._mode);
}
extend<E extends ObjectShape>(extraShape: E): ObjectSchema<S & E> {
return new ObjectSchema(
{ ...this._shape, ...extraShape } as S & E,
this._mode
);
}
merge<E extends ObjectShape>(other: ObjectSchema<E>): ObjectSchema<S & E> {
return new ObjectSchema(
{ ...this._shape, ...other._shape } as S & E,
this._mode
);
}
}
// ---------------------------------------------------------------------------
// Factory
// ---------------------------------------------------------------------------
export function object<S extends ObjectShape>(shape: S): ObjectSchema<S> {
return new ObjectSchema(shape);
}

View File

@@ -0,0 +1 @@
export { OptionalSchema, optional } from "./optional";

View File

@@ -0,0 +1,68 @@
# OptionalSchema
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`OptionalSchema<T>` wraps an inner schema so that `undefined` passes through without validation. Any other value is delegated to the inner schema. It is the canonical way to mark fields as optional in object shapes and as the return type of `.optional()` on any `BaseSchema`.
## Features
- Passes `undefined` through without calling the inner schema
- Delegates all other values to the inner schema
- Composable: `optional(nullable(string()))` is valid
## Usage: Basic Example
```typescript
import { optional, string } from "@suaveplan/validation";
const nickname = optional(string().min(2));
nickname.parse(undefined); // undefined
nickname.parse("Al"); // "Al"
nickname.parse(42); // throws ValidationError
```
## Usage: Advanced Example
```typescript
import { object, string, number, optional } from "@suaveplan/validation";
const ProfileUpdate = object({
displayName: optional(string().min(1).max(50)),
bio: optional(string().max(500)),
age: optional(number().int().min(13)),
});
ProfileUpdate.parse({}); // all optional, empty object valid
```
## API Reference
### `optional<T>(schema: BaseSchema<unknown, T>): OptionalSchema<T>`
Factory wrapping an inner schema.
### `OptionalSchema<T>`
Class extending `BaseSchema<T | undefined, T | undefined>`. The `_parse` method returns `undefined` immediately for `undefined` inputs.
## Implementation Notes
- `OptionalSchema` is also defined in the core `schema.ts` module and re-exported here for convenience.
- `undefined` is the only value that bypasses the inner schema; `null` is not treated as absent (use `nullable()` for that).
### Parse Behaviour
`schema.optional()` wraps any schema so `undefined` is accepted in addition to the underlying type. The inferred output type is `T | undefined`. Object schemas treat missing keys as `undefined`, so `z.object({ x: z.string().optional() }).parse({})` is valid.
## Further Usage: Basic Example
```ts
import z from "@suaveplan/validation";
const s = z.string().optional();
s.parse(undefined); // undefined
```
Internally `.optional()` is a thin wrapper that short-circuits on `undefined` and delegates everything else to the inner schema. Optionality composes cleanly with nullability, defaults, and refinements — the inner transforms only see defined, non-null inputs so their implementations stay simple.

View File

@@ -0,0 +1,26 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { number } from "../number/number.js";
import { string } from "../string/string.js";
import { optional } from "./optional.js";
describe("optional(schema)", () => {
const s = optional(string());
it("allows undefined", { timeout: 5000 }, () => {
expect(s.parse(undefined)).toBeUndefined();
});
it("allows valid inner value", { timeout: 5000 }, () => {
expect(s.parse("hello")).toBe("hello");
});
it("fails invalid inner value", { timeout: 5000 }, () => {
expect(s.safeParse(42).success).toBe(false);
});
it("works with number schema", { timeout: 5000 }, () => {
const n = optional(number());
expect(n.parse(42)).toBe(42);
expect(n.parse(undefined)).toBeUndefined();
});
});

View File

@@ -0,0 +1,25 @@
import {
type BaseSchema,
type INVALID,
OptionalSchema,
} from "../../core/schema/schema.js";
import type { ParseContext } from "../../types/common.js";
// Re-export OptionalSchema from core for external use
export { OptionalSchema } from "../../core/schema/schema.js";
// ---------------------------------------------------------------------------
// Factory
// ---------------------------------------------------------------------------
export function optional<T>(schema: BaseSchema<unknown, T>): OptionalSchema<T> {
return new OptionalSchema(schema);
}
// ---------------------------------------------------------------------------
// Standalone optional schema (same as core OptionalSchema, re-exported)
// ---------------------------------------------------------------------------
// Ensure ParseContext is used (avoids unused import lint error)
type _PC = ParseContext;
type _INVALID = INVALID;

View File

@@ -0,0 +1 @@
export { RecordSchema, record } from "./record";

View File

@@ -0,0 +1,56 @@
# RecordSchema
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`RecordSchema<T>` validates that an input is a plain object where every value satisfies a given schema. Unlike `ObjectSchema`, which checks a fixed set of named keys, `RecordSchema` iterates all present keys and validates each value dynamically — making it ideal for dictionaries, maps, and index-signature types.
## Features
- Type check: rejects non-objects, null, and arrays with `VALIDATION.INVALID_TYPE`
- Value validation: every value parsed against the element schema
- All value errors collected with key-based paths
## Usage: Basic Example
```typescript
import { record, number } from "@suaveplan/validation";
const scores = record(number());
scores.parse({ alice: 95, bob: 88 }); // { alice: 95, bob: 88 }
scores.parse({ alice: "high" }); // throws ValidationError
```
## Usage: Advanced Example
```typescript
import { record, object, string, number } from "@suaveplan/validation";
// Indexed config map
const config = record(object({ value: string(), ttl: number().int() }));
config.parse({
featureA: { value: "enabled", ttl: 3600 },
featureB: { value: "disabled", ttl: 0 },
});
```
## API Reference
### `record<T>(valueSchema: BaseSchema<unknown, T>): RecordSchema<T>`
Factory accepting the value schema.
### `_parse(input, ctx): Record<string, T> | INVALID`
Validates `typeof input === "object"`, not null, not array, then iterates `Object.keys(input)` and parses each value. All errors collected; returns `INVALID` if any fail.
## Implementation Notes
- `record` does not validate keys (keys are always strings in JS objects). Key validation would require a `RecordSchema<K, V>` variant — not currently implemented.
- Error paths use the key string as a path segment: `[...parentPath, key]`.
### Parse Behaviour
`z.record(keySchema, valueSchema)` validates every key/value pair in the input object against the supplied schemas. Unknown keys are preserved. Pair with `z.string()` / `z.enum([...])` as the key schema to narrow the allowed key set. `strict()` / `strip()` / `passthrough()` are not applicable — records are inherently open-shape.

View File

@@ -0,0 +1,51 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { ValidationError } from "../../errors/errors.js";
import { number } from "../number/number.js";
import { string } from "../string/string.js";
import { record } from "./record.js";
describe("RecordSchema", () => {
const s = record(number());
it("accepts record of numbers", { timeout: 5000 }, () => {
expect(s.parse({ a: 1, b: 2 })).toEqual({ a: 1, b: 2 });
});
it("accepts empty record", { timeout: 5000 }, () => {
expect(s.parse({})).toEqual({});
});
it("rejects non-object", { timeout: 5000 }, () => {
expect(() => s.parse("not object")).toThrow(ValidationError);
});
it("rejects null", { timeout: 5000 }, () => {
expect(() => s.parse(null)).toThrow(ValidationError);
});
it("rejects array", { timeout: 5000 }, () => {
expect(() => s.parse([1, 2])).toThrow(ValidationError);
});
it("rejects record with wrong value types", { timeout: 5000 }, () => {
const r = s.safeParse({ a: "not a number" });
expect(r.success).toBe(false);
});
it("includes key in error path", { timeout: 5000 }, () => {
const r = s.safeParse({ a: 1, b: "bad" });
expect(r.success).toBe(false);
if (!r.success) {
const issue = r.error.issues.find((i) => i.path.includes("b"));
expect(issue).toBeDefined();
}
});
it("works with string values", { timeout: 5000 }, () => {
const rs = record(string());
expect(rs.parse({ x: "hello", y: "world" })).toEqual({
x: "hello",
y: "world",
});
});
});

View File

@@ -0,0 +1,63 @@
import { addIssue, BaseSchema, INVALID } from "../../core/schema/schema.js";
import type { ParseContext } from "../../types/common.js";
// ---------------------------------------------------------------------------
// RecordSchema
// ---------------------------------------------------------------------------
export class RecordSchema<T> extends BaseSchema<
Record<string, T>,
Record<string, T>
> {
private readonly _value: BaseSchema<unknown, T>;
constructor(value: BaseSchema<unknown, T>) {
super();
this._value = value;
}
_parse(input: unknown, ctx: ParseContext): Record<string, T> | INVALID {
if (
typeof input !== "object" ||
input === null ||
Array.isArray(input)
) {
return addIssue(ctx, {
code: "VALIDATION.INVALID_TYPE",
path: [...ctx.path],
message: `Expected record object, received ${input === null ? "null" : Array.isArray(input) ? "array" : typeof input}`,
input,
});
}
const raw = input as Record<string, unknown>;
const out: Record<string, T> = {};
let hasInvalid = false;
for (const key of Object.keys(raw)) {
const childCtx: ParseContext = {
path: [...ctx.path, key],
issues: [],
};
const result = this._value._parse(raw[key], childCtx);
if (result === INVALID || childCtx.issues.length > 0) {
ctx.issues.push(...childCtx.issues);
hasInvalid = true;
} else {
out[key] = result as T;
}
}
return hasInvalid ? INVALID : out;
}
}
// ---------------------------------------------------------------------------
// Factory
// ---------------------------------------------------------------------------
export function record<T>(
valueSchema: BaseSchema<unknown, T>
): RecordSchema<T> {
return new RecordSchema(valueSchema);
}

View File

@@ -0,0 +1 @@
export { StringSchema, string } from "./string";

View File

@@ -0,0 +1,90 @@
# StringSchema
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`StringSchema` validates that an input is a JavaScript `string` and optionally enforces structural constraints: minimum/maximum length, exact length, regex pattern, email format, URL format, UUID format, prefix/suffix/substring checks. It also supports output transforms (trim, toLowerCase, toUpperCase) applied before constraint evaluation. Every builder method returns a new instance, making the API fully immutable.
This schema is the most commonly used primitive in form and API validation. All constraints are evaluated in a single `_parse` call with no heap allocation beyond the output string, making it suitable for high-frequency validation paths.
## Features
- Type check: rejects non-string with `VALIDATION.INVALID_TYPE`
- `min(n)` / `max(n)` / `length(n)` — length constraints
- `regex(re)` — arbitrary pattern matching
- `email()` — basic RFC-style email check (`user@domain.tld`)
- `url()` — requires `http://` or `https://` prefix
- `uuid()` — 36-character UUID v4-style regex
- `startsWith(prefix)` / `endsWith(suffix)` / `includes(substr)` — substring checks
- `trim()` / `toLowerCase()` / `toUpperCase()` — output transforms
## Usage: Basic Example
```typescript
import { string } from "@suaveplan/validation";
const username = string().min(3).max(20);
const result = username.safeParse("alice");
// { success: true, data: "alice" }
```
## Usage: Advanced Example
```typescript
import { string } from "@suaveplan/validation";
// Email with domain block refinement
const email = string()
.email()
.toLowerCase()
.trim()
.refine((v) => !v.endsWith("@spam.com"), "Spam domains not allowed")
.describe("User contact email");
// UUID validator
const id = string().uuid().describe("Resource identifier");
// URL with path check
const webhookUrl = string()
.url()
.startsWith("https://")
.includes("/webhook");
```
## API Reference
### `string(): StringSchema`
Factory returning an unconstrained `StringSchema`.
### Constraint methods (all return new `StringSchema`)
| Method | Error code | Description |
|--------|-----------|-------------|
| `min(n)` | `STRING.TOO_SHORT` | Length must be ≥ n |
| `max(n)` | `STRING.TOO_LONG` | Length must be ≤ n |
| `length(n)` | `STRING.INVALID_FORMAT` | Length must be exactly n |
| `regex(re)` | `STRING.INVALID_FORMAT` | Must match regex |
| `email()` | `STRING.INVALID_FORMAT` | Must be valid email |
| `url()` | `STRING.INVALID_FORMAT` | Must start with `http://` or `https://` |
| `uuid()` | `STRING.INVALID_FORMAT` | Must be valid UUID |
| `startsWith(p)` | `STRING.INVALID_FORMAT` | Must begin with prefix p |
| `endsWith(s)` | `STRING.INVALID_FORMAT` | Must end with suffix s |
| `includes(sub)` | `STRING.INVALID_FORMAT` | Must contain substring |
### Transform methods (all return new `StringSchema`)
| Method | Effect |
|--------|--------|
| `trim()` | `String.prototype.trim()` before checks |
| `toLowerCase()` | Converts to lowercase before checks |
| `toUpperCase()` | Converts to uppercase before checks |
## Implementation Notes
- Transforms are applied before constraint checks. This means `string().trim().min(3)` validates the trimmed string.
- The email regex (`/^[^\s@]+@[^\s@]+\.[^\s@]+$/`) is intentionally simple — RFC 5321 compliance is not the goal; rejecting obviously malformed inputs is.
- `uuid()` uses a case-insensitive regex matching the standard 8-4-4-4-12 hex format.
- All builder methods create new `StringSchema` instances via spread of `_constraints`, so chaining is safe across branches.

View File

@@ -0,0 +1,206 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { ValidationError } from "../../errors/errors.js";
import { string } from "./string.js";
describe("StringSchema — type check", () => {
const s = string();
it("accepts string", { timeout: 5000 }, () => {
expect(s.parse("hello")).toBe("hello");
});
it("rejects number", { timeout: 5000 }, () => {
expect(() => s.parse(42)).toThrow(ValidationError);
});
it("rejects null", { timeout: 5000 }, () => {
expect(() => s.parse(null)).toThrow(ValidationError);
});
it("rejects undefined", { timeout: 5000 }, () => {
expect(() => s.parse(undefined)).toThrow(ValidationError);
});
});
describe("StringSchema — min", () => {
const s = string().min(3);
it("passes for sufficient length", { timeout: 5000 }, () => {
expect(s.parse("abc")).toBe("abc");
});
it("fails below min", { timeout: 5000 }, () => {
const r = s.safeParse("ab");
expect(r.success).toBe(false);
if (!r.success)
expect(r.error.issues[0]?.code).toBe("VALIDATION.STRING.TOO_SHORT");
});
});
describe("StringSchema — max", () => {
const s = string().max(3);
it("passes for short string", { timeout: 5000 }, () => {
expect(s.parse("abc")).toBe("abc");
});
it("fails above max", { timeout: 5000 }, () => {
const r = s.safeParse("abcd");
expect(r.success).toBe(false);
if (!r.success)
expect(r.error.issues[0]?.code).toBe("VALIDATION.STRING.TOO_LONG");
});
});
describe("StringSchema — length", () => {
const s = string().length(4);
it("passes exact length", { timeout: 5000 }, () => {
expect(s.parse("abcd")).toBe("abcd");
});
it("fails wrong length", { timeout: 5000 }, () => {
const r = s.safeParse("abc");
expect(r.success).toBe(false);
if (!r.success)
expect(r.error.issues[0]?.code).toBe(
"VALIDATION.STRING.INVALID_FORMAT"
);
});
});
describe("StringSchema — regex", () => {
const s = string().regex(/^\d+$/);
it("passes matching", { timeout: 5000 }, () => {
expect(s.parse("123")).toBe("123");
});
it("fails non-matching", { timeout: 5000 }, () => {
const r = s.safeParse("abc");
expect(r.success).toBe(false);
if (!r.success)
expect(r.error.issues[0]?.code).toBe(
"VALIDATION.STRING.INVALID_FORMAT"
);
});
});
describe("StringSchema — email", () => {
const s = string().email();
it("accepts valid email", { timeout: 5000 }, () => {
expect(s.parse("user@example.com")).toBe("user@example.com");
});
it("rejects invalid email", { timeout: 5000 }, () => {
const r = s.safeParse("not-an-email");
expect(r.success).toBe(false);
if (!r.success)
expect(r.error.issues[0]?.code).toBe(
"VALIDATION.STRING.INVALID_FORMAT"
);
});
});
describe("StringSchema — url", () => {
const s = string().url();
it("accepts https URL", { timeout: 5000 }, () => {
expect(s.parse("https://example.com")).toBe("https://example.com");
});
it("accepts http URL", { timeout: 5000 }, () => {
expect(s.parse("http://example.com")).toBe("http://example.com");
});
it("rejects non-URL", { timeout: 5000 }, () => {
const r = s.safeParse("ftp://example.com");
expect(r.success).toBe(false);
});
});
describe("StringSchema — uuid", () => {
const s = string().uuid();
it("accepts valid UUID", { timeout: 5000 }, () => {
expect(s.parse("550e8400-e29b-41d4-a716-446655440000")).toBe(
"550e8400-e29b-41d4-a716-446655440000"
);
});
it("rejects invalid UUID", { timeout: 5000 }, () => {
const r = s.safeParse("not-a-uuid");
expect(r.success).toBe(false);
});
});
describe("StringSchema — startsWith", () => {
const s = string().startsWith("hello");
it("passes matching prefix", { timeout: 5000 }, () => {
expect(s.parse("hello world")).toBe("hello world");
});
it("fails wrong prefix", { timeout: 5000 }, () => {
const r = s.safeParse("world");
expect(r.success).toBe(false);
});
});
describe("StringSchema — endsWith", () => {
const s = string().endsWith(".ts");
it("passes matching suffix", { timeout: 5000 }, () => {
expect(s.parse("file.ts")).toBe("file.ts");
});
it("fails wrong suffix", { timeout: 5000 }, () => {
const r = s.safeParse("file.js");
expect(r.success).toBe(false);
});
});
describe("StringSchema — includes", () => {
const s = string().includes("foo");
it("passes containing substring", { timeout: 5000 }, () => {
expect(s.parse("foobar")).toBe("foobar");
});
it("fails missing substring", { timeout: 5000 }, () => {
const r = s.safeParse("bar");
expect(r.success).toBe(false);
});
});
describe("StringSchema — transforms", () => {
it("trim removes whitespace", { timeout: 5000 }, () => {
expect(string().trim().parse(" hello ")).toBe("hello");
});
it("toLowerCase converts", { timeout: 5000 }, () => {
expect(string().toLowerCase().parse("HELLO")).toBe("hello");
});
it("toUpperCase converts", { timeout: 5000 }, () => {
expect(string().toUpperCase().parse("hello")).toBe("HELLO");
});
});
describe("StringSchema — chaining", () => {
it("combines multiple constraints", { timeout: 5000 }, () => {
const s = string().min(2).max(10).email();
const r = s.safeParse("a@b.c");
expect(r.success).toBe(true);
});
it("immutable: original unaffected after chaining", {
timeout: 5000,
}, () => {
const base = string();
const withMin = base.min(5);
expect(base.safeParse("ab").success).toBe(true);
expect(withMin.safeParse("ab").success).toBe(false);
});
});

View File

@@ -0,0 +1,214 @@
import {
addIssue,
BaseSchema,
type INVALID,
} from "../../core/schema/schema.js";
import type { ParseContext } from "../../types/common.js";
// ---------------------------------------------------------------------------
// Constraints stored on the schema
// ---------------------------------------------------------------------------
interface StringConstraints {
min?: number;
max?: number;
length?: number;
regex?: RegExp;
email?: boolean;
url?: boolean;
uuid?: boolean;
startsWith?: string;
endsWith?: string;
includes?: string;
trim?: boolean;
toLowerCase?: boolean;
toUpperCase?: boolean;
}
const UUID_RE =
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
// ---------------------------------------------------------------------------
// StringSchema
// ---------------------------------------------------------------------------
export class StringSchema extends BaseSchema<string, string> {
private readonly _constraints: StringConstraints;
constructor(constraints: StringConstraints = {}) {
super();
this._constraints = constraints;
}
_parse(input: unknown, ctx: ParseContext): string | INVALID {
if (typeof input !== "string") {
return addIssue(ctx, {
code: "VALIDATION.INVALID_TYPE",
path: [...ctx.path],
message: `Expected string, received ${typeof input}`,
input,
});
}
let value = input;
const c = this._constraints;
// Transforms (applied before constraint checks)
if (c.trim) value = value.trim();
if (c.toLowerCase) value = value.toLowerCase();
if (c.toUpperCase) value = value.toUpperCase();
// Constraints
if (c.min !== undefined && value.length < c.min) {
return addIssue(ctx, {
code: "VALIDATION.STRING.TOO_SHORT",
path: [...ctx.path],
message: `String must be at least ${c.min} character(s)`,
input,
});
}
if (c.max !== undefined && value.length > c.max) {
return addIssue(ctx, {
code: "VALIDATION.STRING.TOO_LONG",
path: [...ctx.path],
message: `String must be at most ${c.max} character(s)`,
input,
});
}
if (c.length !== undefined && value.length !== c.length) {
return addIssue(ctx, {
code: "VALIDATION.STRING.INVALID_FORMAT",
path: [...ctx.path],
message: `String must be exactly ${c.length} character(s)`,
input,
});
}
if (c.regex !== undefined && !c.regex.test(value)) {
return addIssue(ctx, {
code: "VALIDATION.STRING.INVALID_FORMAT",
path: [...ctx.path],
message: `String does not match pattern ${c.regex.toString()}`,
input,
});
}
if (c.email && !EMAIL_RE.test(value)) {
return addIssue(ctx, {
code: "VALIDATION.STRING.INVALID_FORMAT",
path: [...ctx.path],
message: "Invalid email address",
input,
});
}
if (
c.url &&
!value.startsWith("http://") &&
!value.startsWith("https://")
) {
return addIssue(ctx, {
code: "VALIDATION.STRING.INVALID_FORMAT",
path: [...ctx.path],
message: "Invalid URL",
input,
});
}
if (c.uuid && !UUID_RE.test(value)) {
return addIssue(ctx, {
code: "VALIDATION.STRING.INVALID_FORMAT",
path: [...ctx.path],
message: "Invalid UUID",
input,
});
}
if (c.startsWith !== undefined && !value.startsWith(c.startsWith)) {
return addIssue(ctx, {
code: "VALIDATION.STRING.INVALID_FORMAT",
path: [...ctx.path],
message: `String must start with "${c.startsWith}"`,
input,
});
}
if (c.endsWith !== undefined && !value.endsWith(c.endsWith)) {
return addIssue(ctx, {
code: "VALIDATION.STRING.INVALID_FORMAT",
path: [...ctx.path],
message: `String must end with "${c.endsWith}"`,
input,
});
}
if (c.includes !== undefined && !value.includes(c.includes)) {
return addIssue(ctx, {
code: "VALIDATION.STRING.INVALID_FORMAT",
path: [...ctx.path],
message: `String must include "${c.includes}"`,
input,
});
}
return value;
}
// -------------------------------------------------------------------------
// Constraint builders (return new instance for immutability)
// -------------------------------------------------------------------------
min(n: number): StringSchema {
return new StringSchema({ ...this._constraints, min: n });
}
max(n: number): StringSchema {
return new StringSchema({ ...this._constraints, max: n });
}
length(n: number): StringSchema {
return new StringSchema({ ...this._constraints, length: n });
}
regex(re: RegExp): StringSchema {
return new StringSchema({ ...this._constraints, regex: re });
}
email(): StringSchema {
return new StringSchema({ ...this._constraints, email: true });
}
url(): StringSchema {
return new StringSchema({ ...this._constraints, url: true });
}
uuid(): StringSchema {
return new StringSchema({ ...this._constraints, uuid: true });
}
startsWith(prefix: string): StringSchema {
return new StringSchema({ ...this._constraints, startsWith: prefix });
}
endsWith(suffix: string): StringSchema {
return new StringSchema({ ...this._constraints, endsWith: suffix });
}
includes(substr: string): StringSchema {
return new StringSchema({ ...this._constraints, includes: substr });
}
trim(): StringSchema {
return new StringSchema({ ...this._constraints, trim: true });
}
toLowerCase(): StringSchema {
return new StringSchema({ ...this._constraints, toLowerCase: true });
}
toUpperCase(): StringSchema {
return new StringSchema({ ...this._constraints, toUpperCase: true });
}
}
// ---------------------------------------------------------------------------
// Factory
// ---------------------------------------------------------------------------
export function string(): StringSchema {
return new StringSchema();
}

View File

@@ -0,0 +1 @@
export { TupleSchema, tuple } from "./tuple";

View File

@@ -0,0 +1,55 @@
# TupleSchema
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`TupleSchema<T>` validates fixed-length arrays where each positional element has its own schema. Unlike `ArraySchema` (homogeneous elements), `TupleSchema` is heterogeneous: position 0 can be a `string`, position 1 a `number`, etc. It strictly enforces both the array type and the exact length.
## Features
- Type check: rejects non-arrays with `VALIDATION.INVALID_TYPE`
- Length check: rejects arrays with wrong length with `VALIDATION.INVALID_TYPE`
- Per-position schema validation with positional error paths
## Usage: Basic Example
```typescript
import { tuple, string, number } from "@suaveplan/validation";
const coord = tuple([number(), number()]);
coord.parse([48.8566, 2.3522]); // [48.8566, 2.3522]
coord.parse([48.8566]); // throws (wrong length)
```
## Usage: Advanced Example
```typescript
import { tuple, string, number, boolean } from "@suaveplan/validation";
// CSV row parser: [id, name, score, active]
const row = tuple([string().uuid(), string().min(1), number(), boolean()]);
const result = row.safeParse(["550e8400-e29b-41d4-a716-446655440000", "Alice", 99, true]);
```
## API Reference
### `tuple<T extends AnySchema[]>(schemas: T): TupleSchema<T>`
Factory accepting an ordered array of schemas.
### `_parse(input, ctx): TupleOutputs<T> | INVALID`
Validates input is an array of the exact expected length, then parses each element against its positional schema.
## Implementation Notes
- Length mismatch is reported as `VALIDATION.INVALID_TYPE` (no dedicated `TUPLE` code) with a descriptive message including both expected and actual lengths.
- Error paths include the element index: `[...parentPath, i]`.
- Empty tuples `tuple([])` are valid and accept only `[]`.
### Parse Behaviour
`z.tuple([a, b, c])` validates fixed-length arrays where each position has a distinct schema. Extra trailing items are rejected unless you add a rest element with `.rest(schema)`. Inferred output is a typed tuple (`[A, B, C]`) rather than an array, so downstream destructuring stays fully typed.

View File

@@ -0,0 +1,52 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { ValidationError } from "../../errors/errors.js";
import { boolean } from "../boolean/boolean.js";
import { number } from "../number/number.js";
import { string } from "../string/string.js";
import { tuple } from "./tuple.js";
describe("TupleSchema", () => {
const s = tuple([string(), number(), boolean()]);
it("accepts valid tuple", { timeout: 5000 }, () => {
expect(s.parse(["hello", 42, true])).toEqual(["hello", 42, true]);
});
it("rejects non-array", { timeout: 5000 }, () => {
expect(() => s.parse("not array")).toThrow(ValidationError);
});
it("rejects wrong length", { timeout: 5000 }, () => {
expect(() => s.parse(["hello", 42])).toThrow(ValidationError);
});
it("rejects extra elements", { timeout: 5000 }, () => {
expect(() => s.parse(["hello", 42, true, "extra"])).toThrow(
ValidationError
);
});
it("rejects wrong element type", { timeout: 5000 }, () => {
const r = s.safeParse([42, "wrong", true]);
expect(r.success).toBe(false);
});
it("includes element index in error path", { timeout: 5000 }, () => {
const r = s.safeParse(["ok", "bad", true]);
expect(r.success).toBe(false);
if (!r.success) {
const issue = r.error.issues.find((i) => i.path.includes(1));
expect(issue).toBeDefined();
}
});
it("works with single-element tuple", { timeout: 5000 }, () => {
const t = tuple([number()]);
expect(t.parse([42])).toEqual([42]);
});
it("works with empty tuple", { timeout: 5000 }, () => {
const t = tuple([]);
expect(t.parse([])).toEqual([]);
});
});

View File

@@ -0,0 +1,74 @@
import { addIssue, BaseSchema, INVALID } from "../../core/schema/schema.js";
import type { AnySchema, ParseContext } from "../../types/common.js";
// ---------------------------------------------------------------------------
// Type helpers
// ---------------------------------------------------------------------------
type TupleOutputs<T extends AnySchema[]> = {
[K in keyof T]: T[K] extends BaseSchema<unknown, infer O> ? O : never;
};
// ---------------------------------------------------------------------------
// TupleSchema
// ---------------------------------------------------------------------------
export class TupleSchema<T extends AnySchema[]> extends BaseSchema<
TupleOutputs<T>,
TupleOutputs<T>
> {
private readonly _schemas: T;
constructor(schemas: T) {
super();
this._schemas = schemas;
}
_parse(input: unknown, ctx: ParseContext): TupleOutputs<T> | INVALID {
if (!Array.isArray(input)) {
return addIssue(ctx, {
code: "VALIDATION.INVALID_TYPE",
path: [...ctx.path],
message: `Expected tuple (array), received ${typeof input}`,
input,
});
}
if (input.length !== this._schemas.length) {
return addIssue(ctx, {
code: "VALIDATION.INVALID_TYPE",
path: [...ctx.path],
message: `Expected tuple of length ${this._schemas.length}, received length ${input.length}`,
input,
});
}
const out: unknown[] = [];
let hasInvalid = false;
for (let i = 0; i < this._schemas.length; i++) {
const schema = this._schemas[i]!;
const childCtx: ParseContext = {
path: [...ctx.path, i],
issues: [],
};
const result = schema._parse(input[i], childCtx);
if (result === INVALID || childCtx.issues.length > 0) {
ctx.issues.push(...childCtx.issues);
hasInvalid = true;
} else {
out.push(result);
}
}
return hasInvalid ? INVALID : (out as TupleOutputs<T>);
}
}
// ---------------------------------------------------------------------------
// Factory
// ---------------------------------------------------------------------------
export function tuple<T extends AnySchema[]>(schemas: T): TupleSchema<T> {
return new TupleSchema(schemas);
}

View File

@@ -0,0 +1 @@
export { UnionSchema, union } from "./union";

View File

@@ -0,0 +1,60 @@
# UnionSchema
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`UnionSchema` validates that an input satisfies at least one of a list of member schemas. It short-circuits on the first successful parse, returning that result without checking remaining members. When all members fail, it aggregates all their issues into the `ValidationError`, giving the caller visibility into why each arm failed.
## Features
- Short-circuit success: first matching schema wins
- All-failure aggregation: every arm's issues collected into one `ValidationError`
- Empty union: always fails with `VALIDATION.UNION.NO_MATCH`
## Usage: Basic Example
```typescript
import { union, string, number } from "@suaveplan/validation";
const stringOrNumber = union([string(), number()]);
stringOrNumber.parse("hello"); // "hello"
stringOrNumber.parse(42); // 42
stringOrNumber.parse(true); // throws ValidationError
```
## Usage: Advanced Example
```typescript
import { union, literal, object, string } from "@suaveplan/validation";
// Discriminated union
const Event = union([
object({ type: literal("created"), id: string() }),
object({ type: literal("deleted"), id: string(), reason: string() }),
]);
Event.parse({ type: "created", id: "abc" });
// { type: "created", id: "abc" }
```
## API Reference
### `union(schemas: AnySchema[]): UnionSchema`
Factory accepting an array of member schemas.
### `_parse(input, ctx): unknown | INVALID`
Iterates schemas in order. Returns the first non-`INVALID` result. On all-failure, pushes all collected issues to `ctx.issues` and returns `INVALID`. Empty union always adds a single `VALIDATION.UNION.NO_MATCH` issue.
## Implementation Notes
- Each arm runs with its own `childCtx` so its issues don't contaminate the parent context until the all-failure path is confirmed.
- The order of schemas matters: place more specific schemas first to avoid false positives from permissive schemas like `string()`.
- The aggregated issue list can be long for wide unions; callers may want to use `flatten()` to extract meaningful field-level errors.
### Parse Behaviour
`z.union([a, b, c])` tries each member in order and returns the first success; all failures are aggregated into a `ZodUnionError` with per-member diagnostics so callers can show a useful error. For discriminated unions (a shared literal key across members), prefer `z.discriminatedUnion("kind", [...])` which is faster and produces cleaner errors.

View File

@@ -0,0 +1,77 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { boolean } from "../boolean/boolean.js";
import { number } from "../number/number.js";
import { string } from "../string/string.js";
import { union } from "./union.js";
describe("UnionSchema — basic", () => {
const s = union([string(), number()]);
it("accepts string", { timeout: 5000 }, () => {
expect(s.parse("hello")).toBe("hello");
});
it("accepts number", { timeout: 5000 }, () => {
expect(s.parse(42)).toBe(42);
});
it("rejects boolean (no match)", { timeout: 5000 }, () => {
const r = s.safeParse(true);
expect(r.success).toBe(false);
});
it("rejects null (no match)", { timeout: 5000 }, () => {
const r = s.safeParse(null);
expect(r.success).toBe(false);
});
});
describe("UnionSchema — first-match wins", () => {
const s = union([string(), number()]);
it("returns string result when string matches", { timeout: 5000 }, () => {
const r = s.safeParse("hello");
expect(r.success).toBe(true);
if (r.success) expect(typeof r.data).toBe("string");
});
});
describe("UnionSchema — all-fail lists errors", () => {
const s = union([string(), number()]);
it("returns issues from all arms on failure", { timeout: 5000 }, () => {
const r = s.safeParse(false);
expect(r.success).toBe(false);
if (!r.success) {
// Should have at least one issue from each arm
expect(r.error.issues.length).toBeGreaterThanOrEqual(2);
}
});
});
describe("UnionSchema — empty union", () => {
const s = union([]);
it("always fails with NO_MATCH", { timeout: 5000 }, () => {
const r = s.safeParse("anything");
expect(r.success).toBe(false);
if (!r.success)
expect(r.error.issues[0]?.code).toBe("VALIDATION.UNION.NO_MATCH");
});
});
describe("UnionSchema — three arms", () => {
const s = union([string(), number(), boolean()]);
it("accepts string", { timeout: 5000 }, () => {
expect(s.parse("x")).toBe("x");
});
it("accepts boolean", { timeout: 5000 }, () => {
expect(s.parse(true)).toBe(true);
});
it("rejects null", { timeout: 5000 }, () => {
expect(s.safeParse(null).success).toBe(false);
});
});

View File

@@ -0,0 +1,56 @@
import { addIssue, BaseSchema, INVALID } from "../../core/schema/schema.js";
import type {
AnySchema,
ParseContext,
ValidationIssue,
} from "../../types/common.js";
// ---------------------------------------------------------------------------
// UnionSchema
// ---------------------------------------------------------------------------
export class UnionSchema extends BaseSchema<unknown, unknown> {
private readonly _schemas: AnySchema[];
constructor(schemas: AnySchema[]) {
super();
this._schemas = schemas;
}
_parse(input: unknown, ctx: ParseContext): unknown | INVALID {
const allIssues: ValidationIssue[] = [];
for (const schema of this._schemas) {
const childCtx: ParseContext = {
path: [...ctx.path],
issues: [],
};
const result = schema._parse(input, childCtx);
if (result !== INVALID && childCtx.issues.length === 0) {
return result;
}
allIssues.push(...childCtx.issues);
}
// All arms failed — report aggregated issues
if (allIssues.length === 0) {
return addIssue(ctx, {
code: "VALIDATION.UNION.NO_MATCH",
path: [...ctx.path],
message: "Input did not match any union member",
input,
});
}
ctx.issues.push(...allIssues);
return INVALID;
}
}
// ---------------------------------------------------------------------------
// Factory
// ---------------------------------------------------------------------------
export function union(schemas: AnySchema[]): UnionSchema {
return new UnionSchema(schemas);
}

View File

@@ -0,0 +1,83 @@
# Common Types
## Purpose
`common.ts` defines the core type contracts shared across the entire `@suaveplan/validation` package: validation codes, issue structures, parse result types, context types, schema type helpers, refinement/transform function types, and option bundles. By centralising these types, all schema implementations and consumers can import from a single well-known location without circular dependencies.
## Features
- `ValidationCode` — exhaustive union of all 21 validation error codes
- `ValidationIssue` — structured issue with code, path, message, and optional input
- `ParseSuccess<T>` / `ParseFailure` / `ParseResult<T>` — discriminated result union
- `ParseContext` — mutable context object threaded through the parse tree
- `FlattenedErrors` — flat form-friendly error structure
- Schema type helpers: `AnySchema`, `Infer<S>`, `InferInput<S>`, `SchemaInput<S>`, `SchemaOutput<S>`
- Refinement types: `RefineFn<T>`, `AsyncRefineFn<T>`, `TransformFn<T, U>`, `CustomRule<T>`
- Option bundles: `StringRuleOptions`, `NumberRuleOptions`, `ObjectShape`
## Usage: Basic Example
```typescript
import type { Infer, ParseResult } from "@suaveplan/validation";
import { string } from "@suaveplan/validation";
const NameSchema = string().min(1);
type Name = Infer<typeof NameSchema>; // string
function validateName(input: unknown): ParseResult<Name> {
return NameSchema.safeParse(input);
}
```
## Usage: Advanced Example
```typescript
import type { AnySchema, SchemaOutput, ValidationIssue } from "@suaveplan/validation";
function extractErrors(schema: AnySchema, input: unknown): ValidationIssue[] {
const result = schema.safeParse(input);
if (!result.success) return result.error.issues;
return [];
}
```
## API Reference
### `ValidationCode`
Union of 21 string literals covering all possible validation failures. Used as the `code` field in `ValidationIssue` and as the generic parameter to `ValidationError`.
### `ValidationIssue`
```typescript
interface ValidationIssue {
code: ValidationCode;
path: (string | number)[];
message: string;
input?: unknown;
}
```
### `ParseResult<T>`
Discriminated union: `ParseSuccess<T> | ParseFailure`. Test `result.success` to narrow.
### `ParseContext`
```typescript
interface ParseContext {
path: (string | number)[];
issues: ValidationIssue[];
}
```
Mutable; passed by reference through the schema tree. Schemas push issues and return `INVALID`.
### `Infer<S extends AnySchema>`
Extracts the output type of a schema: `Infer<typeof string()>` → `string`.
## Implementation Notes
- These are type-only declarations; the file emits no runtime code beyond the type imports.
- `ObjectShape` is a simple `Record<string, AnySchema>` alias used by `ObjectSchema` and `RecordSchema` factory signatures.

View File

@@ -0,0 +1,100 @@
import type { BaseSchema } from "../core/schema/schema.js";
import type { ValidationError } from "../errors/errors.js";
// ---------------------------------------------------------------------------
// Validation codes
// ---------------------------------------------------------------------------
export type ValidationCode =
| "VALIDATION.STRING.TOO_SHORT"
| "VALIDATION.STRING.TOO_LONG"
| "VALIDATION.STRING.INVALID_FORMAT"
| "VALIDATION.NUMBER.TOO_SMALL"
| "VALIDATION.NUMBER.TOO_LARGE"
| "VALIDATION.NUMBER.NOT_INT"
| "VALIDATION.NUMBER.NOT_FINITE"
| "VALIDATION.NUMBER.NOT_MULTIPLE"
| "VALIDATION.ARRAY.TOO_SHORT"
| "VALIDATION.ARRAY.TOO_LONG"
| "VALIDATION.OBJECT.UNRECOGNIZED_KEYS"
| "VALIDATION.OBJECT.MISSING_KEY"
| "VALIDATION.DATE.TOO_EARLY"
| "VALIDATION.DATE.TOO_LATE"
| "VALIDATION.DATE.INVALID"
| "VALIDATION.UNION.NO_MATCH"
| "VALIDATION.LITERAL.MISMATCH"
| "VALIDATION.ENUM.INVALID_VALUE"
| "VALIDATION.CUSTOM.REFINEMENT"
| "VALIDATION.REQUIRED"
| "VALIDATION.INVALID_TYPE"
| "VALIDATION.ASYNC_REFINEMENT_REQUIRES_PARSEASYNC";
// ---------------------------------------------------------------------------
// Core data structures
// ---------------------------------------------------------------------------
export interface ValidationIssue {
code: ValidationCode;
path: (string | number)[];
message: string;
input?: unknown;
}
export interface ParseSuccess<T> {
success: true;
data: T;
}
export interface ParseFailure {
success: false;
error: ValidationError;
}
export type ParseResult<T> = ParseSuccess<T> | ParseFailure;
export interface ParseContext {
path: (string | number)[];
issues: ValidationIssue[];
}
export interface FlattenedErrors {
formErrors: string[];
fieldErrors: Record<string, string[]>;
}
// ---------------------------------------------------------------------------
// Schema type helpers
// ---------------------------------------------------------------------------
export type AnySchema = BaseSchema<unknown, unknown>;
export type SchemaInput<S extends AnySchema> =
S extends BaseSchema<infer I, unknown> ? I : never;
export type SchemaOutput<S extends AnySchema> =
S extends BaseSchema<unknown, infer O> ? O : never;
export type Infer<S extends AnySchema> = SchemaOutput<S>;
export type InferInput<S extends AnySchema> = SchemaInput<S>;
// ---------------------------------------------------------------------------
// Refinement / transform types
// ---------------------------------------------------------------------------
export type RefineFn<T> = (value: T) => boolean;
export type AsyncRefineFn<T> = (value: T) => boolean | Promise<boolean>;
export type TransformFn<T, U> = (value: T) => U;
export type CustomRule<T> = { validate: AsyncRefineFn<T>; message: string };
// ---------------------------------------------------------------------------
// Option bundles
// ---------------------------------------------------------------------------
export interface StringRuleOptions {
min?: number;
max?: number;
}
export interface NumberRuleOptions {
min?: number;
max?: number;
}
export type ObjectShape = Record<string, AnySchema>;

View File

@@ -0,0 +1,26 @@
export type {
AnySchema,
AsyncRefineFn,
CustomRule,
FlattenedErrors,
Infer,
InferInput,
NumberRuleOptions,
ObjectShape,
ParseContext,
ParseFailure,
ParseResult,
ParseSuccess,
RefineFn,
SchemaInput,
SchemaOutput,
StringRuleOptions,
TransformFn,
ValidationCode,
ValidationIssue,
} from "./common";
export type {
ValidationHookData,
ValidationHookMap,
ValidationValidatingHandler,
} from "./validation-hooks";

View File

@@ -0,0 +1,100 @@
# Validation Hooks
## Purpose
`validation-hooks.ts` defines the lifecycle hook types for schema validation observability. These hooks allow consumers to intercept the validation lifecycle at three points — before validation begins, after it completes successfully, and when it fails — enabling logging, metrics, and auditing without modifying schema logic.
## Features
- `ValidationHookData<T>` — payload carrying input, optional output, and optional error
- `ValidationValidatingHandler<T>` — async-compatible hook handler type
- `ValidationHookMap<T>` — named map of three lifecycle hooks
## Usage: Basic Example
```typescript
import type { ValidationHookMap } from "@suaveplan/validation";
const hooks: ValidationHookMap<string> = {
beforeValidate: ({ input }) => console.log("validating", input),
afterValidate: ({ output }) => console.log("valid", output),
onError: ({ error }) => console.error("invalid", error?.message),
};
```
## Usage: Advanced Example
```typescript
import type { ValidationHookMap } from "@suaveplan/validation";
import { recordParseSuccess, recordParseFailure } from "@suaveplan/validation";
function makeOtelHooks<T>(schemaType: string): ValidationHookMap<T> {
let start = 0;
return {
beforeValidate: () => { start = performance.now(); },
afterValidate: () => recordParseSuccess({ schemaType }, performance.now() - start),
onError: () => recordParseFailure({ schemaType }, performance.now() - start),
};
}
```
## API Reference
### `ValidationHookData<T>`
```typescript
interface ValidationHookData<T> {
input: unknown;
output?: T;
error?: ValidationError;
}
```
### `ValidationValidatingHandler<T>`
`(data: ValidationHookData<T>) => void | Promise<void>`
### `ValidationHookMap<T>`
```typescript
interface ValidationHookMap<T> {
beforeValidate?: ValidationValidatingHandler<T>;
afterValidate?: ValidationValidatingHandler<T>;
onError?: ValidationValidatingHandler<T>;
}
```
## Implementation Notes
- These types are interface definitions only; wiring hooks into schema instances is left to the consumer. They are provided as a contract for instrumentation libraries built on top of `@suaveplan/validation`.
- All handlers are optional in `ValidationHookMap<T>`, so partial hook sets are valid.
### Hook Points
`ValidationHooks` defines the lifecycle the validation runtime emits events for: `onParseStart`, `onParseSuccess`, `onParseFailure`, `onRefinementStart`, `onRefinementFailure`. Hooks are wired through `@suaveplan/events` so consumers can plug an `EventBus` in without the validation package taking a hard dep on any specific bus implementation. Hook callbacks are side-effect only; their return values are ignored.
## Further Usage: Basic Example
```ts
import type { ValidationHooks } from "@suaveplan/validation/types/validation-hooks";
const hooks: ValidationHooks = { onParseFailure: (err) => console.warn(err.code) };
```
## Further Usage: Advanced Example
```ts
import type { ValidationHooks } from "@suaveplan/validation/types/validation-hooks";
import { EventBus } from "@suaveplan/events";
const bus = new EventBus<{ "validation.failed": { code: string } }>();
const hooks: ValidationHooks = {
onParseFailure: (err) => bus.emit("validation.failed", { code: err.code }),
};
```
## Further API Reference
`ValidationHooks` — record of optional lifecycle callbacks.
## Further Implementation Notes
Hooks fire synchronously in the parse hot path so implementations must return quickly; offload work to a bus listener for anything expensive.

View File

@@ -0,0 +1,21 @@
import type { ValidationError } from "../errors/errors.js";
// ---------------------------------------------------------------------------
// Validation lifecycle hooks
// ---------------------------------------------------------------------------
export interface ValidationHookData<T> {
input: unknown;
output?: T;
error?: ValidationError;
}
export type ValidationValidatingHandler<T> = (
data: ValidationHookData<T>
) => void | Promise<void>;
export interface ValidationHookMap<T> {
beforeValidate?: ValidationValidatingHandler<T>;
afterValidate?: ValidationValidatingHandler<T>;
onError?: ValidationValidatingHandler<T>;
}

View File

@@ -0,0 +1,74 @@
# Coerce Utilities
## Purpose
The coerce utilities allow schemas to accept a broader range of input types by converting them before validation. They bridge the gap between raw HTTP parameters (always strings), form data, and strongly-typed schema expectations. Each coerce function is pure and never throws — failures return `null`. The higher-order `coerce(schema)` wrapper applies the appropriate coercion strategy for string inputs and falls back to the raw schema if no coercion path works.
## Features
- `coerceString(input)` — always succeeds via `String(input)`
- `coerceNumber(input)` — via `Number(input)`, returns `null` for NaN
- `coerceBoolean(input)` — handles `"true"/"false"/"1"/"0"/1/0/true/false`
- `coerceDate(input)` — converts ISO strings and timestamps to `Date`
- `coerce(schema)` — wraps any schema with automatic string coercion
## Usage: Basic Example
```typescript
import { coerce, number } from "@suaveplan/validation";
const ageSchema = coerce(number().int().min(0));
ageSchema.parse("25"); // 25 (string coerced to number)
ageSchema.parse(25); // 25 (passthrough)
```
## Usage: Advanced Example
```typescript
import { coerce, boolean, date, object } from "@suaveplan/validation";
// Query parameter parsing (all values arrive as strings)
const QueryParams = object({
active: coerce(boolean()),
since: coerce(date()),
limit: coerce(number().int().max(100)),
});
QueryParams.parse({
active: "true",
since: "2024-01-01",
limit: "50",
});
```
## API Reference
### `coerceString(input: unknown): string`
Calls `String(input)`. Always succeeds; never returns null.
### `coerceNumber(input: unknown): number | null`
Calls `Number(input)`. Returns `null` if the result is `NaN`.
### `coerceBoolean(input: unknown): boolean | null`
Recognises `true/false` (booleans passthrough), `"true"/"1"/1` → `true`, `"false"/"0"/0` → `false`. Returns `null` for unrecognised input.
### `coerceDate(input: unknown): Date | null`
Passes through existing `Date` instances. Converts strings and numbers via `new Date(input)`. Returns `null` if the result is an invalid date.
### `coerce<T>(schema: BaseSchema<unknown, T>): CoercedSchema<T>`
Returns a `CoerceWrapperSchema` that attempts string coercions in order (number → boolean → date) for string inputs, falling back to the raw schema if none succeed.
### `type CoercedSchema<T>`
Alias for `BaseSchema<unknown, T>`.
## Implementation Notes
- `coerce(schema)` clears `ctx.issues` between coercion attempts so that intermediate failures don't pollute the final error list.
- Coercion order for string inputs is: numeric → boolean → date → raw. The first successful conversion is used.
- For non-string inputs, `coerce(schema)` delegates directly to the inner schema with no transformation.

View File

@@ -0,0 +1,183 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { boolean } from "../schemas/boolean/boolean.js";
import { date } from "../schemas/date/date.js";
import { number } from "../schemas/number/number.js";
import { string } from "../schemas/string/string.js";
import {
coerce,
coerceBoolean,
coerceDate,
coerceNumber,
coerceString,
} from "./coerce.js";
describe("coerceString", () => {
it("converts number to string", { timeout: 5000 }, () => {
expect(coerceString(42)).toBe("42");
});
it("converts boolean", { timeout: 5000 }, () => {
expect(coerceString(true)).toBe("true");
});
it("converts null", { timeout: 5000 }, () => {
expect(coerceString(null)).toBe("null");
});
it("passes through string", { timeout: 5000 }, () => {
expect(coerceString("hello")).toBe("hello");
});
it("returns null for symbol input (non-coercible per VL-09)", {
timeout: 5000,
}, () => {
expect(coerceString(Symbol("x"))).toBeNull();
});
});
describe("coerceNumber", () => {
it("converts numeric string", { timeout: 5000 }, () => {
expect(coerceNumber("42")).toBe(42);
});
it("converts float string", { timeout: 5000 }, () => {
expect(coerceNumber("3.14")).toBe(3.14);
});
it("returns null for non-numeric string", { timeout: 5000 }, () => {
expect(coerceNumber("abc")).toBeNull();
});
it("passes through number", { timeout: 5000 }, () => {
expect(coerceNumber(42)).toBe(42);
});
it("returns null for NaN-producing input", { timeout: 5000 }, () => {
expect(coerceNumber("NaN")).toBeNull();
});
});
describe("coerceBoolean", () => {
it("converts 'true' string", { timeout: 5000 }, () => {
expect(coerceBoolean("true")).toBe(true);
});
it("converts '1' string", { timeout: 5000 }, () => {
expect(coerceBoolean("1")).toBe(true);
});
it("converts 1 number", { timeout: 5000 }, () => {
expect(coerceBoolean(1)).toBe(true);
});
it("converts 'false' string", { timeout: 5000 }, () => {
expect(coerceBoolean("false")).toBe(false);
});
it("converts '0' string", { timeout: 5000 }, () => {
expect(coerceBoolean("0")).toBe(false);
});
it("converts 0 number", { timeout: 5000 }, () => {
expect(coerceBoolean(0)).toBe(false);
});
it("passes through boolean", { timeout: 5000 }, () => {
expect(coerceBoolean(true)).toBe(true);
expect(coerceBoolean(false)).toBe(false);
});
it("returns null for unrecognised", { timeout: 5000 }, () => {
expect(coerceBoolean("yes")).toBeNull();
expect(coerceBoolean({})).toBeNull();
});
});
describe("coerceDate", () => {
it("converts ISO string to Date", { timeout: 5000 }, () => {
const d = coerceDate("2024-01-01");
expect(d).toBeInstanceOf(Date);
});
it("converts timestamp to Date", { timeout: 5000 }, () => {
const d = coerceDate(0);
expect(d).toBeInstanceOf(Date);
});
it("passes through Date", { timeout: 5000 }, () => {
const dt = new Date();
expect(coerceDate(dt)).toBe(dt);
});
it("returns null for invalid string", { timeout: 5000 }, () => {
expect(coerceDate("not-a-date")).toBeNull();
});
it("returns null for objects", { timeout: 5000 }, () => {
expect(coerceDate({})).toBeNull();
});
});
describe("coerce(schema)", () => {
it("coerces string to number", { timeout: 5000 }, () => {
const s = coerce(number());
const r = s.safeParse("42");
expect(r.success).toBe(true);
if (r.success) expect(r.data).toBe(42);
});
it("coerces string '1' to boolean", { timeout: 5000 }, () => {
const s = coerce(boolean());
const r = s.safeParse("1");
expect(r.success).toBe(true);
if (r.success) expect(r.data).toBe(true);
});
it("coerces ISO string to Date", { timeout: 5000 }, () => {
const s = coerce(date());
const r = s.safeParse("2024-01-01");
expect(r.success).toBe(true);
if (r.success) expect(r.data).toBeInstanceOf(Date);
});
it("passes through valid string to string schema", {
timeout: 5000,
}, () => {
const s = coerce(string());
const r = s.safeParse("hello");
expect(r.success).toBe(true);
});
it("fails when coercion cannot help", { timeout: 5000 }, () => {
const s = coerce(number());
const r = s.safeParse("not-a-number");
expect(r.success).toBe(false);
});
it("clears issues after failed boolean coercion and tries date (branch 84)", {
timeout: 5000,
}, () => {
// "true" coerces to boolean true; date() rejects boolean → clears issues; tries raw "true" against date schema → fails
const s = coerce(date());
const r = s.safeParse("true");
// "true" as Date is invalid
expect(r.success).toBe(false);
});
it("clears issues after failed date coercion path (branch 91)", {
timeout: 5000,
}, () => {
// "2024-01-01" is not a number, not a boolean keyword, but IS a valid date string
// coerceDate succeeds → date object → boolean() rejects Date → issues cleared → raw parse of "2024-01-01" against boolean() → fail
const s = coerce(boolean());
const r = s.safeParse("2024-01-01");
expect(r.success).toBe(false);
});
it("passes through non-string input directly", { timeout: 5000 }, () => {
const s = coerce(number());
const r = s.safeParse(42);
expect(r.success).toBe(true);
if (r.success) expect(r.data).toBe(42);
});
});

View File

@@ -0,0 +1,107 @@
import { addIssue, BaseSchema, INVALID } from "../core/schema/schema.js";
import type { ParseContext } from "../types/common.js";
// ---------------------------------------------------------------------------
// Individual coerce functions
// ---------------------------------------------------------------------------
/**
* Coerce input to a string. Returns `null` for inputs that have no safe,
* lossless string representation (`symbol`) so the wrapper can produce a
* `ParseFailure` rather than throwing or yielding the misleading literal
* `"Symbol(x)"` token. All other inputs (including `null`/`undefined`/
* objects) are coerced via `String()`.
*/
export function coerceString(input: unknown): string | null {
if (typeof input === "symbol") return null;
return String(input);
}
/**
* Coerce any input to a number using `Number()`. Fails if the result is NaN.
*/
export function coerceNumber(input: unknown): number | null {
const n = Number(input);
return Number.isNaN(n) ? null : n;
}
/**
* Coerce common truthy/falsy representations to boolean.
* Returns null on unrecognised input.
*/
export function coerceBoolean(input: unknown): boolean | null {
if (typeof input === "boolean") return input;
if (input === "true" || input === "1" || input === 1) return true;
if (input === "false" || input === "0" || input === 0) return false;
return null;
}
/**
* Coerce string or number to Date. Returns null if invalid.
*/
export function coerceDate(input: unknown): Date | null {
if (input instanceof Date) return input;
if (typeof input === "string" || typeof input === "number") {
const d = new Date(input);
return Number.isNaN(d.getTime()) ? null : d;
}
return null;
}
// ---------------------------------------------------------------------------
// CoercedSchema wrapper
// ---------------------------------------------------------------------------
export type CoercedSchema<T> = BaseSchema<unknown, T>;
/**
* Wrap a schema with input coercion: string inputs are coerced with the
* appropriate strategy before being handed to the inner schema.
*/
export function coerce<T>(schema: BaseSchema<unknown, T>): CoercedSchema<T> {
return new CoerceWrapperSchema(schema);
}
class CoerceWrapperSchema<T> extends BaseSchema<unknown, T> {
private readonly _inner: BaseSchema<unknown, T>;
constructor(inner: BaseSchema<unknown, T>) {
super();
this._inner = inner;
}
_parse(input: unknown, ctx: ParseContext): T | INVALID {
// Try to coerce string inputs
if (typeof input === "string") {
// Attempt number coercion
const asNum = coerceNumber(input);
if (asNum !== null) {
const r = this._inner._parse(asNum, ctx);
if (r !== INVALID) return r;
// Clear the issue and try original
ctx.issues.length = 0;
}
// Attempt boolean coercion
const asBool = coerceBoolean(input);
if (asBool !== null) {
const r = this._inner._parse(asBool, ctx);
if (r !== INVALID) return r;
ctx.issues.length = 0;
}
// Attempt date coercion
const asDate = coerceDate(input);
if (asDate !== null) {
const r = this._inner._parse(asDate, ctx);
if (r !== INVALID) return r;
ctx.issues.length = 0;
}
}
return this._inner._parse(input, ctx);
}
}
// Unused import guard
type _INVALID = INVALID;
const _addIssue = addIssue;
void _addIssue;

View File

@@ -0,0 +1,51 @@
# Compose Utilities
## Purpose
The compose utilities provide functional combinators for working with validation results and schemas. `mergeResults` aggregates multiple `ParseResult<T>` values into a single `ParseResult<T[]>`, collecting all issues from failures. `pipe` composes a schema with one or more transform functions into a new pipeline schema, enabling clean left-to-right data transformation chains without manual wrapping.
## Features
- `mergeResults<T>(results)` — aggregate array of results, collecting all failures
- `pipe<T, U>(schema, ...transforms)` — compose schema with sequential transforms
## Usage: Basic Example
```typescript
import { pipe, string } from "@suaveplan/validation";
const trimmedLength = pipe(
string(),
(v: unknown) => (v as string).trim(),
(v: unknown) => (v as string).length,
);
trimmedLength.parse(" hello "); // 5
```
## Usage: Advanced Example
```typescript
import { mergeResults, number } from "@suaveplan/validation";
const n = number().min(0);
const results = [1, 2, -3, 4].map((v) => n.safeParse(v));
const merged = mergeResults(results);
// merged.success === false, merged.error.issues has one issue for -3
```
## API Reference
### `mergeResults<T>(results: ParseResult<T>[]): ParseResult<T[]>`
If all results are successes, returns `{ success: true, data: T[] }`.
If any result is a failure, returns `{ success: false, error: ValidationError }` with all issues aggregated from all failures.
### `pipe<T, U>(schema: BaseSchema<unknown, T>, ...transforms: TransformFn<unknown, unknown>[]): BaseSchema<unknown, U>`
Chains `schema` with each transform in order using `TransformSchema`. Each transform receives the previous stage's output as `unknown` (type-cast by the caller). Returns a new schema producing type `U`.
## Implementation Notes
- `mergeResults` with an empty array returns `{ success: true, data: [] }`.
- `pipe` with no transforms returns the original schema (zero iterations of the loop).
- Transform functions in `pipe` accept `unknown` to avoid TypeScript variance issues across the chain; callers must cast appropriately.

View File

@@ -0,0 +1,75 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { ValidationError } from "../errors/errors.js";
import { number } from "../schemas/number/number.js";
import { string } from "../schemas/string/string.js";
import { mergeResults, pipe } from "./compose.js";
describe("mergeResults", () => {
it("merges all successes", { timeout: 5000 }, () => {
const r = mergeResults([
{ success: true as const, data: 1 },
{ success: true as const, data: 2 },
]);
expect(r.success).toBe(true);
if (r.success) expect(r.data).toEqual([1, 2]);
});
it("merges failures", { timeout: 5000 }, () => {
const err1 = new ValidationError([
{ code: "VALIDATION.REQUIRED", path: ["a"], message: "a required" },
]);
const err2 = new ValidationError([
{ code: "VALIDATION.REQUIRED", path: ["b"], message: "b required" },
]);
const r = mergeResults([
{ success: false as const, error: err1 },
{ success: false as const, error: err2 },
]);
expect(r.success).toBe(false);
if (!r.success) expect(r.error.issues).toHaveLength(2);
});
it("fails if any result is failure", { timeout: 5000 }, () => {
const err = new ValidationError([
{ code: "VALIDATION.REQUIRED", path: [], message: "required" },
]);
const r = mergeResults([
{ success: true as const, data: 1 },
{ success: false as const, error: err },
]);
expect(r.success).toBe(false);
});
it("handles empty array", { timeout: 5000 }, () => {
const r = mergeResults([]);
expect(r.success).toBe(true);
if (r.success) expect(r.data).toEqual([]);
});
});
describe("pipe", () => {
it("applies single transform", { timeout: 5000 }, () => {
const s = pipe<string, number>(
string(),
(v: unknown) => (v as string).length
);
expect(s.parse("hello")).toBe(5);
});
it("applies multiple transforms in order", { timeout: 5000 }, () => {
const s = pipe<number, string>(
number(),
(v: unknown) => (v as number) * 2,
(v: unknown) => String(v as number)
);
expect(s.parse(5)).toBe("10");
});
it("propagates parse failure", { timeout: 5000 }, () => {
const s = pipe<string, number>(
string(),
(v: unknown) => (v as string).length
);
expect(() => s.parse(42)).toThrow(ValidationError);
});
});

View File

@@ -0,0 +1,50 @@
import { type BaseSchema, TransformSchema } from "../core/schema/schema.js";
import { ValidationError } from "../errors/errors.js";
import type { ParseResult, TransformFn } from "../types/common.js";
// ---------------------------------------------------------------------------
// mergeResults
// ---------------------------------------------------------------------------
/**
* Merge an array of ParseResults into a single ParseResult wrapping an array.
* If any result is a failure, all failure errors are aggregated.
*/
export function mergeResults<T>(results: ParseResult<T>[]): ParseResult<T[]> {
const data: T[] = [];
const issues: import("../types/common.js").ValidationIssue[] = [];
for (const r of results) {
if (r.success) {
data.push(r.data);
} else {
issues.push(...r.error.issues);
}
}
if (issues.length > 0) {
return { success: false, error: new ValidationError(issues) };
}
return { success: true, data };
}
// ---------------------------------------------------------------------------
// pipe
// ---------------------------------------------------------------------------
/**
* Compose a schema with one or more transform functions into a new schema.
* Each transform is applied in left-to-right order after the inner schema parses.
*/
export function pipe<T, U>(
schema: BaseSchema<unknown, T>,
...transforms: TransformFn<unknown, unknown>[]
): BaseSchema<unknown, U> {
// biome-ignore lint/suspicious/noExplicitAny: type-erased chaining across transform stages
let current: BaseSchema<unknown, any> = schema;
for (const fn of transforms) {
current = new TransformSchema(current, fn);
}
return current as BaseSchema<unknown, U>;
}

View File

@@ -0,0 +1,9 @@
export {
type CoercedSchema,
coerce,
coerceBoolean,
coerceDate,
coerceNumber,
coerceString,
} from "./coerce";
export { mergeResults, pipe } from "./compose";

View File

@@ -0,0 +1,18 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import pkg from "../package.json";
describe("dep-graph", () => {
it("runtime @suaveplan/* deps are exactly the expected set", {
timeout: 5000,
}, () => {
const deps = Object.keys(pkg.dependencies ?? {})
.filter((d) => d.startsWith("@suaveplan/"))
.sort();
expect(deps).toEqual([
"@suaveplan/error",
"@suaveplan/events",
"@suaveplan/types",
]);
});
});

View File

@@ -0,0 +1,52 @@
import { readdir, readFile } from "node:fs/promises";
import { join } from "node:path";
import { describe, expect, it } from "@suaveplan/testing/runner";
const SRC_DIR = join(import.meta.dirname, "../src");
const DISALLOWED = [
"node:fs",
"node:path",
"process.env",
"Buffer",
"document",
"window",
];
async function collectTsFiles(dir: string): Promise<string[]> {
const entries = await readdir(dir, { withFileTypes: true });
const files: string[] = [];
for (const e of entries) {
if (e.name === "__benchmarks__") continue;
if (e.isDirectory()) {
files.push(...(await collectTsFiles(join(dir, e.name))));
} else if (
e.name.endsWith(".ts") &&
!e.name.endsWith(".test.ts") &&
!e.name.endsWith(".bench.ts")
) {
files.push(join(dir, e.name));
}
}
return files;
}
describe("no-disallowed", () => {
it("source files must not use disallowed APIs", {
timeout: 5000,
}, async () => {
const files = await collectTsFiles(SRC_DIR);
const violations: string[] = [];
for (const file of files) {
const content = await readFile(file, "utf-8");
for (const banned of DISALLOWED) {
if (content.includes(banned)) {
violations.push(`${file}: contains "${banned}"`);
}
}
}
expect(violations).toEqual([]);
});
});

View File

@@ -0,0 +1 @@
export {};

View File

@@ -0,0 +1,8 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import pkg from "../package.json";
describe("tree-shake", () => {
it("sideEffects is false", { timeout: 5000 }, () => {
expect(pkg.sideEffects).toBe(false);
});
});

View File

@@ -0,0 +1,12 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "../../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": ".",
"outDir": "./dist",
"lib": ["ES2022"],
"types": ["node"]
},
"include": ["src/**/*.ts", "tests/**/*.ts"],
"exclude": ["src/**/*.test.ts", "src/**/*.bench.ts", "dist", "node_modules"]
}

View File

@@ -0,0 +1,12 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "../../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "./src",
"outDir": "./dist",
"lib": ["ES2022"],
"types": ["node"]
},
"include": ["src/**/*.ts"],
"exclude": ["src/**/*.test.ts", "src/**/*.bench.ts", "dist", "node_modules"]
}

View File

@@ -0,0 +1,37 @@
import { defineConfig } from "vite";
import dts from "vite-plugin-dts";
export default defineConfig({
root: __dirname,
cacheDir: "../../../node_modules/.vite/packages/validation",
plugins: [
dts({
entryRoot: "src",
tsconfigPath: "./tsconfig.lib.json",
insertTypesEntry: true,
}),
],
build: {
outDir: "./dist",
emptyOutDir: true,
reportCompressedSize: true,
sourcemap: true,
lib: { entry: "src/index.ts", formats: ["es"] },
rollupOptions: {
treeshake: true,
output: {
preserveModules: true,
preserveModulesRoot: "src",
entryFileNames: "[name].js",
},
external: [
"@suaveplan/error",
"@suaveplan/types",
"@suaveplan/events",
"tslib",
"@opentelemetry/api",
/^node:/,
],
},
},
});

View File

@@ -0,0 +1,36 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
globals: false,
environment: "node",
pool: "forks",
setupFiles: ["./tests/setup/polyfills.ts"],
include: ["src/**/*.test.ts", "tests/**/*.test.ts"],
exclude: [
"src/__benchmarks__/**",
"dist/**",
"node_modules/**",
],
coverage: {
provider: "istanbul",
reporter: ["text", "html", "lcov"],
include: ["src/**/*.ts"],
exclude: [
"src/**/*.test.ts",
"src/**/*.bench.ts",
"src/**/*.types.ts",
"src/**/index.ts",
"src/__benchmarks__/**",
"tests/**",
],
thresholds: {
statements: 100,
branches: 100,
functions: 100,
lines: 100,
},
},
testTimeout: 5000,
},
});