How to type-safely pass unknown (Zod-parsed config) to a JsonB field without casting to InputJsonValue? #29513
QuestionI have a generic component lifecycle system where configuration is parsed by Zod schemas at the service layer and then written to a JsonB column via a repository. To keep the repository and service layers decoupled from specific config shapes, the repository method accepts unknown: This produces a type error because unknown is not assignable to InputJsonValue | typeof Prisma.skip. I currently bridge the gap with a helper at the repository boundary: The values always come from schema.safeParse() using Zod strict-object schemas, so they're always valid JSON (no symbols, bigints, functions, or non-serializable values). But TypeScript can't prove it. What I've considered and why each doesn't satisfy me:
Questions:
How to reproduce (optional)No response Expected behavior (optional)No response Information about Prisma Schema, Client Queries and Environment (optional)
|
Replies: 5 comments
|
This is a genuinely hard TypeScript problem. You're caught between three design constraints:
The core issue is that TypeScript's type system cannot "see through" a Zod parse into the literal structure of your schema. Even with The pragmatic solution: a typed wrapperInstead of fighting the type system, add a thin runtime+type boundary at the point where your domain model meets Prisma. Here's the pattern I use in production: import { Prisma } from '@prisma/client';
/**
* Domain-to-Prisma JSON bridge.
* This accepts anything Zod produces and returns Prisma's JSON type,
* with a small runtime check for serializability.
*/
function toJsonValue<T>(
value: T
): Prisma.InputJsonValue | typeof Prisma.skip {
if (value === undefined || value === null) {
return Prisma.skip;
}
// Runtime guard: ensure value is JSON-serializable
// This catches symbols, functions, undefined in objects, etc.
try {
JSON.stringify(value);
} catch {
throw new Error('Value is not JSON-serializable');
}
// Return the same value but typed as InputJsonValue.
// The `JSON.stringify` check acts as our runtime proof.
return value as unknown as Prisma.InputJsonValue;
}Then your repository method becomes: public updateComponent(
type: string,
configuration?: unknown
): Effect<Component, RepositoryError> {
return tryPrisma(() =>
this.prisma.component.update({
where: { type },
data: {
configuration: toJsonValue(configuration),
},
})
);
}Why this is better than the alternatives you considered
Alternative: branded type + Zod refinementIf you want to eliminate the // Branded type: "this unknown has been proven JSON-safe"
type JsonSafe<T = unknown> = T & { readonly __jsonSafe: true };
function jsonSafe<T>(value: T): JsonSafe<T> {
JSON.stringify(value); // throws if invalid
return value as JsonSafe<T>;
}
// Zod schema that produces JsonSafe
defineConfigSchema = z.strictObject({
// ... your fields
}).transform((data) => jsonSafe(data));
// Then in Prisma, extend the generated types
// (requires a small type declaration merge)
declare global {
namespace Prisma {
type InputJsonValue = JsonSafe | string | number | boolean | null | JsonObject | JsonArray;
}
}This is more work but gives you zero casts in application code. However, I only recommend this for large teams where the branded-type discipline is worth the overhead. The honest answer about Prisma's typesPrisma's
Bottom lineUse the If you want, I can also show how to make |
|
Hello 👋 The bounded cast at the storage boundary is the intended pattern, and there's no built-in narrower from unknown to InputJsonValue. On Prisma exports, the runtime package gives you JsonValue, InputJsonValue, JsonObject, InputJsonObject, JsonArray, and InputJsonArray as types only. There's no isInputJsonValue runtime helper or branded validator. The Input variants exclude null because writes need Prisma.JsonNull or Prisma.DbNull explicitly, which is why you can't round-trip a JsonValue read straight back as an InputJsonValue write. On Zod integrations, nothing produces types that drop in for InputJsonValue. prisma-json-types-generator works on the output side, making JsonValue more specific, and zod-prisma-types generates schemas for Prisma's model input types like ComponentUpdateInput, not free-form JSON. You can hand-write a recursive Zod schema typed against InputJsonValue and intersect it with your strict-object schemas, but that pulls the Prisma type back into your schema definitions, which is the coupling you're trying to avoid. One alternative worth trying before settling on the cast: make the repository method generic with a JSON constraint, which moves the structural check to the call site instead of the implementation. public updateComponent<T extends Prisma.InputJsonValue>(
type: string,
configuration?: T,
): Effect<Component, RepositoryError> {
return tryPrisma(() => this.prisma.component.update({
where: { type },
data: { configuration: configuration ?? Prisma.skip },
}));
} |
|
Hi, As we have not heard back from you, we are closing this discussion to keep our discussions organized. Feel free to start a new discussion if this remains relevant. Thank you for being part of the community! |
|
You've correctly identified the fundamental tension here: TypeScript can't prove that a Zod-validated value satisfies The intended patternThe single bounded
Cleaner helper approach (still has a cast, but narrows it)The cleanest version without coupling your domain to Prisma types: // In your repository infrastructure layer only
import { Prisma } from '@prisma/client'
/**
* Converts a Zod-parsed value to Prisma's InputJsonValue.
* Safe to use because Zod strict schemas produce only JSON-serializable output.
* The cast here is a known-safe boundary between domain and persistence layers.
*/
export function toInputJson(value: unknown): Prisma.InputJsonValue {
return value as Prisma.InputJsonValue
}
export function toInputJsonOrSkip(
value: unknown
): Prisma.InputJsonValue | typeof Prisma.skip {
if (value === undefined) return Prisma.skip
return value as Prisma.InputJsonValue
}This gives you a single named cast point that documents its intent. Your repo becomes: public updateComponent(type: string, configuration?: unknown) {
return tryPrisma(() => this.prisma.component.update({
where: { type },
data: {
configuration: toInputJsonOrSkip(configuration),
},
}))
}Zod integration optionIf you want to avoid the cast entirely at the cost of some coupling, import { ComponentOptionalDefaultsSchema } from './generated/zod'
// ComponentOptionalDefaultsSchema.shape.configuration produces Prisma.InputJsonValueBut this couples your service layer schemas to generated Prisma types — the opposite of what you want. JSON.parse(JSON.stringify()) alternativeYou already found this doesn't help (still returns Answer to your specific questions
|
|
The single bounded cast at the storage boundary is the intended pattern here — but you can make it safer and more explicit. Recommended approach: narrow with a type guard at the boundaryInstead of a bare function isInputJsonValue(value: unknown): value is Prisma.InputJsonValue {
// Prisma.InputJsonValue accepts any JSON-serializable value.
// Values from Zod strict-object schemas are always valid JSON,
// so this guard is a documented assertion, not a runtime check.
return value !== undefined;
}
public updateComponent(type: string, configuration?: unknown) {
return tryPrisma(() => this.prisma.component.update({
where: { type },
data: {
configuration: isInputJsonValue(configuration)
? configuration
: Prisma.skip,
},
}));
}This is semantically equivalent to your cast, but it reads as a deliberate boundary assertion rather than a suppressed type error. Using zod-to-prisma types (zod-prisma-types)If you use Is
|
Hello 👋
The bounded cast at the storage boundary is the intended pattern, and there's no built-in narrower from unknown to InputJsonValue.
On Prisma exports, the runtime package gives you JsonValue, InputJsonValue, JsonObject, InputJsonObject, JsonArray, and InputJsonArray as types only. There's no isInputJsonValue runtime helper or branded validator. The Input variants exclude null because writes need Prisma.JsonNull or Prisma.DbNull explicitly, which is why you can't round-trip a JsonValue read straight back as an InputJsonValue write.
On Zod integrations, nothing produces types that drop in for InputJsonValue. prisma-json-types-generator works on the output side, making JsonValue more …