zod-schema-for
v0.1.1
Published
Tiny, zero-dependency, Zod-4, transform-aware helper that pins a Zod schema's INPUT and OUTPUT types to canonical TypeScript types with EXACT identity.
Maintainers
Readme
zod-schema-for
Tiny, zero-dependency, Zod 4 helper that pins a Zod schema's input and output types to your hand-written canonical TypeScript types — with exact identity.
schemaFor<In, Out>() wraps a Zod schema and forces, at compile time, that:
- the schema's input type (
z.input) is exactly your canonicalIn, and - the schema's output type (
z.output) is exactly your canonicalOut.
Unlike the common const s: z.ZodType<Out> annotation — which only checks
assignability — schemaFor uses exact type identity. That means it also
catches extra keys and narrowed values that the annotation silently lets
through. It is transform-aware: In and Out can differ, so it works for
request-body → handler-output pipelines.
It returns the schema unchanged at runtime (zero runtime cost), so
schema.parse(x) still returns a plain, readable object.
Install
pnpm add -D zod-schema-for
pnpm add zod # zod 4 is a peer dependencyzod-schema-for has zero runtime dependencies; zod (^4) is a peer dependency.
Usage
The canonical TypeScript types stay the source of truth; the schema must conform to them, exactly.
Non-transform (input === output): one type param
import {z} from 'zod';
import {schemaFor} from 'zod-schema-for';
type User = {id: string; name: string};
const User = schemaFor<User>()(
z.object({id: z.string(), name: z.string()}),
);
User.parse({id: 'a', name: 'Alice'}); // => { id: string; name: string }Transform (input !== output): two type params
A classic case: a request body comes in as strings, and the response / handler wants stronger types (a lowercased hex string, a numeric amount).
import {z} from 'zod';
import {schemaFor} from 'zod-schema-for';
type Hex = `0x${string}`;
type RequestIn = {address: string; amount: string}; // request body (the wire)
type ResponseOut = {address: Hex; amount: number}; // handler / response
const CreateTransfer = schemaFor<RequestIn, ResponseOut>()(
z.object({
address: z
.string()
.regex(/^0x[0-9a-fA-F]+$/)
.transform((s) => s.toLowerCase() as Hex),
amount: z
.string()
.regex(/^[0-9]+$/)
.transform((s) => Number(s)),
}),
);
const parsed = CreateTransfer.parse({address: '0xABCDEF', amount: '1000'});
// parsed: { address: Hex; amount: number } (a readable object)
// => { address: '0xabcdef', amount: 1000 }Read it as "a schema for RequestIn → ResponseOut": validate the
request-side input, produce the response-side output.
If you forget the second param on a transform schema, the inferred output won't equal the (defaulted) input, and you get a compile error — so you can't silently mis-pin.
Why exact identity? (schemaFor vs z.ZodType<...>)
The widely-used const s: z.ZodType<Canonical> annotation only checks
assignability, which leaves real gaps. schemaFor checks exact identity.
For type Canonical = {a: string; b: number}:
| Schema drift | z.ZodType<Canonical> (assignability) | schemaFor<Canonical>() (exact) |
| --------------------------------------------- | -------------------------------------- | -------------------------------- |
| Missing key ({a}) | ❌ errors | ❌ errors |
| Wrong type (b: string) | ❌ errors | ❌ errors |
| Extra key ({a, b, c}) | ✅ false-passes | ❌ errors |
| Widened value (a: string \| number) | ❌ errors | ❌ errors |
| Narrowed value (a: 'x' literal) | ✅ false-passes | ❌ errors |
| Input-side drift (transform) | not checked (single-arg form) | ❌ errors |
The two false-passes (extra keys, narrowed values) are precisely the holes
schemaFor closes. See packages/zod-schema-for/test
for the full, type-level proof matrix (validated by vitest --typecheck).
Caveats (where exact identity can misfire)
"Exact identity" is the well-known
Equals trick, which is
implementation-defined (TypeScript has no built-in type-equality operator). It
is very reliable for the shapes schemaFor targets — flat objects, primitives,
unions, enums, and transforms — but a few corners matter:
- Most rough edges are false alarms (a correct schema is rejected): mainly
intersection types (
{a} & {b}≠ flat{a; b}, from either side) and object-of-union vs union-of-objects. Safe; fix by reshaping the canonical type (prefer flat objects). - There is one false pass (a wrong schema accepted): optional keys under
exactOptionalPropertyTypeson TypeScript < 6.0, where Zod's{name?: string | undefined}is wrongly treated as equal to{name?: string}(TS#61547, fixed in TS 6.0). Use TS 6.0+,z.exactOptional(), or{name?: T | undefined}canonical types. readonly,any/unknown, nested objects, tuples-vs-arrays, enum drift, and.default()asymmetry are all correctly rejected —schemaForis, if anything, stricter than a barez.ZodType<...>here.
The full, verified matrix (TS 5.9.3, Zod 4.4.3) — including .brand() vs
hand-written brands, records, discriminated unions, and the exact mitigations —
lives in docs/edge-cases.md, and is pinned as
@ts-expect-error tests in
packages/zod-schema-for/test/edge-cases.test.ts.
No dependency? Copy this.
The helper is tiny and has no runtime code. If you'd rather not add a dependency,
copy this verbatim into your project (kept in sync with the published source
by pnpm readme:check in CI):
import type {z} from 'zod';
/** Exact type identity (the well-known "Equals" trick). Pure type-level. */
export type AssertEqual<T, U> =
(<V>() => V extends T ? 1 : 2) extends <V>() => V extends U ? 1 : 2
? true
: false;
/**
* A Zod schema whose input AND output are pinned to canonical TS types, inline.
*
* It returns the schema UNCHANGED at runtime, but the type checker enforces
* EXACT identity (not mere assignability) between:
* - the schema's INPUT type (`z.input`) and `In` (request-body side)
* - the schema's OUTPUT type (`z.output`) and `Out` (response / handler side)
*
* Because it is exact identity rather than assignability, it ALSO catches extra
* keys and over-narrowed/over-widened values that a bare `z.ZodType<...>`
* annotation would silently accept.
*
* `Out` defaults to `In`, so a single type param suffices for non-transform
* schemas. For a transform schema you must supply both params — if you forget
* the second one, the output won't equal `In` and you get a compile error, so
* you can't silently mis-pin.
*
* @example
* // non-transform (input === output): one type param
* type User = {id: string; name: string};
* const User = schemaFor<User>()(z.object({id: z.string(), name: z.string()}));
*
* @example
* // transform (input !== output): two type params
* type Hex = `0x${string}`;
* type RequestIn = {address: string; amount: string};
* type ResponseOut = {address: Hex; amount: number};
* const CreateTransfer = schemaFor<RequestIn, ResponseOut>()(
* z.object({
* address: z
* .string()
* .regex(/^0x[0-9a-fA-F]+$/)
* .transform((s) => s.toLowerCase() as Hex),
* amount: z
* .string()
* .regex(/^[0-9]+$/)
* .transform((s) => Number(s)),
* }),
* );
* CreateTransfer.parse(data); // returns ResponseOut (a readable object)
*/
export const schemaFor =
<In, Out = In>() =>
<S extends z.ZodType<any, any, any>>(
schema: AssertEqual<z.input<S>, In> extends true
? AssertEqual<z.output<S>, Out> extends true
? S
: S & {
'output does not match canonical type': {
expected: Out;
got: z.output<S>;
};
}
: S & {
'input does not match canonical type': {
expected: In;
got: z.input<S>;
};
},
): S =>
schema;Development
This is a pnpm monorepo. The package lives under
packages/zod-schema-for.
pnpm install
pnpm build # tsc -> dist
pnpm test # vitest run --typecheck (runtime + type-level tests)
pnpm format # prettier --write
pnpm format:check # prettier --check (also runs readme:check)
pnpm readme # regenerate the copy-paste block above from src/index.ts
pnpm readme:check # fail if the copy-paste block drifted from src/index.tsThe readme:check step is wired into format:check and prepublishOnly, so a
drifted README blocks publish.
License
MIT
