zod-dynamic-schema-type-inference
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 toRecord<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:
- Hover over
z.infer<typeof schema>in your IDE — should show named fields, notRecord - Run
tsc --noEmit— no "'unknown'" type errors on schema-derived data - 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 thatz.infer<>produces typed fields before adopting it — some generateRecord<string, unknown> - Custom validators from shared modules (e.g.,
monetaryAmountSchema,ukPostcodeSchema) can be mixed freely with factory helpers since they have concrete return types