API Overview
| Symbol | Purpose | Execution mode | Common gotcha |
|---|---|---|---|
s | Creates schemas | Sync or async, depending on checks | checkAsync() requires async parsing |
createParseContext() | Creates request-local messages | Sync | Pass the context to each parse call |
joinIssuePath() | Flattens issue paths to UI keys | Sync | Empty or undefined paths join to '' |
SpellValidationError | Provides validation issues | Sync/async parse failures | Use safeParse() for expected invalid data |
Package Entry Point
| Import | Purpose |
|---|---|
@vielzeug/spell | Schema builders, errors, types, and parse contexts |
@vielzeug/spell/json | Convert portable definitions to JSON Schema |
@vielzeug/spell/predicates | Standalone format and type predicates |
import { createParseContext, s, type Infer } from '@vielzeug/spell';
import { fromDefinition } from '@vielzeug/spell/json';
import { isEmail } from '@vielzeug/spell/predicates';s
All builders live under s.
| Builder | Purpose |
|---|---|
string, number, boolean, bigint, date | Primitive values |
literal, enum, null, undefined, unknown, never | Exact and universal values |
array, tuple, set, map, record, object | Collections |
union, intersect, discriminatedUnion, lazy | Composition |
coerce.* | Coercing primitive schemas |
const User = s.object({
email: s.string().email(),
id: s.string().uuid(),
role: s.enum(['admin', 'member'] as const),
});
type User = Infer<typeof User>;Object schemas reject unknown keys. Use .relaxed() to retain extras.
Standard Schema interoperability
Every schema implements the dependency-free Standard Schema v1 structural protocol through schema['~standard']. Consumers that accept Standard Schema validators can use Spell schemas directly without an adapter.
const User = s.object({ name: s.string() });
const result = await User['~standard'].validate({ name: 'Ada' });Successful results contain value; failures contain portable { message, path } issues. Failed unions remain one stable union issue instead of selecting a branch heuristically.
The exported StandardSchemaV1 interface mirrors the full v1 protocol, including validate(value, options?) and the InferInput and InferOutput namespace helpers.
Parsing
Every schema provides:
schema.parse(value, context?); // Output or SpellValidationError
schema.safeParse(value, context?); // ParseResult<Output>
schema.parseAsync(value, context?); // Promise<Output>
schema.safeParseAsync(value, context?); // Promise<ParseResult<Output>>
schema.is(value); // value is Output
schema.assert(value, label?); // assertionparse(), safeParse(), is(), and assert() run synchronous parsing. When a schema — or any schema nested inside it — carries an async check added with checkAsync(), the synchronous methods throw at runtime with "cannot evaluate async checks"; use parseAsync() or safeParseAsync() instead. Async checks compose at any nesting depth, so s.object({ tags: s.array(s.string().checkAsync(fn)) }) parses correctly through parseAsync().
UI adapters that accept AnySchema cannot call safeParse() on it — the structural surface type omits it. Type adapter parameters against SyncParsable instead:
import type { SyncParsable } from '@vielzeug/spell';
function spellValidator(schema: SyncParsable<unknown>) {
const result = schema.safeParse(value); // types cleanly, no shim or cast
}The shape is structural, so a schema with async checks is assignable to SyncParsable; calling safeParse() on one returns a failed result ("parse() cannot evaluate async checks"). When async checks may be present, use safeParseAsync() instead.
Custom Checks
check() is synchronous. checkAsync() is asynchronous. Do not return a Promise from check().
const Signup = s.object({ confirm: s.string(), password: s.string() }).check((value, context) => {
if (value.password !== value.confirm) {
context.addIssue({ code: 'custom', message: 'Passwords must match', path: ['confirm'] });
}
});
const AvailableEmail = s
.string()
.email()
.checkAsync(async (value) => {
return (await emailAvailable(value)) || 'Email is already registered';
});CheckContext.addIssue() takes { code, message, params?, path? }. Paths are relative to current schema.
Modifiers and Transforms
s.string().optional();
s.string().nullable();
s.string().nullish();
s.string().required();
s.string().default('guest');
s.string().catch('guest');
s.string()
.trim()
.transform((value) => value.toLowerCase());
s.string().pipe(s.string().slug());
s.string().label('User name');default(), catch(), preprocessors, transforms, and checks are runtime behavior. They cannot become portable definitions.
tolerate()
Wraps an object schema so records saved by older schema versions still parse: the named keys are accepted and stripped instead of failing invalid_keys. The parsed output never contains the tolerated keys.
const Person = tolerate(s.object({ name: s.string() }), 'address');The implementation is a preprocessor on the wrapped schema: non-object input reaches the wrapped schema untouched, declared fields keep their validation, and unknown keys outside the tolerated set still fail.
Definitions and JSON Schema
definition() is only for schemas containing declarative structure. It returns frozen data and throws SpellDefinitionError when runtime behavior is present.
import { s } from '@vielzeug/spell';
import { fromDefinition } from '@vielzeug/spell/json';
const Product = s.object({
id: s.string().uuid(),
name: s.string().min(1),
});
const definition = Product.definition();
const jsonSchema = fromDefinition(definition);No implicit schema-to-JSON conversion exists. Make definition boundary explicit.
Parse Contexts
createParseContext() creates immutable request-local validation messages.
function createParseContext(messages?: DeepPartial<Messages>): ParseContext;Returns: A parse context with built-in messages merged with the supplied overrides.
import { createParseContext, s } from '@vielzeug/spell';
const context = createParseContext({
object: { invalidKeys: () => 'Unsupported field' },
});
const result = s.object({ email: s.string().email() }).safeParse({ email: 'ada@example.com', extra: true }, context);
if (!result.success) {
const messages = result.error.messagesAt('email');
console.log(messages);
}Types
Inference types
| Type | Resolves to |
|---|---|
Infer<TSchema> | Parsed output; alias of InferOutput<TSchema> |
InferInput<TSchema> | Input accepted before parsing or transformation |
InferOutput<TSchema> | Value returned after parsing and transformation |
Composite schemas preserve child input types. Object input types make fields optional when their field schema accepts undefined.
Standard Schema
interface StandardSchemaV1<Input = unknown, Output = Input> {
readonly '~standard': StandardSchemaV1.Props<Input, Output>;
}
namespace StandardSchemaV1 {
interface Props<Input = unknown, Output = Input> {
readonly version: 1;
readonly vendor: string;
readonly validate: (value: unknown, options?: Options) => Result<Output> | Promise<Result<Output>>;
readonly types?: Types<Input, Output>;
}
type Result<Output> = { readonly value: Output; readonly issues?: undefined } | { readonly issues: readonly Issue[] };
interface Options { readonly libraryOptions?: Record<string, unknown> }
interface Issue { readonly message: string; readonly path?: readonly (PropertyKey | PathSegment)[] }
interface PathSegment { readonly key: PropertyKey }
interface Types<Input = unknown, Output = Input> { readonly input: Input; readonly output: Output }
type InferInput<Schema extends StandardSchemaV1> = NonNullable<Schema['~standard']['types']>['input'];
type InferOutput<Schema extends StandardSchemaV1> = NonNullable<Schema['~standard']['types']>['output'];
}Parse result and issues
type ParseResult<T> = { data: T; success: true } | { error: SpellValidationError; success: false };
type Issue =
| { code: 'custom'; message: string; params?: Record<string, unknown>; path: (string | number)[] }
| { code: 'invalid_base64'; message: string; params: { format: string }; path: (string | number)[] }
| { code: 'invalid_date'; message: string; params?: undefined; path: (string | number)[] }
| { code: 'invalid_duration'; message: string; params: { format: string }; path: (string | number)[] }
| { code: 'invalid_enum'; message: string; params: { values: readonly unknown[] }; path: (string | number)[] }
| { code: 'invalid_finite'; message: string; params?: undefined; path: (string | number)[] }
| { code: 'invalid_integer'; message: string; params?: undefined; path: (string | number)[] }
| { code: 'invalid_keys'; message: string; params: { keys: string[] }; path: (string | number)[] }
| { code: 'invalid_length'; message: string; params: { exact: number }; path: (string | number)[] }
| { code: 'invalid_literal'; message: string; params: { expected: unknown }; path: (string | number)[] }
| { code: 'invalid_multiple_of'; message: string; params: { step: number | bigint }; path: (string | number)[] }
| { code: 'invalid_safe'; message: string; params?: undefined; path: (string | number)[] }
| {
code: 'invalid_string';
message: string;
params: { format?: string; includes?: string; pattern?: string; prefix?: string; suffix?: string };
path: (string | number)[];
}
| { code: 'invalid_type'; message: string; params?: undefined; path: (string | number)[] }
| { code: 'invalid_union'; message: string; params: { errors: Issue[][] }; path: (string | number)[] }
| { code: 'invalid_unique'; message: string; params: { unique: true }; path: (string | number)[] }
| { code: 'invalid_url'; message: string; params: { format: string }; path: (string | number)[] }
| {
code: 'invalid_variant';
message: string;
params: { discriminator: string; expected: string[] };
path: (string | number)[];
}
| {
code: 'too_big';
message: string;
params: { exclusive?: boolean; max: number | bigint | Date };
path: (string | number)[];
}
| {
code: 'too_small';
message: string;
params: { exclusive?: boolean; min: number | bigint | Date };
path: (string | number)[];
}
| { code: string & {}; message: string; params?: Record<string, unknown>; path: (string | number)[] };ErrorCode is a const object mapping each issue code to its string literal.
Validation contracts
type ParseContext = { messages: Messages };
type CheckContext = {
addIssue: (issue: {
code: string;
message: string;
params?: Record<string, unknown>;
path?: (string | number)[];
}) => void;
};
type ValidateResult = boolean | null | undefined | string;Messages
type MessageFn<Ctx extends Record<string, unknown> = Record<string, unknown>> = string | ((ctx: Ctx) => string);
type Messages = {
array: { length: (ctx: { exact: number; value: unknown[] }) => string; max: (ctx: { max: number; value: unknown[] }) => string; min: (ctx: { min: number; value: unknown[] }) => string; nonEmpty: () => string; type: () => string; unique: () => string };
bigint: { max: (ctx: { max: bigint; value: bigint }) => string; min: (ctx: { min: bigint; value: bigint }) => string; multipleOf: (ctx: { step: bigint; value: bigint }) => string; negative: () => string; nonNegative: () => string; nonPositive: () => string; positive: () => string; type: () => string };
boolean: { type: () => string };
check: { default: () => string };
date: { max: (ctx: { max: Date; value: Date }) => string; min: (ctx: { min: Date; value: Date }) => string; type: () => string };
enum: { invalid: (ctx: { values: readonly unknown[] }) => string };
instanceof: { type: (ctx: { className: string }) => string };
literal: { expected: (ctx: { expected: unknown }) => string };
map: { max: (ctx: { max: number; value: Map<unknown, unknown> }) => string; min: (ctx: { min: number; value: Map<unknown, unknown> }) => string; nonEmpty: () => string; size: (ctx: { exact: number; value: Map<unknown, unknown> }) => string; type: () => string };
never: { invalid: () => string };
number: { finite: () => string; int: () => string; max: (ctx: { max: number; value: number }) => string; min: (ctx: { min: number; value: number }) => string; multipleOf: (ctx: { step: number; value: number }) => string; negative: () => string; nonNegative: () => string; nonPositive: () => string; positive: () => string; safe: () => string; type: () => string };
object: { invalidKeys: (ctx: { keys: string[] }) => string; type: () => string };
set: { max: (ctx: { max: number; value: Set<unknown> }) => string; min: (ctx: { min: number; value: Set<unknown> }) => string; nonEmpty: () => string; size: (ctx: { exact: number; value: Set<unknown> }) => string; type: () => string };
string: { base64: () => string; base64url: () => string; cuid: () => string; cuid2: () => string; date: () => string; dateTime: () => string; duration: () => string; email: () => string; emoji: () => string; endsWith: (ctx: { suffix: string; value: string }) => string; hex: () => string; hexColor: () => string; includes: (ctx: { substr: string; value: string }) => string; ip: () => string; jwt: () => string; length: (ctx: { exact: number; value: string }) => string; max: (ctx: { max: number; value: string }) => string; min: (ctx: { min: number; value: string }) => string; nanoid: () => string; nonEmpty: () => string; numeric: () => string; regex: (ctx: { value: string }) => string; semver: () => string; slug: () => string; startsWith: (ctx: { prefix: string; value: string }) => string; time: () => string; type: () => string; ulid: () => string; url: () => string; uuid: () => string };
tuple: { length: (ctx: { exact: number }) => string; min: (ctx: { min: number }) => string; type: () => string };
union: { invalid: () => string };
variant: { invalidDiscriminator: (ctx: { discriminator: string; expected: string[] }) => string; type: () => string };
};
type DeepPartial<T> = {
[K in keyof T]?: T[K] extends Record<string, unknown> ? DeepPartial<T[K]> : T[K];
};Descriptor and JSON Schema
type SchemaDescriptor = BaseDescriptor &
(
| { kind: 'unknown' | 'never' | 'boolean' | 'bigint' | 'date' | 'lazy' }
| { className: string; kind: 'instanceof' }
| { contentEncoding?: string; format?: string; kind: 'string'; maxLength?: number; minLength?: number; pattern?: string | null }
| { exclusiveMaximum?: number; exclusiveMinimum?: number; kind: 'number'; maximum?: number; minimum?: number; multipleOf?: number; typeHint?: 'integer' }
| { kind: 'literal'; value: string | number | boolean | null | undefined }
| { kind: 'enum'; values: readonly (string | number)[] }
| { items: SchemaDescriptor; kind: 'array'; maxItems?: number; minItems?: number }
| { items: SchemaDescriptor[]; kind: 'tuple'; rest: SchemaDescriptor | null }
| { fields: Record<string, SchemaDescriptor>; kind: 'object'; strict: boolean }
| { key: SchemaDescriptor; kind: 'record'; value: SchemaDescriptor }
| { items: SchemaDescriptor; kind: 'set' }
| { key: SchemaDescriptor; kind: 'map'; value: SchemaDescriptor }
| { branches: SchemaDescriptor[]; kind: 'union' | 'intersect' }
| { branches: Record<string, SchemaDescriptor>; discriminator: string; kind: 'variant' }
| { from: SchemaDescriptor; kind: 'pipe'; to: SchemaDescriptor }
);
type JsonSchema = Record<string, unknown>;Error helpers
type FlatError = { messages: string[]; path: (string | number)[] };
type FlatErrorFirst = { message: string; path: (string | number)[] };
/** An issue path in either shape: spell-native segments or Standard Schema `{ key }` segments. */
type IssuePathSegment = PropertyKey | { readonly key: PropertyKey };
type IssuePath = readonly IssuePathSegment[];
/** The synchronous parse surface; `AnySchema` omits `safeParse`, adapter parameters use this. */
type SyncParsable<T = unknown> = { safeParse(value: unknown): ParseResult<T> };
function joinIssuePath(path: IssuePath | undefined, separator?: string): string;joinIssuePath() flattens an issue path into a UI key such as 'permissions.0.slug'. It accepts spell-native (string | number)[] paths and Standard Schema paths with { key } segments, and returns '' for empty or undefined paths.
Errors
SpellError— base class. Useinstanceof SpellErrorfor cross-boundary narrowing.SpellValidationError— validation failure withissues,messagesAt(),flatten(), andflattenFirst().SpellDefinitionError— schema cannot create portable definition.
const result = s.object({ email: s.string().email() }).safeParse({ email: 'invalid' });
if (!result.success) {
const { fieldErrors, formErrors } = result.error.flatten();
console.log(fieldErrors, formErrors);
}