diff --git a/PLAN.md b/PLAN.md new file mode 100644 index 0000000..ea7dc64 --- /dev/null +++ b/PLAN.md @@ -0,0 +1,64 @@ +# Plan: Type Inference for Validator Functions + +## Goal + +Implement type inference for the validator functions (`validate`, +`validateAsync`, `parse`, `parseAsync`) and schema builders in the `validation` +namespace, drawing inspiration from Zod. The input type of +`validate(schema, input)` and the return type of `parse(schema, input)` should +be inferred from the schema, including for composed schemas (`array`, `object`, +`combination`, `nullable`). + +## Background + +- Schemas implement `StandardSchemaV1` (a generic interface) but + the builders in `json_schema.ts` did NOT set the `~standard.types` field. As a + result `StandardSchemaV1.InferInput`/`InferOutput` (which read + `~standard.types`) resolved to `unknown` for these schemas. +- `validator.ts` used `StandardSchemaV1.InferInput` / `InferOutput` plus a + union with `unknown`, so callers never got useful input typing and `parse` + returned `unknown`. +- Zod exposes inference via typed schemas + `z.infer`. The Standard Schema + equivalent is the `~standard.types` field plus the generic + `StandardSchemaV1` parameters. + +## Tasks + +- + 1. [x] Add inference helpers in `validation/infer.ts` exposing `InferInput` + and `InferOutput` that read `~standard.types` (set by the schema + builder) and fall back to `unknown`, plus composition helpers + (`InferMemberOutput`, `InferObjectOutput`, `InferCombinationOutput`). +- + 2. [x] Update `validator.ts` (`validate`, `validateAsync`, `parse`, + `parseAsync`) to use the new `InferInput`/`InferOutput` helpers so + input is typed and parsed output carries the schema's output type. Kept + the existing `boolean` schema shortcut and async support behavior. +- + 3. [x] Set `~standard.types` in the `schema()` builder so inference resolves + to the schema's `Input`/`Output` (the generic params alone cannot infer + `Input` because it is structurally absent from `StandardSchemaV1`). +- + 4. [x] Make schema builders in `json_schema.ts` carry proper composed types: + - [x] 4a. `array({ items })` -> `InferOutput[]` (input/output). + - [x] 4b. `object({ properties })` -> object shape with all properties + optional (JSON Schema's `required` is `string[]` and widens, so it + cannot mark keys required at the type level). + - [x] 4c. `combination({ allOf/anyOf/oneOf })` -> union of member output + types. + - [x] 4d. `nullable()` already typed as `null`; left as is. +- + 5. [x] Add type-level tests (`validation/infer.test.ts`) using compile-time + `IsExact`/`IsSubtype` assertions for scalars, `array`, `object`, + `combination`, and `parse`/`validate` signatures. +- + 6. [x] Verified with `tsc` (against the real `@standard-schema/spec` types) + and runtime tests via Node (`--experimental-strip-types`): all type + checks pass and all existing runtime behavior is preserved. + +## Non-goals + +- No new runtime behavior changes beyond setting the `~standard.types` carrier + field (whose runtime values are `undefined`; it is a type-level carrier). +- No changes to JSON Schema output/input converters. +- No changes to other packages. diff --git a/validation/README.md b/validation/README.md index 388871c..17e56be 100644 --- a/validation/README.md +++ b/validation/README.md @@ -150,3 +150,65 @@ const outputSchema = getStandardJSONSchemaV1Output(mySchema, { target: "draft-2020-12", }); ``` + +### Type Inference + +The schema builders and validator functions are fully typed. The input type of +`validate`/`parse` and the return type of `parse` are inferred from the schema, +including for composed schemas (`array`, `object`, `combination`). + +```ts +import { + array, + combination, + InferInput, + InferOutput, + number, + object, + parse, + string, +} from "@stdext/validation"; + +// Scalars infer their own type +const str = string(); +type T = InferOutput; // string +const parsed: string = parse(str, "hello"); + +// Arrays infer the element type +const tags = array({ items: string() }); +type Tags = InferOutput; // string[] +const arr: string[] = parse(tags, ["a", "b"]); + +// prefixItems infers a fixed tuple, and items/unevaluatedItems/contains append +// a variadic tail +const tuple = array({ prefixItems: [string(), number()] }); +type Tuple = InferOutput; // [string, number] +const t: [string, number] = parse(tuple, ["a", 1]); + +const tupleRest = array({ prefixItems: [string()], items: number() }); +type TupleRest = InferOutput; // [string, ...number[]] +const tr: [string, ...number[]] = parse(tupleRest, ["a", 1, 2, 3]); + +// Objects infer their shape from `properties`, `required`, and +// `additionalProperties`. `required` keys become required; the rest are +// optional. `additionalProperties: false` disallows extra keys, a schema/`true` +// allows them (typed `unknown`). +const person = object({ + properties: { name: string(), age: number() }, + required: ["name"], +}); +type Person = InferOutput; // { name: string; age?: number } & { [key: string]: unknown } + +const strict = object({ + properties: { name: string() }, + required: ["name"], + additionalProperties: false, +}); +type Strict = InferOutput; // { name: string } + +// Combinations infer a union of their members +const id = combination({ anyOf: [string(), number()] }); +type Id = InferOutput; // string | number +``` + +`InferInput` works the same way to extract a schema's expected input type. diff --git a/validation/infer.test.ts b/validation/infer.test.ts new file mode 100644 index 0000000..203f593 --- /dev/null +++ b/validation/infer.test.ts @@ -0,0 +1,188 @@ +import { + array, + boolean, + combination, + integer, + nullable, + number, + object, + string, +} from "./json_schema.ts"; +import { type InferInput, type InferOutput, parse, validate } from "./mod.ts"; +import { assert } from "@std/assert"; + +/** + * Compile-time type assertion helper. + * + * Asserts that the type `Actual` is assignable to `Expected` (i.e. `Expected` + * is a supertype of `Actual`). If `Actual` is not assignable to `Expected`, + * `deno check` fails with an error. + */ +type IsSubtype = Actual extends Expected ? true : never; + +/** + * Compile-time type assertion helper. + * + * Asserts that `Actual` and `Expected` are exactly the same type by requiring + * mutual assignability. Use `IsExact` when the types must match precisely. + */ +type IsExact = IsSubtype extends true + ? IsSubtype extends true ? true : never + : never; + +/** Marker const used to force evaluation of a type-level assertion. */ +const ok: true = true; + +Deno.test("type inference: scalar schemas", () => { + const s = string(); + const _a: IsExact, string> = ok; + const _b: IsExact, string> = ok; + + const n = number(); + const _c: IsExact, number> = ok; + + const i = integer(); + const _d: IsExact, number> = ok; + + const b = boolean(); + const _e: IsExact, boolean> = ok; + + const nu = nullable(); + const _f: IsExact, null> = ok; +}); + +Deno.test("type inference: array schema", () => { + const s = array({ items: string() }); + const _a: IsExact, string[]> = ok; + const _b: IsExact, string[]> = ok; + + const n = array({ items: number() }); + const _c: IsExact, number[]> = ok; + + // Array without items falls back to unknown[] + const u = array(); + const _d: IsExact, unknown[]> = ok; + + // prefixItems infers a fixed tuple + const tuple = array({ prefixItems: [string(), number()] }); + const _e: IsExact, [string, number]> = ok; + + // prefixItems + items appends a variadic tail to the tuple + const tupleRest = array({ prefixItems: [string()], items: number() }); + const _f: IsExact, [string, ...number[]]> = ok; + + // prefixItems + unevaluatedItems appends a variadic tail to the tuple + const tupleUnevaluated = array({ + prefixItems: [string()], + unevaluatedItems: number(), + }); + const _g: IsExact< + InferOutput, + [string, ...number[]] + > = ok; + + // prefixItems + contains appends a variadic tail to the tuple + const tupleContains = array({ + prefixItems: [string(), number()], + contains: boolean(), + }); + const _h: IsExact< + InferOutput, + [string, number, ...boolean[]] + > = ok; + + // contains (without prefixItems) infers a uniform array + const contains = array({ contains: number() }); + const _i: IsExact, number[]> = ok; + + // unevaluatedItems (without prefixItems) infers a uniform array + const unevaluated = array({ unevaluatedItems: number() }); + const _j: IsExact, number[]> = ok; +}); + +Deno.test("type inference: object schema", () => { + // required drives required vs optional keys + const s = object({ + properties: { + name: string(), + age: number(), + }, + required: ["name"], + }); + // name is required, age is optional; extras allowed as unknown + const _a: IsSubtype<{ name: string; age?: number }, InferOutput> = + ok; + const _b: IsSubtype< + InferOutput, + { name: string; age?: number; [k: string]: unknown } + > = ok; + + // all required + additionalProperties: false + const all = object({ + properties: { a: string(), b: boolean() }, + required: ["a", "b"], + additionalProperties: false, + }); + const _c: IsSubtype<{ a: string; b: boolean }, InferOutput> = ok; + + // no required -> all optional + const opt = object({ properties: { x: string(), y: number() } }); + const _d: IsSubtype<{ x?: string; y?: number }, InferOutput> = ok; + + // additionalProperties: false disallows extras (strict shape) + const strict = object({ + properties: { name: string() }, + required: ["name"], + additionalProperties: false, + }); + const _e: IsSubtype<{ name: string }, InferOutput> = ok; + + // additionalProperties: allows extras (typed unknown to avoid + // conflicts with declared properties of a different type) + const extras = object({ + properties: { name: string() }, + required: ["name"], + additionalProperties: number(), + }); + const _f: IsSubtype< + { name: string; extra: number }, + InferOutput + > = ok; +}); + +Deno.test("type inference: combination schema", () => { + const s = combination({ anyOf: [string(), number()] }); + const _a: IsExact, string | number> = ok; + + const one = combination({ oneOf: [string(), boolean()] }); + const _b: IsExact, string | boolean> = ok; +}); + +Deno.test("type inference: parse and validate signatures", () => { + const s = string(); + const parsed = parse(s, "hello"); + const _a: IsExact = ok; + + // Array parse infers element type + const arr = array({ items: string() }); + const arrParsed = parse(arr, ["a", "b"]); + const _b: IsExact = ok; + + // Object parse infers shape (required keys are required) + const obj = object({ + properties: { id: number(), label: string() }, + required: ["id", "label"], + additionalProperties: false, + }); + const objParsed = parse(obj, { id: 1, label: "x" }); + const _c: IsExact = ok; + + // validate result carries the output type + const result = validate(s, "hello"); + if (!result.issues) { + const _d: IsExact = ok; + } + + // Sanity: the assertions above are all compile-time; keep deno test happy. + assert(parsed === "hello"); +}); diff --git a/validation/infer.ts b/validation/infer.ts new file mode 100644 index 0000000..cb6fe35 --- /dev/null +++ b/validation/infer.ts @@ -0,0 +1,258 @@ +import type { StandardSchemaV1 } from "@standard-schema/spec"; + +/** + * Extracts the input type of a Standard Schema. + * + * Reads the schema's `~standard.types` field (set by the schema builders in + * `./json_schema.ts`), falling back to the `StandardSchemaV1` + * type parameters, and finally to `unknown`. Schemas built by this package + * always set `~standard.types`, so this resolves to the correct input type for + * them; for foreign schemas that do not set `types` the result is `unknown` + * (matching the spec's own `InferInput`). + * + * @template S - The schema type + * + * @example + * ```typescript + * const s = string(); + * type In = InferInput; // string + * ``` + */ +export type InferInput = + // Prefer the explicitly declared `~standard.types` when available... + S extends { "~standard": { types?: { input: infer I } } } ? I + // ...otherwise derive from the StandardSchemaV1 parameters. + : S extends StandardSchemaV1 ? I + : unknown; + +/** + * Extracts the output type of a Standard Schema. + * + * Reads the schema's `~standard.types` field (set by the schema builders in + * `./json_schema.ts`), falling back to the `StandardSchemaV1` + * type parameters, and finally to `unknown`. Schemas built by this package + * always set `~standard.types`, so this resolves to the correct output type for + * them; for foreign schemas that do not set `types` the result is `unknown` + * (matching the spec's own `InferOutput`). + * + * @template S - The schema type + * + * @example + * ```typescript + * const s = string(); + * type Out = InferOutput; // string + * ``` + */ +export type InferOutput = S extends + { "~standard": { types?: { output: infer O } } } ? O + : S extends StandardSchemaV1 ? O + : unknown; + +/** + * Resolves the output type of a {@link SchemaObject} member of an array or + * object schema, where the member may be a schema or a literal `boolean` + * (JSON Schema's `true`/`false` shorthand). + * + * - A `false` member always fails, contributing `never`. + * - A `true` member accepts anything, contributing `unknown`. + * - A schema member contributes its inferred output type. + * + * @template S - The member schema or boolean + * + * @example + * ```typescript + * type A = InferMemberOutput; + * ``` + */ +export type InferMemberOutput = S extends false ? never + : S extends true ? unknown + : InferOutput; + +/** + * Maps a record of member schemas (e.g. an object's `properties`) to a record + * of their inferred output types. + * + * @template T - The record of member schemas + * + * @example + * ```typescript + * const props = { name: string(), age: number() }; + * type Out = InferMemberOutputRecord; + * // { name: string, age: number } + * ``` + */ +export type InferMemberOutputRecord = { + [K in keyof T]: InferMemberOutput; +}; + +/** + * Resolves the index-signature contribution of an object schema's + * `additionalProperties` option, mirroring JSON Schema 2020-12 semantics: + * + * - `false` disallows extra properties (no index signature). + * - `true` or absent allows any extra property (`unknown`). + * - a schema allows extra properties validated against it (its output type). + * + * @template AdditionalProperties - The `additionalProperties` option value + */ +export type InferObjectAdditionalIndex = + // `false` disallows extra properties by contributing no index signature, so + // the object type consists only of its declared (and required) keys and + // excess-property checks forbid undeclared keys on object literals. Any other + // value (`true`, a schema, or absent) allows extra properties; the index is + // typed `unknown` to avoid unsound conflicts with declared properties of a + // different type (the `additionalProperties` schema still validates extras at + // runtime). + // deno-lint-ignore ban-types + AdditionalProperties extends false ? {} + : { [key: string]: unknown }; + +/** + * Extracts the union of required property keys from a `required` tuple. + * + * @template Required - The readonly `required` string tuple + */ +export type InferObjectRequiredKeys< + Required extends ReadonlyArray, +> = Required[number]; + +/** + * Builds the output type of an `object` schema from its `properties`, + * `required`, and `additionalProperties`. + * + * - Keys listed in `required` are required; the remaining declared keys are + * optional. + * - `additionalProperties` controls extra (undeclared) keys: `false` removes + * the index signature, a schema types the extra values, and `true`/absent + * allows `unknown` extra values. + * + * `required` must be captured as a `const` tuple on the builder (see + * `object()`) so the literal keys are preserved rather than widened to + * `string[]`. + * + * @template Properties - The `properties` record + * @template Required - The readonly `required` string tuple + * @template AdditionalProperties - The `additionalProperties` option value + * + * @example + * ```typescript + * type Out = InferObjectOutput< + * { name: StringSchema; age: NumberSchema }, + * ["name"], + * false + * >; + * // { name: string; age?: number } + * ``` + */ +export type InferObjectOutput< + Properties, + Required extends ReadonlyArray = [], + AdditionalProperties = undefined, +> = + & { + [ + K in keyof Properties as K extends InferObjectRequiredKeys ? K + : never + ]: InferMemberOutput; + } + & { + [ + K in keyof Properties as K extends InferObjectRequiredKeys + ? never + : K + ]?: InferMemberOutput; + } + & InferObjectAdditionalIndex; + +/** + * Maps a readonly tuple of schemas (e.g. an array's `prefixItems`) to a tuple + * of their inferred output types, preserving element order and count. + * + * @template T - The readonly tuple of member schemas + * + * @example + * ```typescript + * type T = InferArrayTuple; + * // readonly [string, number] + * ``` + */ +export type InferArrayTuple> = { + [K in keyof T]: InferMemberOutput; +}; + +/** + * Resolves the variadic "rest" element type of an array schema from its + * `items`, `unevaluatedItems`, or `contains` option (in that order of + * precedence), mirroring JSON Schema 2020-12 evaluation. Returns `never` when + * none of these are present. + * + * @template Options - The array options + */ +export type InferArrayRest = Options extends { items: infer Items } + ? InferMemberOutput + : Options extends { unevaluatedItems: infer Unevaluated } ? InferMemberOutput< + Unevaluated + > + : Options extends { contains: infer Contains } ? InferMemberOutput + : never; + +/** + * Whether an array schema declares any variadic rest element source + * (`items`, `unevaluatedItems`, or `contains`). + * + * @template Options - The array options + */ +export type InferArrayHasRest = Options extends { items: infer _Items } + ? true + : Options extends { unevaluatedItems: infer _Unevaluated } ? true + : Options extends { contains: infer _Contains } ? true + : false; + +/** + * Builds the output type of an `array` schema. + * + * - When `prefixItems` is present, the leading elements form a fixed tuple. + * If a variadic rest source (`items`/`unevaluatedItems`/`contains`) is also + * present, it is appended as a variadic tail; otherwise the tuple is exact. + * - When only a rest source is present, the result is `Rest[]`. + * - Otherwise the result is `unknown[]`. + * + * @template Prefix - The readonly `prefixItems` tuple, or `undefined` + * @template Options - The array options (carrying the rest element sources) + * + * @example + * ```typescript + * type A = InferArrayOutput; + * // [string, number] + * type B = InferArrayOutput; + * // [string, ...number[]] + * ``` + */ +export type InferArrayOutput< + Prefix extends ReadonlyArray | undefined, + Options, +> = Prefix extends ReadonlyArray + ? InferArrayHasRest extends true + ? [...InferArrayTuple, ...InferArrayRest[]] + : [...InferArrayTuple] + : InferArrayHasRest extends true ? InferArrayRest[] + : unknown[]; + +/** + * Infers the output type of a `combination` schema from its `allOf`, `anyOf` + * and `oneOf` members. The resulting type is the union of every member output. + * When no members are present the result is `unknown`. + * + * @template AllOf - The readonly array of `allOf` member schemas + * @template AnyOf - The readonly array of `anyOf` member schemas + * @template OneOf - The readonly array of `oneOf` member schemas + */ +export type InferCombinationOutput< + AllOf extends ReadonlyArray | undefined, + AnyOf extends ReadonlyArray | undefined, + OneOf extends ReadonlyArray | undefined, +> = [ + | (AllOf extends ReadonlyArray ? InferMemberOutput : never) + | (AnyOf extends ReadonlyArray ? InferMemberOutput : never) + | (OneOf extends ReadonlyArray ? InferMemberOutput : never), +][0] extends infer R ? [R] extends [never] ? unknown : R : unknown; diff --git a/validation/json_schema.ts b/validation/json_schema.ts index ed350ee..8d6329b 100644 --- a/validation/json_schema.ts +++ b/validation/json_schema.ts @@ -29,6 +29,11 @@ import { RFC6901_RELATIVE_JSON_POINTER, stringify, } from "./utils.ts"; +import type { + InferArrayOutput, + InferCombinationOutput, + InferObjectOutput, +} from "./infer.ts"; import { validate as _validate } from "./validator.ts"; /** @@ -263,6 +268,13 @@ function schema< "~standard": { version: 1, vendor: "@stdext/validation", + // Expose the inferred types so that `InferInput`/`InferOutput` (and the + // spec's `StandardSchemaV1.InferInput`/`InferOutput`) resolve to the + // schema's `Input`/`Output` rather than `unknown`. + types: { + input: undefined as unknown as Input, + output: undefined as unknown as Output, + }, validate: options.validate, jsonSchema: { input: options.input, @@ -825,10 +837,43 @@ export interface ArrayOptions extends > { } +/** + * The inferred output type for an array schema. + * + * - When `prefixItems` is present, the leading elements form a fixed tuple. + * If a variadic rest source (`items`/`unevaluatedItems`/`contains`) is also + * present, it is appended as a variadic tail; otherwise the tuple is exact. + * - When only a rest source (`items`/`unevaluatedItems`/`contains`) is present, + * the result is `Rest[]`. + * - Otherwise the result is `unknown[]`. + * + * @template Prefix - The readonly `prefixItems` tuple, or `undefined` + * @template Options - The {@link ArrayOptions} carrying the rest element + * sources + */ +export type ArrayElementOutput< + Prefix extends ReadonlyArray | undefined, + Options extends ArrayOptions | undefined, +> = InferArrayOutput; + /** * Creates an array schema that validates array values. - * Supports constraints for items, length, and uniqueness. + * Supports constraints for items, prefix items, contains, length, and + * uniqueness. * + * Type inference: + * - `items` infers a uniform array type (e.g. `array({ items: string() })` -> + * `string[]`). + * - `prefixItems` infers a fixed tuple (e.g. `array({ prefixItems: [string(), + * number()] })` -> `[string, number]`). + * - `prefixItems` combined with `items`, `unevaluatedItems`, or `contains` + * appends a variadic tail to the tuple (e.g. `array({ prefixItems: + * [string()], items: number() })` -> `[string, ...number[]]`). + * - `unevaluatedItems` or `contains` (without `prefixItems`) infers a uniform + * array of that element type. + * + * @template Prefix - The readonly `prefixItems` tuple, or `undefined` + * @template O - The array options, used to infer the element type * @param options - Optional array schema options * @returns A schema object for array validation * @@ -837,11 +882,25 @@ export interface ArrayOptions extends * const stringArraySchema = array({ items: string(), minItems: 1 }); * const result = validate(stringArraySchema, ["hello", "world"]); * // result: { value: ["hello", "world"] } + * const parsed: string[] = parse(stringArraySchema, ["hello", "world"]); + * + * const tupleSchema = array({ prefixItems: [string(), number()] }); + * const tuple: [string, number] = parse(tupleSchema, ["hello", 42]); + * + * const restSchema = array({ prefixItems: [string()], items: number() }); + * const rest: [string, ...number[]] = parse(restSchema, ["hello", 1, 2, 3]); * ``` */ -export function array( - options?: ArrayOptions, -): SchemaObject<"array", unknown[], unknown[]> { +export function array< + const Prefix extends ReadonlyArray | undefined = undefined, + O extends ArrayOptions | undefined = undefined, +>( + options?: O & { prefixItems?: Prefix }, +): SchemaObject< + "array", + ArrayElementOutput, + ArrayElementOutput +> { return schema( { type: "array", ...options }, { @@ -996,7 +1055,9 @@ export function array( } } - return issues.length ? { issues } : { value }; + return issues.length + ? { issues } + : { value: value as ArrayElementOutput }; }, input: (params) => { return { @@ -1062,10 +1123,57 @@ export interface ObjectOptions extends > { } +/** + * The inferred output type for an object schema, derived from its `properties`, + * `required`, and `additionalProperties`. + * + * - Keys listed in `required` are required; the remaining declared keys are + * optional. + * - `additionalProperties` controls extra (undeclared) keys: `false` removes the + * index signature, a schema types the extra values, and `true`/absent allows + * `unknown` extra values. + * + * @template Properties - The `properties` record + * @template Required - The readonly `required` string tuple + * @template AdditionalProperties - The `additionalProperties` option value + */ +export type ObjectElementOutput< + Properties, + Required extends ReadonlyArray, + AdditionalProperties, +> = InferObjectOutput; + +/** + * Resolves the inferred output type of an {@link object} schema from its + * options. Used internally so the `validate` return annotation and the returned + * value share the exact same type (avoiding spurious mismatches between + * conditionals that differ only in `infer P` vs `infer P | undefined`). + * + * @template O - The {@link ObjectOptions} + * @template Required - The readonly `required` string tuple + */ +type ObjectOutputOf< + O extends ObjectOptions | undefined, + Required extends ReadonlyArray | undefined, +> = ObjectElementOutput< + O extends { properties?: infer P } ? P : never, + Required extends ReadonlyArray ? Required : [], + O extends { additionalProperties?: infer AP } ? AP : undefined +>; + /** * Creates an object schema that validates object values. * Supports constraints for properties, patterns, and additional properties. * + * Type inference uses `properties`, `required`, and `additionalProperties`: + * - Keys listed in `required` are required; the remaining declared keys are + * optional. + * - `additionalProperties: false` disallows extra (undeclared) keys; + * `additionalProperties: ` types extra values; `true`/absent allows + * `unknown` extra values. + * + * @template Required - The readonly `required` string tuple, or `undefined` + * @template O - The object options, used to infer the output shape * @param options - Optional object schema options * @returns A schema object for object validation * @@ -1080,15 +1188,33 @@ export interface ObjectOptions extends * }); * const result = validate(personSchema, { name: "Alice", age: 30 }); * // result: { value: { name: "Alice", age: 30 } } + * const parsed: { name: string; age?: number } = parse(personSchema, { name: "Alice" }); + * + * const strict = object({ + * properties: { name: string() }, + * required: ["name"], + * additionalProperties: false, + * }); + * const strictParsed: { name: string } = parse(strict, { name: "Alice" }); * ``` */ -export function object( - options?: ObjectOptions, -): SchemaObject<"object", object, object> { +export function object< + const Required extends ReadonlyArray | undefined = undefined, + O extends ObjectOptions | undefined = undefined, +>( + options?: O & { required?: Required }, +): SchemaObject< + "object", + ObjectOutputOf, + ObjectOutputOf +> { return schema( { type: "object", ...options }, { - validate: (value, _opts) => { + validate: ( + value, + _opts, + ): StandardSchemaV1.Result> => { if (!isObject(value)) { return failureResult(msg.invalidType("object", value)); } @@ -1238,7 +1364,9 @@ export function object( } } - return issues.length ? { issues } : { value }; + return issues.length ? { issues } : { + value: value as ObjectOutputOf, + }; }, input: (params) => { return { @@ -1290,10 +1418,33 @@ export interface CombinationOptions extends > { } +/** + * The inferred output type for a combination schema, derived as the union of + * the inferred output types of its `allOf`, `anyOf` and `oneOf` members. When + * no members are present the result is `unknown`. + * + * @template O - The {@link CombinationOptions} passed to the builder + */ +export type CombinationElementOutput = + O extends { + allOf?: infer A; + anyOf?: infer B; + oneOf?: infer C; + } ? InferCombinationOutput< + A extends ReadonlyArray ? A : [], + B extends ReadonlyArray ? B : [], + C extends ReadonlyArray ? C : [] + > + : unknown; + /** * Creates a combination schema that combines multiple schemas. * Supports allOf, anyOf, oneOf, and not for complex validation logic. * + * When `allOf`, `anyOf` or `oneOf` are provided, the schema's input and output + * types are inferred as the union of the member schemas' output types. + * + * @template O - The combination options, used to infer the output union * @param options - Optional combination schema options * @returns A schema object for combination validation * @@ -1304,15 +1455,25 @@ export interface CombinationOptions extends * }); * const result = validate(combinedSchema, "hello world"); * // result: { value: "hello world" } + * const parsed: string = parse(combinedSchema, "hello world"); * ``` */ -export function combination( - options?: CombinationOptions, -): SchemaObject<"combination", unknown, unknown> { +export function combination< + O extends CombinationOptions | undefined = undefined, +>( + options?: O, +): SchemaObject< + "combination", + CombinationElementOutput, + CombinationElementOutput +> { return schema( { type: "combination", ...options }, { - validate: (value, _opts) => { + validate: ( + value, + _opts, + ): StandardSchemaV1.Result> => { const validateAndCount = ( schemas: StandardSchemaV1 | StandardSchemaV1[], ) => { @@ -1364,7 +1525,7 @@ export function combination( return { issues }; } } - return { value }; + return { value: value as CombinationElementOutput }; }, input: (params) => { return { diff --git a/validation/mod.ts b/validation/mod.ts index 05226ab..67cdb1e 100644 --- a/validation/mod.ts +++ b/validation/mod.ts @@ -1,5 +1,19 @@ export * from "./validator.ts"; export * from "./json_schema.ts"; +export type { + InferArrayHasRest, + InferArrayOutput, + InferArrayRest, + InferArrayTuple, + InferCombinationOutput, + InferInput, + InferMemberOutput, + InferMemberOutputRecord, + InferObjectAdditionalIndex, + InferObjectOutput, + InferObjectRequiredKeys, + InferOutput, +} from "./infer.ts"; export { getStandardJSONSchemaV1Input, getStandardJSONSchemaV1Output, diff --git a/validation/validator.ts b/validation/validator.ts index 4b11f11..d2468a0 100644 --- a/validation/validator.ts +++ b/validation/validator.ts @@ -2,6 +2,13 @@ import type { StandardSchemaV1 } from "@standard-schema/spec"; import { SchemaError } from "@standard-schema/utils"; import { stringify } from "./utils.ts"; +/** + * Re-export of the inference helpers for convenience. These read the schema's + * `~standard.types` field (set by the schema builders in `./json_schema.ts`) + * so that the input/output types of any Standard Schema can be extracted. + */ +export type { InferInput, InferOutput } from "./infer.ts"; + /** * Validates input against a StandardSchema * @@ -25,7 +32,7 @@ import { stringify } from "./utils.ts"; */ export function validateAsync( schema: S | boolean, - input: StandardSchemaV1.InferInput | unknown, + input: unknown, options?: Parameters[1], ): | StandardSchemaV1.Result> @@ -70,7 +77,7 @@ export function validateAsync( */ export function validate( schema: S | boolean, - input: StandardSchemaV1.InferInput | unknown, + input: unknown, options?: Parameters[1], ): StandardSchemaV1.Result> { const result = validateAsync(schema, input, options); @@ -104,7 +111,7 @@ export function validate( */ export async function parseAsync( schema: S | boolean, - input: StandardSchemaV1.InferInput | unknown, + input: unknown, options?: Parameters[1], ): Promise> { let result = validateAsync(schema, input, options); @@ -144,7 +151,7 @@ export async function parseAsync( */ export function parse( schema: S | boolean, - input: StandardSchemaV1.InferInput | unknown, + input: unknown, options?: Parameters[1], ): StandardSchemaV1.InferOutput { const result = validate(schema, input, options);