The extraction itself was simple. A support message goes in, and I wanted a small typed object back:
import { z } from "zod";
const TicketSchema = z.object({
category: z.enum(["billing", "bug", "account"]),
summary: z.string(),
});
The schema worked everywhere else in the application. TypeScript was happy. Zod's own safeParse() was happy. Then the schema crossed a package boundary and ShapeCraft stopped recognizing it.
The error looked like a schema problem. It was actually a module identity problem.
The dependency layout that caused it
The app imported z from its own node_modules. A local package in the same workspace also had its own Zod dependency. Depending on the package manager and install layout, Node could load two different copies of Zod:
app/node_modules/zod
packages/ai/node_modules/zod
Both copies created real Zod schemas. They just did not share the same constructors or prototype objects.
The tempting detection code looks like this:
schema instanceof z.ZodType
That can return false even when schema is a genuine Zod schema. It was created by another Zod module instance, so its prototype chain does not lead back to the ZodType imported by the library doing the check.
My first fix was dependency cleanup
I tried to make the dependency tree contain one copy of Zod. I pinned the version, adjusted workspace dependencies, and ran the package manager's dedupe command.
That fixed one install layout. It did not fix the underlying assumption. A consumer using a file: dependency, a nested package, a different package manager, or a compatible Zod version could still end up with another module instance later.
I did not want the extraction API to depend on every consumer arranging its node_modules in exactly the way I expected. The question was not "was this object constructed by my copy of Zod?" It was "does this object have the Zod schema behavior I need?"
The check that survives the package boundary
ShapeCraft recognizes Zod schemas by their stable runtime surface instead of relying only on instanceof:
const isZodSchema = (value: unknown): boolean => {
return (
typeof value === "object" &&
value !== null &&
"_def" in value &&
typeof (value as { parse?: unknown }).parse === "function" &&
typeof (value as { safeParse?: unknown }).safeParse === "function"
);
};
That is the whole idea. _def, parse, and safeParse are present on Zod schemas across the supported versions, while the other schema input types in ShapeCraft do not have that same combination.
The consumer does not need a special adapter or a shared singleton:
import { generate, groq } from "@aviasole/shapecraft";
import { z } from "zod";
const TicketSchema = z.object({
category: z.enum(["billing", "bug", "account"]),
summary: z.string(),
});
const result = await generate(
groq({ model: "llama-3.3-70b-versatile" }),
TicketSchema,
"The invoice page crashes when I try to change my card."
);
console.log(result.data.category);
The schema can come from the app's Zod installation, a workspace package, or a linked dependency. Once ShapeCraft has recognized it, it uses the schema's own safeParse() method for the final validation.
Why duck typing is the right tradeoff here
Usually, duck typing has a bad reputation because a loose check can mistake an unrelated object for the thing you wanted. This check is deliberately narrow: it requires the internal definition plus both parsing methods that ShapeCraft actually needs.
It also avoids making Zod a runtime identity contract between two packages. The package that creates the schema and the package that consumes it do not have to import the exact same module object for the boundary to work.
That matters more in real TypeScript projects than it sounds. Monorepos, test runners, linked local packages, nested dependencies, and major-version transitions all make duplicate module instances possible without anyone doing anything unusual.
What this does not solve
This only solves schema recognition. It does not make incompatible schema APIs compatible, and it does not turn a plain object that happens to have a parse() method into a full Zod schema unless it also satisfies the complete detection shape.
It also does not change the guarantee level of the model backend. A recognized Zod schema still gets the guarantee that backend provides: native, constrained, or best-effort. Fixing the module boundary means the schema reaches the validation pipeline; it does not make the model's content factually correct.
Where it landed
The workspace stopped caring which copy of Zod created the schema. I removed the dependency-tree workaround from the integration instructions, and the same generate() call now works across the app package and the linked package.
The useful lesson was not to make every consumer dedupe perfectly. It was to avoid using constructor identity for a value that crosses package boundaries:
await generate(model, TicketSchema, prompt);
If a valid Zod schema suddenly becomes "unknown" after moving code into a workspace package, check for multiple Zod instances before rewriting the schema. In a library boundary, recognizing the behavior is more reliable than recognizing the constructor that produced it.
Top comments (2)
Great article! This
isZodSchemapart especially caught my eye. 👀It’s basically a composition of small runtime predicates: “is an object”, “has this capability”, “this property is a function”, and so on.
That’s actually very close to what I’ve been exploring with
is-kit.It's composing small reusable type guards instead of writing one large boolean check by hand.
I really like this kind of capability-based detection. It feels much more robust than relying too heavily on library identity. 😸
@yatindavra, this is a useful identity-versus-capability distinction at package boundaries. Because
_defis internal, I’d treat the detector itself as a compatibility contract: test single and duplicated installs, workspace links, each supported major, plus impostor objects that expose only one or two of the required members. That gives the duck-typing choice an explicit false-positive and versioning boundary instead of an accidental promise. Have you tested v3 and v4 copies in the same process, or do you deliberately reject mixed-major compatibility?