npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

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.

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 canonical In, and
  • the schema's output type (z.output) is exactly your canonical Out.

Unlike the common const s: z.ZodType<Out> annotation — which only checks assignabilityschemaFor 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 dependency

zod-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 RequestInResponseOut": 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 exactOptionalPropertyTypes on 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 rejectedschemaFor is, if anything, stricter than a bare z.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.ts

The readme:check step is wired into format:check and prepublishOnly, so a drifted README blocks publish.

License

MIT