Inferred typescript types of schema intersection do not match with validation rules
When using z.intersection or z.and the typescript types do not match the actual parsing behaviour.
I think the inferred types should match the actual behavior, such that:
- if a type is considered invalid by the typescript compiler, it should produce a parsing error
- if a type is considered valid by the typescript compiler, parsing should also work (except in a few more refined cases than can be expressed by the typescript type system).
Here are a few examples of where the types and the behavior do not match.
const baseSchema = z.strictObject({
a: z.string(),
b: z.number(),
});
const extensionSchema = z.object({
c: z.boolean(),
});
const laxCombinedSchema = z.object(baseSchema.shape).and(extensionSchema);
// { a: string; b: number; } & { c: boolean; }
// but should be { a: string; b: number; } & { c: boolean; } & { [key: string]: any; }
type TLaxCombinedIn = z.input<typeof laxCombinedSchema>;
// { a: string; b: number; } & { c: boolean; }
type TLaxCombinedOut = z.output<typeof laxCombinedSchema>;
const laxIn: TLaxCombinedIn = {
a: "foo",
b: 1,
c: true,
d: "anything",
} as TLaxCombinedIn; // ❌ Type Error: unrecognized key(s), but 'fixed' by adding `as TLaxCombinedIn`
laxCombinedSchema.parse(laxIn); // ✅ OK
const combinedSchema = baseSchema.and(extensionSchema);
// { a: string; b: number; } & { c: boolean; } but should be { }
type TCombinedIn = z.input<typeof combinedSchema>;
// { a: string; b: number; } & { c: boolean; } but should be { }
type TCombinedOut = z.output<typeof combinedSchema>;
const combinedIn: TCombinedIn = { a: "foo", b: 1, c: true }; // ✅ OK though it shouldn't be
try {
combinedSchema.parse(combinedIn); // ❌ Error while parsing: Unrecognized key: "c"
} catch (e) {
console.error("Error parsing combined schema with extra properties, while no type error.");
}
const intersectionSchema = z.intersection(baseSchema, extensionSchema);
// { a: string; b: number; } & { c: boolean; } but should be { }
type TIntersectionIn = z.input<typeof intersectionSchema>;
// { a: string; b: number; } & { c: boolean; } but should be { }
type TIntersectionOut = z.output<typeof intersectionSchema>;
const intersectionIn: TIntersectionIn = { a: "foo", b: 1, c: true }; // ✅ OK though it shouldn't be
try {
intersectionSchema.parse(intersectionIn); // ❌ Error while parsing: Unrecognized key: "c"
} catch (e) {
console.error("Error parsing intersection schema with extra properties, while no type error.");
}
const extensionStrictSchema = z.strictObject(extensionSchema.shape);
const combinedStrict = baseSchema.and(extensionStrictSchema);
// { a: string; b: number; } & { c: boolean; } but should be { }
type TCombinedStrictIn = z.input<typeof combinedStrict>;
// { a: string; b: number; } & { c: boolean; } but should be { }
type TCombinedStrictOut = z.output<typeof combinedStrict>;
// => same problem as combined, but even worse
const extendedSchema = baseSchema.extend(extensionStrictSchema.shape);
extendedSchema.parse({ a: "foo", b: 1, c: true }); // ✅ OK
console.log("Extended schema parsed successfully with extension.");
// type TExtendedIn = { a: string; b: number; c: boolean; }
type TExtendedIn = z.input<typeof extendedSchema>;
// type TExtendedOut = { a: string; b: number; c: boolean; }
type TExtendedOut = z.output<typeof extendedSchema>;
const extendedIn: TExtendedIn = { a: "foo", b: 1, c: true }; // ✅ OK
const extendedOut: TExtendedOut = extendedSchema.parse(extendedIn); // ✅ OK
I would also like to question the usefulness of the intersection of strictObjects, in the way that it is implemented currently. Although it may seem to make sense that if the first object does not allow extra properties, that the intersection with any other object will respect that, it is not how the typescript type system implements it. And especially the 'and' convenience method is misleading, since it reminds people (me at least) of & in typescript, but { a: ... } & { b: ... } in typescript is equivalent to { a: ...; b: ... }, whereas z.strictObject({a: ... }).and(z.object({ b: ... }) is equivalent to input type {}.
Also z.object({ a: ... }) produces input type {a: ...} which is incorrect because it allows extra properties in the input ({a: ...; [string]: any } would be closer to the truth).
The choice seems to be dictated by ease-of-implementation (just check both schema's independently) rather than usefulness for a specific purpose.
📝 CodeRabbit Plan Mode
Generate an implementation plan and prompts that you can use with your favorite coding agent.
- [ ] Create Plan
🔗 Similar Issues
Possible Duplicates
- https://github.com/colinhacks/zod/issues/4564
Related Issues
- https://github.com/colinhacks/zod/issues/4565
- https://github.com/colinhacks/zod/issues/4550
- https://github.com/colinhacks/zod/issues/5390
- https://github.com/colinhacks/zod/issues/4561
- https://github.com/colinhacks/zod/issues/3962
- https://github.com/colinhacks/zod/issues/4610
🔗 Related PRs
colinhacks/zod#4074 - Zod 4 [merged]
colinhacks/zod#4150 - Optimize extendShape [merged]
colinhacks/zod#4271 - Support recursive types in z.object(), drop z.interface() [merged]
colinhacks/zod#4539 - Fix default & prefault object handling [merged]
colinhacks/zod#5534 - Add z.fromJSONSchema(), z.looseRecord(), z.xor() [merged]
👤 Suggested Assignees
🧪 Issue enrichment is currently in open beta.
To disable automatic issue enrichment, add the following to your .coderabbit.yaml:
issue_enrichment:
auto_enrich:
enabled: false
💬 Have feedback or questions? Drop into our discord or schedule a call!
Hi @mrft! I'm Dosu and I’m helping the zod team.
I'm a bot trained on Zod's codebase and issues. The mismatch you're seeing between TypeScript type inference and Zod's runtime validation for intersections—especially with strict objects and extra properties—is a known and intentional design decision in Zod. Type inference for intersections just computes the TypeScript intersection of the input/output types, but at runtime, each strict object schema independently rejects keys not in its shape, so extra keys from the other schema cause parse errors even if the TypeScript type allows them. This is due to how strictness is enforced at runtime, not a bug in type inference (discussion, implementation).
TypeScript's type system is structural and doesn't enforce excess property checks at runtime—those are only checked in certain assignment contexts. Zod's .strictObject() (or .strict()) enforces strictness at runtime, so the intersection of two strict objects will often fail to parse any object with keys from both sides, even though the inferred type looks like it should work (example, example).
The recommended approach is to avoid strictness on at least one side of the intersection, or—better yet—use .extend() to merge schemas instead of intersecting strict objects. .extend() produces a new object schema with the combined shape and avoids these runtime issues (docs, docs). If you want a strict merged schema, use .merge() or .extend() and then .strict() (workaround).
This mismatch is a known source of confusion and is partly due to TypeScript's design, not just Zod's implementation (discussion). If you have a self-contained, runnable reproduction or further questions, feel free to share. Tagging @colinhacks for visibility. If this answers your question, please close the issue.
To reply, just mention @dosu.
How did I do? Good | Irrelevant | Incorrect | Verbose | Hallucination | Report 🐛 | Other
I am aware that it is a "known and intentional design decision in Zod".
First of all, I am questioning the design decision, because I can't see how that design decision helps the users. It just makes the implementation easier.
But even if we respect the design decision, the inferred typescript types do not conform to what the validator actually accepts, which should be considered a bug.
You're right that the mismatch between inferred TypeScript types and what Zod's validator actually accepts—especially with intersections of strict objects—is confusing and counterintuitive. This has been discussed at length by both users and maintainers, and while many agree it feels like a bug, the maintainers have consistently stated that it's an intentional design tradeoff for how strictness is enforced at runtime. Intersections in Zod are "dumb": they validate the input against both schemas independently, so strict objects will reject each other's keys, even if the TypeScript type system would allow them. This is not considered a bug by the maintainers, but rather a consequence of the current implementation and TypeScript's own limitations (issue #4012, issue #749, discussion #976, issue #82).
The recommended workaround is to use .merge() or .extend() (and then .strict() if you want strictness) to combine object schemas, rather than intersecting strict objects. This approach produces a new object schema with the combined shape and avoids the runtime/type inference mismatch (discussion #976, issue #3952). There are no open PRs or plans to change this behavior, and the official docs don't currently address this in detail.
If you have suggestions for a better approach or want to see this reconsidered, maintainers have been open to feedback in the past, but for now, this is the intended behavior. If this answers your question, please close the issue.
To reply, just mention @dosu.
How did I do? Good | Irrelevant | Incorrect | Verbose | Hallucination | Report 🐛 | Other