Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 64 additions & 0 deletions PLAN.md
Original file line number Diff line number Diff line change
@@ -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<Input, Output>` (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<S>` / `InferOutput<S>` 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<Input, Output>` parameters.

## Tasks

-
1. [x] Add inference helpers in `validation/infer.ts` exposing `InferInput<S>`
and `InferOutput<S>` 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<items>[]` (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.
62 changes: 62 additions & 0 deletions validation/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof str>; // string
const parsed: string = parse(str, "hello");

// Arrays infer the element type
const tags = array({ items: string() });
type Tags = InferOutput<typeof tags>; // 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<typeof tuple>; // [string, number]
const t: [string, number] = parse(tuple, ["a", 1]);

const tupleRest = array({ prefixItems: [string()], items: number() });
type TupleRest = InferOutput<typeof tupleRest>; // [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<typeof person>; // { name: string; age?: number } & { [key: string]: unknown }

const strict = object({
properties: { name: string() },
required: ["name"],
additionalProperties: false,
});
type Strict = InferOutput<typeof strict>; // { name: string }

// Combinations infer a union of their members
const id = combination({ anyOf: [string(), number()] });
type Id = InferOutput<typeof id>; // string | number
```

`InferInput` works the same way to extract a schema's expected input type.
188 changes: 188 additions & 0 deletions validation/infer.test.ts
Original file line number Diff line number Diff line change
@@ -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, Expected> = 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<Actual, Expected> = IsSubtype<Actual, Expected> extends true
? IsSubtype<Expected, Actual> 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<InferOutput<typeof s>, string> = ok;
const _b: IsExact<InferInput<typeof s>, string> = ok;

const n = number();
const _c: IsExact<InferOutput<typeof n>, number> = ok;

const i = integer();
const _d: IsExact<InferOutput<typeof i>, number> = ok;

const b = boolean();
const _e: IsExact<InferOutput<typeof b>, boolean> = ok;

const nu = nullable();
const _f: IsExact<InferOutput<typeof nu>, null> = ok;
});

Deno.test("type inference: array schema", () => {
const s = array({ items: string() });
const _a: IsExact<InferOutput<typeof s>, string[]> = ok;
const _b: IsExact<InferInput<typeof s>, string[]> = ok;

const n = array({ items: number() });
const _c: IsExact<InferOutput<typeof n>, number[]> = ok;

// Array without items falls back to unknown[]
const u = array();
const _d: IsExact<InferOutput<typeof u>, unknown[]> = ok;

// prefixItems infers a fixed tuple
const tuple = array({ prefixItems: [string(), number()] });
const _e: IsExact<InferOutput<typeof tuple>, [string, number]> = ok;

// prefixItems + items appends a variadic tail to the tuple
const tupleRest = array({ prefixItems: [string()], items: number() });
const _f: IsExact<InferOutput<typeof tupleRest>, [string, ...number[]]> = ok;

// prefixItems + unevaluatedItems appends a variadic tail to the tuple
const tupleUnevaluated = array({
prefixItems: [string()],
unevaluatedItems: number(),
});
const _g: IsExact<
InferOutput<typeof tupleUnevaluated>,
[string, ...number[]]
> = ok;

// prefixItems + contains appends a variadic tail to the tuple
const tupleContains = array({
prefixItems: [string(), number()],
contains: boolean(),
});
const _h: IsExact<
InferOutput<typeof tupleContains>,
[string, number, ...boolean[]]
> = ok;

// contains (without prefixItems) infers a uniform array
const contains = array({ contains: number() });
const _i: IsExact<InferOutput<typeof contains>, number[]> = ok;

// unevaluatedItems (without prefixItems) infers a uniform array
const unevaluated = array({ unevaluatedItems: number() });
const _j: IsExact<InferOutput<typeof unevaluated>, 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<typeof s>> =
ok;
const _b: IsSubtype<
InferOutput<typeof s>,
{ 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<typeof all>> = ok;

// no required -> all optional
const opt = object({ properties: { x: string(), y: number() } });
const _d: IsSubtype<{ x?: string; y?: number }, InferOutput<typeof opt>> = ok;

// additionalProperties: false disallows extras (strict shape)
const strict = object({
properties: { name: string() },
required: ["name"],
additionalProperties: false,
});
const _e: IsSubtype<{ name: string }, InferOutput<typeof strict>> = ok;

// additionalProperties: <schema> 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<typeof extras>
> = ok;
});

Deno.test("type inference: combination schema", () => {
const s = combination({ anyOf: [string(), number()] });
const _a: IsExact<InferOutput<typeof s>, string | number> = ok;

const one = combination({ oneOf: [string(), boolean()] });
const _b: IsExact<InferOutput<typeof one>, string | boolean> = ok;
});

Deno.test("type inference: parse and validate signatures", () => {
const s = string();
const parsed = parse(s, "hello");
const _a: IsExact<typeof parsed, string> = ok;

// Array parse infers element type
const arr = array({ items: string() });
const arrParsed = parse(arr, ["a", "b"]);
const _b: IsExact<typeof arrParsed, string[]> = 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<typeof objParsed, { id: number; label: string }> = ok;

// validate result carries the output type
const result = validate(s, "hello");
if (!result.issues) {
const _d: IsExact<typeof result.value, string> = ok;
}

// Sanity: the assertions above are all compile-time; keep deno test happy.
assert(parsed === "hello");
});
Loading
Loading