@tsed/schema
Version:
JsonSchema module for Ts.ED Framework
208 lines (148 loc) • 12.6 kB
Markdown
# Ts.ED Schema Functional API — Typed Inference Plan (Zod-like)
Status: Proposal/Plan
Owner: @tsed/schema
Last updated: 2025-10-12
## Goals
- Provide strong TypeScript inference for the Functional API under `@tsed/schema/src/fn`, similar to Zod.
- Preserve current runtime behavior and JSON Schema generation.
- Keep backward compatibility for existing call sites (decorators and functional builders).
- Offer a simple type-level utility to derive a TypeScript type from a schema (like `z.infer`).
## Non-Goals
- No changes to runtime logic of `JsonSchema` and mappers unless strictly needed for typing.
- No breaking changes to decorator-based API.
---
## High-level Design
Leverage the new generic `JsonSchema<T>` as the single source of truth for type inference. We no longer need a separate phantom wrapper type. All functional builders will return `JsonSchema<T>` directly and chaining methods will transform `T` via their generic signatures.
### 1) JsonSchema<T> as carrier of the inferred type
- `JsonSchema` is now generic: `class JsonSchema<T = any> { ... }`. The generic `T` carries the inferred TypeScript type across builder chains.
- No runtime changes are necessary; the generic only exists at compile time.
Rationale: By placing the generic on `JsonSchema` itself, we simplify the type surface, avoid wrapper aliases, and reduce conflicts with existing decorators like `@Schema()`.
### 2) Infer Utility (s.infer)
- Provide an `infer<S>` helper that extracts the `T` from `JsonSchema<T>`.
- Expose it under the `s` namespace as `s.infer<...>` using declaration merging. Also export from the package root for convenience.
```ts
// Helper to extract the generic parameter
export type Infer<S> = S extends JsonSchema<infer T> ? T : never;
// Re-export as s.infer via namespace merging
export namespace s {
export type infer<S> = Infer<S>;
}
```
Usage:
```ts
const user = s.object({name: s.string(), age: s.number().minimum(0)});
type User = s.infer<typeof user>; // { name: string; age: number }
```
### 3) Typed Builder Signatures
Note on time(): time-of-day values are mapped to Date by default (not string) to align with @tsed/json-mapper’s default behavior. This can be made configurable in a future phase.
Update the type signatures of the functional builders to return `JsonSchema<T>` directly. Key builders:
- Primitives: `string() -> JsonSchema<string>`, `number() -> JsonSchema<number>`, `integer() -> JsonSchema<number>`, `boolean() -> JsonSchema<boolean>`
- Dates: `date() -> JsonSchema<Date>`, `datetime() -> JsonSchema<Date>`, `time() -> JsonSchema<Date>` (inferred as Date by default to align with @tsed/json-mapper)
- Collections: `array(item: JsonSchema<I>) -> JsonSchema<I[]>`, `set(item: JsonSchema<I>) -> JsonSchema<Set<I>>`, `map(value: JsonSchema<V>) -> JsonSchema<Map<string, V>>`
- Object: `object(props: { [K in string]: JsonSchema<any> }) -> JsonSchema<{ [K in keyof props]: Infer<props[K]> }>`
- Enums: `enums(["A","B"]) -> JsonSchema<"A" | "B">` and `enums(enumObj) -> JsonSchema<EnumType>`
- Unions: `oneOf([S1, S2]) -> JsonSchema<Infer<S1> | Infer<S2>>`, `anyOf` similarly
- Intersections: `allOf([S1, S2]) -> JsonSchema<Infer<S1> & Infer<S2>>`
- Lazy refs: `lazyRef(() => Class) -> JsonSchema<InstanceType<typeof Class>>`
Notes:
- All definitions remain runtime-compatible and internally still build `JsonSchema`.
- Overloads may be necessary to support both array-of-schemas and variadic signatures.
### 4) Typed Method Chaining on JsonSchema
Many instance methods exist on `JsonSchema<T>` (e.g., `optional`, `nullable`, `default`, `minLength`, etc.). We will type the subset that affects the resulting inferred type via generic method signatures on `JsonSchema<T>` itself:
- `optional(): JsonSchema<T | undefined>` — marks property as optional (required=false) and adds `undefined` to `T`.
- `nullable(value?: boolean): JsonSchema<T | null>` — sets `nullable=true` and adds `null` to `T`.
- `default(value: T | (() => T)): JsonSchema<T>` — documentation-only; DOES NOT change the type `T`.
- `required(): JsonSchema<Exclude<T, undefined>>` and `required(false): JsonSchema<T | undefined>` — toggles required at type level accordingly.
Implementation approach:
- Since `JsonSchema` is generic, its instance methods can be declared with generic-aware return types. No wrapper interface is needed.
- Non-type-affecting methods (format, minLength, etc.) keep returning `JsonSchema<T>`.
### 5) Object Properties Typing
- `object({...})` should accept `JsonSchema` values and infer the resulting TypeScript type.
- Add a helper `PropsToShape<P>` mapping from builder map to a TS type:
```ts
type PropsToShape<P extends Record<string, JsonSchema<any>>> = {
[K in keyof P]: Infer<P[K]>;
};
```
- For optional properties via `.optional()`, `Infer` already carries `| undefined` which object typing will reflect.
### 6) Narrowing via JSON Keywords (Optional Phase II)
- Certain keywords could refine types, but we will keep Phase I simple:
- `minLength`, `maxLength`: do not narrow `string`.
- `minimum`, `maximum`, `multipleOf`: do not narrow `number`.
- Leave a roadmap for literal/default inference improvements (e.g., `.const("x")` -> type `"x"`).
### 7) Types for `from()`
- `from(Ctor)` should produce `JsonSchema<InstanceType<Ctor>>` when `Ctor` is a class.
- For primitives passed (String, Number, Boolean), map to their primitive types.
### 8) Public Exports and `s` Namespace
- Extend `s` in `fn/index.ts` with a declaration-merged namespace to expose `type infer<S>`; also export `Infer` from the package root (`src/index.ts`). No separate `SchemaShape` export is required anymore.
### 9) Type Tests (no runtime)
Add `.dts.spec.ts` style tests using Vitest’s type tests (or `@tsd` style) to validate inference without executing code:
- `object` with required/optional/nullable/default.
- `array/map/set` element typing (ensure `set()` infers `Set<T>`).
- `oneOf/anyOf/allOf` unions and intersections.
- `enums` literal unions.
- `from(Class)` yields instance type.
Use `// @ts-expect-error` to ensure invalid compositions are caught.
### 10) Docs
- Update docs with a new page: “Functional API with Type Inference”.
- Show side-by-side examples vs Zod (`s.string().optional()` etc.).
- Document `s.infer` (lowercase) and ensure discoverability from `s` namespace.
### 11) Migration & Compatibility
- The change is additive: existing code using `JsonSchema` continues to work.
- Builders now return `JsonSchema<T>` directly; the inferred type is carried by the generic parameter.
- No change in emitted JSON Schema or OpenAPI.
### 12) Implementation Steps
Additional builder: any()
- Behavior: `any()` with no arguments => `JsonSchema<any>`; `any(S1, S2, ...)` where each Si is a `JsonSchema` => `JsonSchema<[Infer<S1>, Infer<S2>, ...]>` (tuple of provided types). It is also exposed on `s.any`.
- Rationale: matches Ts.ED’s current JSON mapper use-cases for a variadic any() that describes a fixed tuple of allowed types at the type level while preserving runtime behavior.
1. Ensure `Infer` type extracts the generic from `JsonSchema<T>` and expose it as `s.infer` via namespace merging.
2. Verify each builder’s TypeScript signature returns `JsonSchema<T>` with correct generic, leveraging the new `JsonSchema<T>`.
3. Ensure `JsonSchema` chainers (`optional`, `nullable`, `default`, `required`) have generic-aware return types that transform `T` as specified; `default()` remains doc-only.
4. Keep `object()` typings using `PropsToShape` to infer property shapes from `JsonSchema` props.
5. Confirm `from()` typings map classes and primitives to the correct `T`.
6. Public exports: expose `Infer` and `s.infer`; no separate wrapper type export needed.
7. Maintain and extend type-level tests in `packages/specs/schema/src/fn/typing.spec.ts` to cover all scenarios.
8. Update docs and examples to reflect `JsonSchema<T>` as the carrier type and `s.infer` usage.
### 13) Decisions Applied (resolving previous open questions)
- `set()` MUST infer `Set<T>`.
- `.default()` is documentation-only for Swagger/JSON Schema and DOES NOT affect runtime behavior or TypeScript types.
- `date()` and `datetime()` infer `Date` by default because `@tsed/json-mapper` performs conversion. We will document an extension point to let devs override the inferred type (e.g., Moment) in a future phase; not part of MVP.
### 14) Axes d'amélioration (Roadmap Phase II/III)
- Date type override (configurable): Allow a global/project-level override for the inferred date-like type (e.g., Moment, Dayjs) while keeping `Date` as the default. Possible approaches: module augmentation hook, DI configuration token, or generic parameter like `SchemaDate<TDate = Date>` applied via a central helper. The runtime stays unchanged; only typing varies.
- Literal narrowing enhancements: Improve `.const()` and `.enum()` so that the inferred type preserves literal values ("A", 42) where possible. Keep `.default()` as documentation-only (no type impact) but explore an opt-in helper (e.g., `.asConstDefault()`) for literal default-based narrowing without changing runtime.
- Branded primitives: Provide branded types for format-based strings (email/uri/url/uuid/cuid) via intersection branding (e.g., `string & { __brand: "email" }`) to improve static safety without affecting runtime.
- Deep helpers on object schemas: Add helpers like `.partial()`, `.requiredAll()`, `.pick<K>()`, `.omit<K>()`, `.merge()`, and `.deepPartial()` that transform `T` while preserving JSON Schema equivalence or providing documented transformation semantics.
- `additionalProperties` typing ergonomics: Offer a configuration or helper to choose between `Record<string, V>` and `Map<string, V>` for object maps when using `additionalProperties`. Today `map()` is `Map<string, V>`; consider an alternate builder `record()` to infer `Record<string, V>`.
- `patternProperties` key typing: Explore a light refinement to represent known regex buckets at the type level (e.g., via branded keys or mapped types) while remaining sound. Likely an opt-in helper.
- Union/intersection expressiveness: Improve `oneOf/anyOf/allOf` inference for complex arrays (distributive unions, exclusion of `null`/`undefined` where appropriate) and add stricter oneOf semantics checks (type-level) when feasible.
- `any()` ergonomics: Evaluate an additional signature that yields union of provided types instead of tuple when appropriate, e.g., `anyUnion(S1, S2, ...) -> Infer<S1> | Infer<S2> | ...`, keeping existing behavior for backward compatibility.
- Schema generics and parameterization: Provide a pattern for defining generic schemas (e.g., `makePaged<T>(item: JsonSchema<T>)`) with strong typing, including inference for nested generics.
- Type emission performance: Keep types shallow where possible to reduce editor latency (avoid excessive conditional types and recursive mapped types). Provide internal utility types to cap recursion depth for `.deepPartial()`.
- DX and error messages: Add typed helpers that surface clearer compile-time errors (e.g., invalid enum inputs, inconsistent `oneOf` array forms). Consider labeled helpers like `s.name("User")` that brand the type for better diagnostics without changing `T`.
- Interop and adapters: Provide type-safe adapters/importers from Zod/Valibot/Yup where feasible (type-only). Ensure the inferred `T` matches adapters’ expectations.
- Testing strategy: Augment type-level tests using `@tsd` or `vitest` type assertions to cover the above features; include negative tests (`@ts-expect-error`) and perf sentinels for slow types.
- Documentation and recipes: Add a dedicated page with advanced recipes (branded types, deep helpers, generics), guidance on date type override, and migration notes. Provide copy‑paste snippets and playground links.
- Tooling guardrails: Offer optional ESLint rules or codemods to discourage unsafe `any()` usage in favor of precise builders (array/map/set/object) or `anyUnion` when introduced.
---
## Example
```ts
import {s} from "@tsed/schema";
const UserSchema = s.object({
id: s.string().uuid().required(),
email: s.email().optional(),
age: s.number().minimum(0).optional(),
roles: s.array(s.string()).default([]),
profile: s.object({
firstName: s.string(),
lastName: s.string().optional()
})
});
type User = s.infer<typeof UserSchema>;
// type User = {
// id: string;
// email?: string | undefined;
// age?: number | undefined;
// roles: string[];
// profile: { firstName: string; lastName?: string | undefined };
// }
```