skills/hankanman/claude-config/zod-dynamic-schema-type-inference

zod-dynamic-schema-type-inference

Installation
SKILL.md

Zod Dynamic Schema Construction Type Inference Loss

Problem

When building a Zod schema factory that dynamically constructs z.object() from a Record<string, z.ZodType>, TypeScript's z.infer<> produces Record<string, unknown> instead of properly-typed fields. This breaks all downstream type usage — components, server actions, and form handlers that depend on typed schema inference get unknown for every field.

Context / Trigger Conditions

  • Building a schema factory or helper that returns z.ZodObject<Record<string, z.ZodType>>
  • Type error: 'data.fieldName' is of type 'unknown'
  • z.infer<typeof generatedSchema> resolves to Record<string, unknown> in IDE hover
  • Refactoring hand-written Zod schemas to use a centralized factory/generator
  • Using ORM metadata (Prisma, ZenStack, Drizzle) to auto-generate Zod schemas at runtime

Root Cause

z.object() accepts Record<string, z.ZodType> at runtime, but TypeScript needs the literal object type of the shape parameter to infer field types. When the shape is typed as Record<string, z.ZodType>, TypeScript only knows the values are "some Zod type" — it cannot narrow to ZodString, ZodNumber, ZodUUID, etc. per field.

// BAD: Returns z.ZodObject<Record<string, z.ZodType>>
// z.infer<> produces Record<string, unknown>
function makeModelSchema(fields: Record<string, z.ZodType>) {
  return z.object(fields);
}

const schema = makeModelSchema({
  name: z.string(),
  age: z.number(),
});
type T = z.infer<typeof schema>; // { [x: string]: unknown } — BROKEN

Solution: Per-Field Typed Helpers

Instead of building the whole object schema dynamically, export typed helper functions that return concrete Zod types. Each consumer file constructs z.object({}) explicitly with these helpers, preserving the literal type that TypeScript needs.

// factory.ts — export per-field typed helpers
export function fk(label: string): z.ZodUUID {
  return z.uuid(`Invalid ${label} ID`);
}

export function str(model: string, field: string): z.ZodString {
  // Read constraints from metadata (ORM schema, config, etc.)
  const constraints = getFieldConstraints(model, field);
  let s = z.string().trim();
  if (constraints.minLength) s = s.min(constraints.minLength);
  if (constraints.maxLength) s = s.max(constraints.maxLength);
  return s;
}

export function int(model: string, field: string): z.ZodNumber {
  const constraints = getFieldConstraints(model, field);
  let n = z.number().int();
  if (constraints.gte !== undefined) n = n.gte(constraints.gte);
  if (constraints.lte !== undefined) n = n.lte(constraints.lte);
  return n;
}
// user.ts — explicit z.object() preserves type inference
import { fk, str, int } from "./factory";

export const userSchema = z.object({
  name: str("User", "name"),       // TypeScript sees z.ZodString
  email: str("User", "email"),     // TypeScript sees z.ZodString
  age: int("User", "age"),         // TypeScript sees z.ZodNumber
  teamId: fk("team"),              // TypeScript sees z.ZodUUID
});

type User = z.infer<typeof userSchema>;
// { name: string; email: string; age: number; teamId: string } — CORRECT

Why This Works

  • Each helper has a concrete return type (z.ZodString, z.ZodNumber, z.ZodUUID)
  • The z.object({}) call sees the literal shape { name: z.ZodString, email: z.ZodString, ... }
  • TypeScript can infer each field type precisely
  • Runtime behavior is identical — constraints are still read from metadata

Alternative: Generic Function with Type Parameter

If you must return a whole schema from a function, use a generic type parameter:

function makeSchema<T extends z.ZodRawShape>(shape: T) {
  return z.object(shape);
}

// Caller must pass a literal object — NOT a variable typed as Record
const schema = makeSchema({
  name: z.string(),
  age: z.number(),
});
// Works because TypeScript infers T as { name: z.ZodString, age: z.ZodNumber }

Limitation: This only works when the shape is a literal at the call site. If you're building the shape dynamically from metadata, the type information is lost before the call — which is why per-field helpers are the robust solution.

Verification

After applying the fix:

  1. Hover over z.infer<typeof schema> in your IDE — should show named fields, not Record
  2. Run tsc --noEmit — no "'unknown'" type errors on schema-derived data
  3. Components using z.infer<typeof schema> as props should compile without casts

Notes

  • This applies to both Zod 3 and Zod 4 — the type inference mechanism is the same
  • The per-field helper pattern also works well with .omit(), .pick(), .partial(), .extend() — all composition methods preserve the inferred types
  • If using an ORM schema factory (e.g., @zenstackhq/zod, Drizzle-Zod), test that z.infer<> produces typed fields before adopting it — some generate Record<string, unknown>
  • Custom validators from shared modules (e.g., monetaryAmountSchema, ukPostcodeSchema) can be mixed freely with factory helpers since they have concrete return types
Weekly Installs
1
First Seen
Mar 4, 2026
Installed on
windsurf1
amp1
cline1
openclaw1
trae1
qoder1