import type { StandardSchemaV1 } from '@standard-schema/spec' /** * A validation issue returned by a schema, compatible with Standard Schema v1. * * Issues include a human-readable `message` and an optional `path` that points to the * failing location in the input (e.g. `['user', 'email']` or `[0, 'id']`). */ export type Issue = StandardSchemaV1.Issue /** * The result of schema validation. * * On success, `value` is present and `issues` is absent. On failure, `issues` is present. */ export type ValidationResult = StandardSchemaV1.Result /** * Options passed to `~standard.validate`. */ export type ValidationOptions = StandardSchemaV1.Options /** * Context passed to `errorMap` to customize issue messages. */ export type ErrorMapContext = { code: string defaultMessage: string path?: Issue['path'] values?: Record input: unknown locale?: string } /** * Function used to customize issue messages. * * Return `undefined` to use the default message. */ export type ErrorMap = (context: ErrorMapContext) => string | undefined /** * Options passed to {@link parse} and {@link parseSafe}. * * This mirrors {@link ValidationOptions}, but also supports a convenience `abortEarly` * option at the top level. */ export type ParseOptions = StandardSchemaV1.Options & { abortEarly?: boolean errorMap?: ErrorMap locale?: string } type SyncStandardSchemaProps = Omit< StandardSchemaV1.Props, 'validate' > & { // data-schema is sync-first; keep validate sync. validate: (value: unknown, options?: ValidationOptions) => ValidationResult // Preserve Standard Schema's compile-time type channel. types?: StandardSchemaV1.Types | undefined } type SyncStandardSchema = { '~standard': SyncStandardSchemaProps } /** * A reusable check for use with `schema.pipe(...)`. */ export type Check = { check: (value: output) => boolean message?: string code?: string values?: Record } /** * A sync, Standard Schema v1-compatible schema with a small chainable API. */ export type Schema = SyncStandardSchema & { /** * Compose one or more reusable checks onto this schema. * * Checks run after the underlying schema has validated and produced an `output` value. * If any check fails, validation fails with an issue (using the check's `message` if provided). * * @param checks One or more `Check`s to apply in order * @returns A new schema with the checks applied */ pipe: (...checks: Check[]) => Schema /** * Add an inline predicate check onto this schema. * * The predicate runs after the underlying schema has validated and produced an `output` value. * If the predicate returns `false`, validation fails with an issue. * * @param predicate A function that returns `true` for valid values * @param message Optional issue message when the predicate fails * @returns A new schema with the refinement applied */ refine: (predicate: (value: output) => boolean, message?: string) => Schema /** * Transform the output of this schema with a function. * * The transform function runs after the underlying schema has validated and produced an `output` value. * The returned schema has a different output type. * * @param transformer A function that transforms the validated output * @returns A new schema with the transformation applied */ transform: (transformer: (value: output) => newOutput) => Schema /** * Internal validator used to validate nested values while preserving `path`/`options`. */ '~run': (value: unknown, context: ValidationContext) => ValidationResult } /** * Infers the input type of a schema-like value. */ export type InferInput = schema extends StandardSchemaV1 ? input : never /** * Infers the output type of a schema-like value. */ export type InferOutput = schema extends StandardSchemaV1 ? output : never type ValidationContext = { path: NonNullable options?: ParseOptions } type InternalValidationContext = ValidationContext & { mutablePath?: boolean } const builtinSchemas = new WeakSet() type IssueDescriptor = { code: string defaultMessage: string input: unknown path?: Issue['path'] values?: Record } /** * Creates a sync Standard Schema-compatible schema from a validation function. * * @param validator Validator that returns either a parsed value or validation issues. * @returns A chainable schema object. */ export function createSchema( validator: (value: unknown, context: ValidationContext) => ValidationResult, ): Schema { let schema: Schema = { '~standard': { version: 1, vendor: 'data-schema', validate(value: unknown, options?: ValidationOptions) { let context: InternalValidationContext = { path: [], options, mutablePath: isBuiltinSchema(schema), } return validator(value, context) }, }, '~run'(value: unknown, context: InternalValidationContext) { if (context.mutablePath) { return validator(value, { path: [...context.path], options: context.options }) } return validator(value, context) }, pipe(...checks: Check[]) { if (checks.length === 0) { return schema } return createDerivedSchema(schema, function validate(value, context) { let result = schema['~run'](value, context) if (result.issues) { return result } for (let check of checks) { if (!check.check(result.value)) { if (!check.code) { return { issues: [createIssue(check.message ?? 'Check failed', [...context.path])] } } return { issues: [ createIssueFromContext(context, { code: check.code, defaultMessage: check.message ?? 'Check failed', input: result.value, values: check.values, }), ], } } } return result }) }, refine(predicate: (value: output) => boolean, message?: string) { return createDerivedSchema(schema, function validate(value, context) { let result = schema['~run'](value, context) if (result.issues) { return result } if (!predicate(result.value)) { if (message !== undefined) { return { issues: [createIssue(message, [...context.path])] } } return { issues: [ createIssueFromContext(context, { code: 'refine.failed', defaultMessage: 'Refinement failed', input: result.value, }), ], } } return result }) }, transform(transformer: (value: output) => newOutput): Schema { return createDerivedSchema(schema, function validate(value, context) { let result = schema['~run'](value, context) if (result.issues) { return result } return { value: transformer(result.value) } }) }, } return schema } function isBuiltinSchema(schema: Schema): boolean { return builtinSchemas.has(schema) } function createBuiltinSchema( validator: (value: unknown, context: InternalValidationContext) => ValidationResult, ): Schema { let schema = createSchema(validator) // Built-in validators keep the shared path internal and copy it when creating issues. schema['~run'] = validator builtinSchemas.add(schema) return schema } function createDerivedSchema( source: Schema, validator: (value: unknown, context: InternalValidationContext) => ValidationResult, ): Schema { return isBuiltinSchema(source) ? createBuiltinSchema(validator) : createSchema(validator) } function runAtPath( schema: Schema, value: unknown, context: InternalValidationContext, key: PropertyKey, ): ValidationResult { if (context.mutablePath !== true || !Array.isArray(context.path)) { let childContext: InternalValidationContext = { path: withPath(context.path, key), options: context.options, mutablePath: false, } return schema['~run'](value, childContext) } context.path.push(key) try { return schema['~run'](value, context) } finally { context.path.pop() } } function shouldAbortEarly(options?: ParseOptions): boolean { let libraryAbortEarly = (options?.libraryOptions as { abortEarly?: unknown } | undefined) ?.abortEarly let abortEarly = options?.abortEarly ?? libraryAbortEarly return Boolean(abortEarly) } function withPath(path: NonNullable, key: PropertyKey): NonNullable { return path.length === 0 ? [key] : [...path, key] } function getErrorMap(options?: ParseOptions): ErrorMap | undefined { let libraryErrorMap = (options?.libraryOptions as { errorMap?: unknown } | undefined)?.errorMap if (typeof options?.errorMap === 'function') { return options.errorMap } if (typeof libraryErrorMap === 'function') { return libraryErrorMap as ErrorMap } } function getLocale(options?: ParseOptions): string | undefined { let libraryLocale = (options?.libraryOptions as { locale?: unknown } | undefined)?.locale if (typeof options?.locale === 'string') { return options.locale } if (typeof libraryLocale === 'string') { return libraryLocale } } function resolveIssueMessage(options: ParseOptions | undefined, context: ErrorMapContext): string { let errorMap = getErrorMap(options) if (!errorMap) { return context.defaultMessage } let message = errorMap(context) return message ?? context.defaultMessage } function createIssueFromContext(context: ValidationContext, descriptor: IssueDescriptor): Issue { let path = [...(descriptor.path ?? context.path)] let message = resolveIssueMessage(context.options, { code: descriptor.code, defaultMessage: descriptor.defaultMessage, path, values: descriptor.values, input: descriptor.input, locale: getLocale(context.options), }) return createIssue(message, path) } /** * Creates a Standard Schema issue object. * * @param message Human-readable validation message. * @param path Optional issue path within the input value. * @returns A Standard Schema issue. */ export function createIssue(message: string, path: Issue['path']): Issue { return !path || path.length === 0 ? { message } : { message, path } } /** * Creates a Standard Schema failure result with a single issue. * * @param message Human-readable validation message. * @param path Optional issue path within the input value. * @param options Optional issue metadata used for localized error mapping. * @param options.code Optional error code passed to the error map. * @param options.values Optional values passed to the error map. * @param options.input Optional input value passed to the error map. * @param options.parseOptions Optional parse options used for localization and error mapping. * @returns A failure result containing one issue. */ export function fail( message: string, path: Issue['path'], options?: { code?: string values?: Record input?: unknown parseOptions?: ParseOptions }, ): StandardSchemaV1.FailureResult { if (!options?.code) { return { issues: [createIssue(message, path)] } } path = path && [...path] let resolvedMessage = resolveIssueMessage(options.parseOptions, { code: options.code, defaultMessage: message, path, values: options.values, input: options.input, locale: getLocale(options.parseOptions), }) return { issues: [createIssue(resolvedMessage, path)] } } /** * Create a schema that accepts any value without validation. * * @returns A schema that produces `unknown` */ export function any(): Schema { return createBuiltinSchema(function validate(value) { return { value } }) } /** * Create a schema that validates an array by validating each element with `elementSchema`. * * @param elementSchema The schema to validate each element * @returns A schema that produces an array of validated outputs */ export function array( elementSchema: Schema, ): Schema { return createBuiltinSchema(function validate(value, context) { if (!Array.isArray(value)) { return fail('Expected array', context.path, { code: 'type.array', input: value, parseOptions: context.options, }) } let abortEarly = shouldAbortEarly(context.options) let issues: Issue[] = [] let outputValues: output[] = new Array(value.length) let index = 0 for (let item of value) { let result = runAtPath(elementSchema, item, context, index) if (result.issues) { if (abortEarly) { return result } issues.push(...result.issues) } else { outputValues[index] = result.value } index += 1 } if (issues.length > 0) { return { issues } } outputValues.length = index return { value: outputValues } }) } /** * Create a schema that accepts bigints. * * @returns A schema that produces a `bigint` */ export function bigint(): Schema { return createBuiltinSchema(function validate(value, context) { if (typeof value !== 'bigint') { return fail('Expected bigint', context.path, { code: 'type.bigint', input: value, parseOptions: context.options, }) } return { value } }) } /** * Create a schema that accepts booleans. * * @returns A schema that produces a `boolean` */ export function boolean(): Schema { return createBuiltinSchema(function validate(value, context) { if (typeof value !== 'boolean') { return fail('Expected boolean', context.path, { code: 'type.boolean', input: value, parseOptions: context.options, }) } return { value } }) } /** * Provide a default when the input is `undefined`. * * @param schema The wrapped schema * @param defaultValue A value or function used to produce the default * @returns A schema that produces the default when the input is `undefined` */ export function defaulted( schema: Schema, defaultValue: output | (() => output), ): Schema { return createBuiltinSchema(function validate(value, context) { if (value === undefined) { let resolved = typeof defaultValue === 'function' ? (defaultValue as () => output)() : defaultValue return { value: resolved } } return schema['~run'](value, context) }) } /** * Create a schema that accepts one of the given values using strict equality (`===`). * * @param values The allowed values * @returns A schema that produces the union of allowed value types */ export function enum_( values: values, ): Schema { return createBuiltinSchema(function validate(value, context) { for (let allowed of values) { if (value === allowed) { return { value: value as values[number] } } } return fail('Expected one of: ' + values.map(String).join(', '), context.path, { code: 'enum.invalid_value', input: value, values: { values: [...values] }, parseOptions: context.options, }) }) } /** * Create a schema that validates a value is an instance of a class. * * @param constructor The class constructor to check against * @returns A schema that produces the instance type */ export function instanceof_ any>( constructor: constructor, ): Schema> { return createBuiltinSchema(function validate(value, context) { if (!(value instanceof constructor)) { return fail('Expected instance of ' + constructor.name, context.path, { code: 'instanceof.invalid_type', input: value, values: { constructorName: constructor.name }, parseOptions: context.options, }) } return { value: value as InstanceType } }) } /** * Create a schema that accepts a single literal value using strict equality (`===`). * * @param literalValue The literal value to match * @returns A schema that produces the literal type */ export function literal(literalValue: value): Schema { return createBuiltinSchema(function validate(value, context) { if (value !== literalValue) { return fail('Expected literal value', context.path, { code: 'literal.invalid_value', input: value, values: { expected: literalValue }, parseOptions: context.options, }) } return { value: literalValue } }) } /** * Create a schema that validates a Map with typed keys and values. * * @param keySchema Schema for Map keys * @param valueSchema Schema for Map values * @returns A schema that produces a `Map` */ export function map( keySchema: Schema, valueSchema: Schema, ): Schema> { return createBuiltinSchema(function validate(value, context) { if (!(value instanceof Map)) { return fail('Expected Map', context.path, { code: 'type.map', input: value, parseOptions: context.options, }) } let abortEarly = shouldAbortEarly(context.options) let issues: Issue[] = [] let outputMap = new Map() for (let [key, val] of value) { let keyResult = runAtPath(keySchema, key, context, key) if (keyResult.issues) { if (abortEarly) { return keyResult } issues.push(...keyResult.issues) continue } let valueResult = runAtPath(valueSchema, val, context, key) if (valueResult.issues) { if (abortEarly) { return valueResult } issues.push(...valueResult.issues) continue } outputMap.set(keyResult.value, valueResult.value) } if (issues.length > 0) { return { issues } } return { value: outputMap } }) } /** * Create a schema that accepts `null`. * * @returns A schema that produces `null` */ export function null_(): Schema { return createBuiltinSchema(function validate(value, context) { if (value !== null) { return fail('Expected null', context.path, { code: 'type.null', input: value, parseOptions: context.options, }) } return { value: null } }) } /** * Allow `null` as an input value, short-circuiting validation when `null` is provided. * * @param schema The wrapped schema * @returns A schema that accepts `null` in addition to the wrapped schema */ export function nullable( schema: Schema, ): Schema { return createBuiltinSchema(function validate(value, context) { if (value === null) { return { value: null } } return schema['~run'](value, context) }) } /** * Create a schema that accepts finite numbers (excluding `NaN` and `Infinity`). * * @returns A schema that produces a `number` */ export function number(): Schema { return createBuiltinSchema(function validate(value, context) { if (typeof value !== 'number' || !Number.isFinite(value)) { return fail('Expected number', context.path, { code: 'type.number', input: value, parseOptions: context.options, }) } return { value } }) } type ObjectShape = Record> type ObjectOptions = { unknownKeys?: 'strip' | 'passthrough' | 'error' } /** * Create a schema that validates an object with a fixed shape. * * By default, unknown keys are stripped. You can change this via `options.unknownKeys`. * * @param shape A mapping of keys to schemas * @param options Controls unknown key behavior * @returns A schema that produces a typed object matching the shape */ export function object( shape: shape, options?: ObjectOptions, ): Schema }> { return createBuiltinSchema(function validate(value, context) { if (typeof value !== 'object' || value === null || Array.isArray(value)) { return fail('Expected object', context.path, { code: 'type.object', input: value, parseOptions: context.options, }) } let abortEarly = shouldAbortEarly(context.options) let issues: Issue[] = [] let outputValues: Record = {} let input = value as Record let unknownKeys = options?.unknownKeys ?? 'strip' for (let key of Object.keys(shape)) { let result = runAtPath(shape[key], input[key], context, key) if (result.issues) { if (abortEarly) { return result } issues.push(...result.issues) } else { if (Object.prototype.hasOwnProperty.call(input, key) || result.value !== undefined) { outputValues[key] = result.value } } } if (unknownKeys === 'passthrough' || unknownKeys === 'error') { for (let key in input) { if (!Object.prototype.hasOwnProperty.call(input, key)) { continue } if (Object.prototype.hasOwnProperty.call(shape, key)) { continue } if (unknownKeys === 'passthrough') { outputValues[key] = input[key] } else { let issue = createIssueFromContext(context, { code: 'object.unknown_key', defaultMessage: 'Unknown key', input: input[key], path: withPath(context.path, key), values: { key }, }) if (abortEarly) { return { issues: [issue] } } issues.push(issue) } } } if (issues.length > 0) { return { issues } } return { value: outputValues as { [key in keyof shape]: InferOutput } } }) } /** * Allow `undefined` as an input value, short-circuiting validation when `undefined` is provided. * * @param schema The wrapped schema * @returns A schema that accepts `undefined` in addition to the wrapped schema */ export function optional( schema: Schema, ): Schema { return createBuiltinSchema( function validate(value, context) { if (value === undefined) { return { value: undefined } } return schema['~run'](value, context) }, ) } /** * Create a schema that validates a record (object map) by validating each key and value. * * @param keySchema Schema used to validate and transform each key * @param valueSchema Schema used to validate and transform each value * @returns A schema that produces a record of validated keys and values */ export function record( keySchema: Schema, valueSchema: Schema, ): Schema> { return createBuiltinSchema(function validate(value, context) { if (typeof value !== 'object' || value === null || Array.isArray(value)) { return fail('Expected object', context.path, { code: 'type.object', input: value, parseOptions: context.options, }) } let abortEarly = shouldAbortEarly(context.options) let issues: Issue[] = [] let outputValues: Record = {} let input = value as Record for (let key in input) { if (!Object.prototype.hasOwnProperty.call(input, key)) { continue } let keyResult = runAtPath(keySchema, key, context, key) if (keyResult.issues) { if (abortEarly) { return keyResult } issues.push(...keyResult.issues) continue } let valueResult = runAtPath(valueSchema, input[key], context, key) if (valueResult.issues) { if (abortEarly) { return valueResult } issues.push(...valueResult.issues) continue } outputValues[keyResult.value] = valueResult.value } if (issues.length > 0) { return { issues } } return { value: outputValues as Record } }) } /** * Create a schema that validates a Set with typed values. * * @param valueSchema Schema for Set values * @returns A schema that produces a `Set` */ export function set( valueSchema: Schema, ): Schema> { return createBuiltinSchema(function validate(value, context) { if (!(value instanceof Set)) { return fail('Expected Set', context.path, { code: 'type.set', input: value, parseOptions: context.options, }) } let abortEarly = shouldAbortEarly(context.options) let issues: Issue[] = [] let outputSet = new Set() let index = 0 for (let item of value) { let result = runAtPath(valueSchema, item, context, index) if (result.issues) { if (abortEarly) { return result } issues.push(...result.issues) } else { outputSet.add(result.value) } index++ } if (issues.length > 0) { return { issues } } return { value: outputSet } }) } /** * Create a schema that accepts strings. * * @returns A schema that produces a `string` */ export function string(): Schema { return createBuiltinSchema(function validate(value, context) { if (typeof value !== 'string') { return fail('Expected string', context.path, { code: 'type.string', input: value, parseOptions: context.options, }) } return { value } }) } /** * Create a schema that accepts symbols. * * @returns A schema that produces a `symbol` */ export function symbol(): Schema { return createBuiltinSchema(function validate(value, context) { if (typeof value !== 'symbol') { return fail('Expected symbol', context.path, { code: 'type.symbol', input: value, parseOptions: context.options, }) } return { value } }) } /** * Create a schema that validates a fixed-length tuple. * * @param items Schemas for each tuple position * @returns A schema that produces a typed tuple */ export function tuple[]>( items: items, ): Schema }> { return createBuiltinSchema(function validate(value, context) { if (!Array.isArray(value)) { return fail('Expected array', context.path, { code: 'type.array', input: value, parseOptions: context.options, }) } let abortEarly = shouldAbortEarly(context.options) let issues: Issue[] = [] let outputValues: unknown[] = [] if (value.length !== items.length) { let issue = createIssueFromContext(context, { code: 'tuple.length', defaultMessage: 'Expected tuple length ' + String(items.length), input: value, values: { length: items.length }, }) if (abortEarly) { return { issues: [issue] } } issues.push(issue) } let index = 0 let max = Math.min(value.length, items.length) while (index < max) { let result = runAtPath(items[index], value[index], context, index) if (result.issues) { if (abortEarly) { return result } issues.push(...result.issues) } else { outputValues[index] = result.value } index += 1 } if (issues.length > 0) { return { issues } } return { value: outputValues as { [index in keyof items]: InferOutput } } }) } /** * Create a schema that accepts `undefined`. * * @returns A schema that produces `undefined` */ export function undefined_(): Schema { return createBuiltinSchema(function validate(value, context) { if (value !== undefined) { return fail('Expected undefined', context.path, { code: 'type.undefined', input: value, parseOptions: context.options, }) } return { value: undefined } }) } /** * Create a discriminated-union schema. * * The returned schema expects an object with a `discriminator` property and selects a variant schema * based on that value. * * @param discriminator The property name used to select a variant * @param variants A mapping from discriminator value to schema * @returns A schema that produces the selected variant output type */ export function variant< key extends PropertyKey, variants extends Record>, >(discriminator: key, variants: variants): Schema> { return createBuiltinSchema(function validate(value, context) { if (typeof value !== 'object' || value === null || Array.isArray(value)) { return fail('Expected object', context.path, { code: 'type.object', input: value, parseOptions: context.options, }) } let input = value as Record let tag = input[discriminator] if (tag === undefined) { return fail('Expected discriminator', [...context.path, discriminator], { code: 'variant.missing_discriminator', input: value, values: { discriminator: String(discriminator) }, parseOptions: context.options, }) } if (typeof tag !== 'string' && typeof tag !== 'number' && typeof tag !== 'symbol') { return fail('Unknown discriminator', [...context.path, discriminator], { code: 'variant.unknown_discriminator', input: tag, values: { discriminator: String(discriminator) }, parseOptions: context.options, }) } if (!Object.prototype.hasOwnProperty.call(variants, tag)) { return fail('Unknown discriminator', [...context.path, discriminator], { code: 'variant.unknown_discriminator', input: tag, values: { discriminator: String(discriminator) }, parseOptions: context.options, }) } let schema = variants[tag as keyof variants] return schema['~run'](value, context) }) } /** * Create a schema that tries multiple schemas in order and returns the first success. * * When `abortEarly` is disabled (default), issues are collected from all failing variants. * * @param schemas Candidate schemas to try * @returns A schema that produces the first successful variant output */ export function union[]>( schemas: schemas, ): Schema> { return createBuiltinSchema(function validate(value, context) { if (schemas.length === 0) { return fail('No union variant matched', context.path, { code: 'union.no_variants', input: value, parseOptions: context.options, }) } let abortEarly = shouldAbortEarly(context.options) let issues: Issue[] = [] for (let schema of schemas) { let result = schema['~run'](value, context) if (result.issues) { if (abortEarly) { return { issues: result.issues } } issues.push(...result.issues) continue } return result } return { issues } }) } /** * Error thrown by {@link parse} when validation fails. */ export class ValidationError extends Error { /** * The validation issues produced by the schema. */ issues: ReadonlyArray /** * @param issues The issues produced by schema validation * @param message Optional error message (defaults to "Validation failed") */ constructor(issues: ReadonlyArray, message = 'Validation failed') { super(message) this.name = 'ValidationError' this.issues = issues } } /** * Validate a value and return the typed output or throw a {@link ValidationError}. * * @param schema The schema to validate against * @param value The value to validate * @param options Validation options * @returns The validated output value * @throws {ValidationError} If validation fails */ export function parse( schema: StandardSchemaV1, value: unknown, options?: ParseOptions, ): output { let result = schema['~standard'].validate(value, options) as ValidationResult if (result.issues) { throw new ValidationError(result.issues) } return result.value } /** * Validate a value without throwing. * * @param schema The schema to validate against * @param value The value to validate * @param options Validation options * @returns A success result with the value, or a failure result with issues */ export function parseSafe( schema: StandardSchemaV1, value: unknown, options?: ParseOptions, ): | { success: true; value: output } | { success: false; issues: ReadonlyArray } { let result = schema['~standard'].validate(value, options) as ValidationResult if (result.issues) { return { success: false, issues: result.issues } } return { success: true, value: result.value } }