zod-prisma-types icon indicating copy to clipboard operation
zod-prisma-types copied to clipboard

Json field types incompatible with generated Prisma types

Open jaschaephraim opened this issue 1 year ago • 7 comments

Minimal reproduction:

schema.prisma

generator client {
  provider = "prisma-client-js"
}

generator zod {
  provider = "zod-prisma-types"
}

datasource db {
  provider = "postgresql"
  url      = env("DATABASE_URL")
}

model Model {
  id   String @id @default(uuid())
  json Json
}

index.ts

import type { Model as PrismaModel } from '@prisma/client';
import type { Model as ZodModel } from './generated/zod';

const a: PrismaModel = { id: 'abc', json: {} };
const b: ZodModel = a;

This results in the TypeScript error:

Type 'Model' is not assignable to type '{ id: string; json: InputJsonValue; }'.
  Types of property 'json' are incompatible.
    Type 'JsonValue' is not assignable to type 'InputJsonValue'.
      Type 'null' is not assignable to type 'InputJsonValue'.

If the field is specified as json Json?, the error is:

Type 'Model' is not assignable to type '{ json?: string | number | true | JsonObject | JsonArray | DbNull | JsonNull | undefined; id: string; }'.
  Types of property 'json' are incompatible.
    Type 'JsonValue' is not assignable to type 'string | number | true | JsonObject | JsonArray | DbNull | JsonNull | undefined'.
      Type 'null' is not assignable to type 'string | number | true | JsonObject | JsonArray | DbNull | JsonNull | undefined'.

My pull request https://github.com/chrishoermann/zod-prisma-types/pull/135 fixes these issues, but may not be taking all use cases into account.

jaschaephraim avatar Apr 24 '23 19:04 jaschaephraim

@jaschaephraim thanks for the report. I looked into it a bit and I'm not quite sure why prisma did it this way. Because when inspecting the prisma type JsonValue it looks like this:

  export type JsonValue = string | number | boolean | JsonObject | JsonArray | null

so according to this type the json field can be nullable even if it is marked as non nullable. So if we have a model like this

model JsonModel {
  id      Int   @id @default(autoincrement())
  json    Json
  jsonOpt Json?
}

both, the json and jsonOpt field could be null, which is, at least to my understanding, not what the schema tells me.

interestingly the generated prisma type is

export type JsonModel = {
  id: number
  json: Prisma.JsonValue // is nullable - should not be
  jsonOpt: Prisma.JsonValue | null // added "null" where null is already included in "JsonValue"
}

so the following would be ok for prisma even if, in my opinon, it should not:

  const a: JsonModelPrisma = { id: 1, json: {}, jsonOpt: null }; // is ok - as expected from looking at the schema
  const a: JsonModelPrisma = { id: 1, json: null, jsonOpt: null }; // is also ok - not as expected from looking at the schema

I must admit, that I do not have much experience using json fields with prisma - so maybe you have some more insight if this is all ok what they did or not.

problem with generator

So the easiest way to fix this in the generator would be to just add null to the JsonValue which then sould be used instead of InputJsonValue (like in your PR) and NullableJsonValue. This would exactly replicate the prisma type and fix the typescript issue but it would prevent an acutall null to be converted to DbNull or JsonNull which is achieved via transformJsonNull.

I'm actually not sure now why i did it this way in the first place but I think I wanted to be able to validate DbNull or JsonNull via trpc or it was a bug/feature someone reported.

I'll also post an issue on the prisma page to get some insight why they did it this way. I'll do some further experimenting and then address your PR.

chrishoermann avatar May 03 '23 20:05 chrishoermann

I found an issue in the prisma repo that is about the behaviour I mentioned and it seems that json fields can contain null when beeing retrieved from the database. So the type above is valid and I must rethink the way I implemented it - because actually my transformJsonNull should be used in create or update methods (I think).

chrishoermann avatar May 03 '23 20:05 chrishoermann

I found an issue in the prisma repo that is about the behaviour I mentioned and it seems that json fields can contain null when beeing retrieved from the database. So the type above is valid and I must rethink the way I implemented it - because actually my transformJsonNull should be used in create or update methods (I think).

Have you found a solution for create and update ? I have the same problem. On create, I don't want JsonNullValueInputSchema.

If I try to override with this:

prompt  Json /// @zod.custom.use(z.record(z.nativeEnum(PrompLanguages), z.string()))

I have this result:

prompt: z.union([z.lazy(() => JsonNullValueInputSchema), z.lazy(() => z.record(z.nativeEnum(PrompLanguages), z.string()))]),

I would like:

prompt: z.record(z.nativeEnum(PrompLanguages), z.string())),

Jeromearsene avatar May 31 '23 15:05 Jeromearsene

Any news on this? Or is there any workaround now?

cimchd avatar Oct 16 '23 05:10 cimchd

@jaschaephraim in v3.0.0 I revamped the json implementation so it exactly matches the types generated by prisma.

for a given model like

model JsonModel {
  id Int @id @default(autoincrement())
  json    Json
  jsonOpt Json?
}

the following schemas are created

// PRISMA TYPES
// ------------------------------------------------------

export type JsonValue = string | number | boolean | JsonObject | JsonArray | null

export type JsonModel = $Result.DefaultSelection<Prisma.$JsonModelPayload>;

export type $JsonModelPayload<
  ExtArgs extends $Extensions.InternalArgs = $Extensions.DefaultArgs,
> = {
  name: 'JsonModel';
  objects: {};
  scalars: $Extensions.GetPayloadResult<
    {
      id: number;
      json: Prisma.JsonValue;
      jsonOpt: Prisma.JsonValue | null;
    },
    ExtArgs['result']['jsonModel']
  >;
  composites: {};
};

// SCHEMA
// ------------------------------------------------------

export const JsonValueSchema: z.ZodType<Prisma.JsonValue> = z.lazy(() =>
  z.union([
    z.string(),
    z.number(),
    z.boolean(),
    z.literal(null),
    z.record(z.lazy(() => JsonValueSchema.optional())),
    z.array(z.lazy(() => JsonValueSchema)),
  ]),
);

export const JsonModelSchema = z.object({
  id: z.number().int(),
  json: JsonValueSchema.nullable(), // just a nullable json input like in prismas type
  jsonOpt: JsonValueSchema,
});

// INPUT TYPES
// ------------------------------------------------------

export const JsonNullValueInput: {
  JsonNull: typeof JsonNull;
};

export type JsonNullValueInput =
  (typeof JsonNullValueInput)[keyof typeof JsonNullValueInput];

export const NullableJsonNullValueInput: {
  DbNull: typeof DbNull;
  JsonNull: typeof JsonNull;
};

export type NullableJsonNullValueInput =
  (typeof NullableJsonNullValueInput)[keyof typeof NullableJsonNullValueInput];

export type JsonModelCreateInput = {
  json: JsonNullValueInput | InputJsonValue;
  jsonOpt?: NullableJsonNullValueInput | InputJsonValue;
};

// EXAMPLE INPUT SCHEMA
// ------------------------------------------------------

export const JsonNullValueInputSchema = z
  .enum(['JsonNull'])
  .transform((value) => (value === 'JsonNull' ? Prisma.JsonNull : value));

export const InputJsonValueSchema: z.ZodType<Prisma.InputJsonValue> = z.lazy(
  () =>
    z.union([
      z.string(),
      z.number(),
      z.boolean(),
      z.object({ toJSON: z.function(z.tuple([]), z.any()) }),
      z.record(z.lazy(() => z.union([InputJsonValueSchema, z.literal(null)]))),
      z.array(z.lazy(() => z.union([InputJsonValueSchema, z.literal(null)]))),
    ]),
);

export const JsonModelCreateInputSchema: z.ZodType<Prisma.JsonModelCreateInput> =
  z
    .object({
      json: z.union([
        z.lazy(() => JsonNullValueInputSchema),
        InputJsonValueSchema,
      ]), // complex input that can handle passed in `DbNull` of `JsonNull` strings
      jsonOpt: z
        .union([
          z.lazy(() => NullableJsonNullValueInputSchema),
          InputJsonValueSchema,
        ])
        .optional(),
    })
    .strict();

I think this should fix the problem menitoned by @jaschaephraim.

In @Jeromearsene's case I think the generated schema with the .custom.use() directive is actually valid since the field, even if it is not nullable, can accept a JsonNull value since this would be valid json. The only thing that would not be possible is to write the string "JsonValue" to the json field as typeof string. But I don't assume that this would be a real life use case. 😉

chrishoermann avatar Nov 07 '23 20:11 chrishoermann

I am seeing a similar issue but with toJSON()

  export type InputJsonValue = string | number | boolean | InputJsonObject | InputJsonArray | { toJSON(): unknown }
../../libs/shared/prisma-zod/src/lib/generated/inputTypeSchemas/InputJsonValueSchema.ts:4:14 - error TS2322: Type 'ZodLazy<ZodUnion<[ZodString, ZodNumber, ZodBoolean, ZodObject<{ toJSON: ZodFunction<ZodTuple<[], null>, ZodAny>; }, "strip", ZodTypeAny, { ...; }, { ...; }>, ZodRecord<...>, ZodArray<...>]>>' is not assignable to type 'ZodType<InputJsonValue, ZodTypeDef, InputJsonValue>'.
  Types of property '_type' are incompatible.
    Type 'string | number | boolean | any[] | Record<string, any> | { toJSON?: (...args: unknown[]) => any; }' is not assignable to type 'InputJsonValue'.
      Type '{ toJSON?: (...args: unknown[]) => any; }' is not assignable to type 'InputJsonValue'.
        Type '{ toJSON?: (...args: unknown[]) => any; }' is not assignable to type '{ toJSON(): unknown; }'.
          Property 'toJSON' is optional in type '{ toJSON?: (...args: unknown[]) => any; }' but required in type '{ toJSON(): unknown; }'.

4 export const InputJsonValueSchema: z.ZodType<Prisma.InputJsonValue> = z.lazy(() =>

jamespsterling avatar Nov 09 '23 21:11 jamespsterling

@jamespsterling for this type error to go away you have to set strictNullChecks: true in your tsconfig.json.

chrishoermann avatar Nov 10 '23 15:11 chrishoermann

@jaschaephraim in v3.0.0 I revamped the json implementation so it exactly matches the types generated by prisma.

for a given model like

model JsonModel {
  id Int @id @default(autoincrement())
  json    Json
  jsonOpt Json?
}

the following schemas are created

// PRISMA TYPES
// ------------------------------------------------------

export type JsonValue = string | number | boolean | JsonObject | JsonArray | null

export type JsonModel = $Result.DefaultSelection<Prisma.$JsonModelPayload>;

export type $JsonModelPayload<
  ExtArgs extends $Extensions.InternalArgs = $Extensions.DefaultArgs,
> = {
  name: 'JsonModel';
  objects: {};
  scalars: $Extensions.GetPayloadResult<
    {
      id: number;
      json: Prisma.JsonValue;
      jsonOpt: Prisma.JsonValue | null;
    },
    ExtArgs['result']['jsonModel']
  >;
  composites: {};
};

// SCHEMA
// ------------------------------------------------------

export const JsonValueSchema: z.ZodType<Prisma.JsonValue> = z.lazy(() =>
  z.union([
    z.string(),
    z.number(),
    z.boolean(),
    z.literal(null),
    z.record(z.lazy(() => JsonValueSchema.optional())),
    z.array(z.lazy(() => JsonValueSchema)),
  ]),
);

export const JsonModelSchema = z.object({
  id: z.number().int(),
  json: JsonValueSchema.nullable(), // just a nullable json input like in prismas type
  jsonOpt: JsonValueSchema,
});

// INPUT TYPES
// ------------------------------------------------------

export const JsonNullValueInput: {
  JsonNull: typeof JsonNull;
};

export type JsonNullValueInput =
  (typeof JsonNullValueInput)[keyof typeof JsonNullValueInput];

export const NullableJsonNullValueInput: {
  DbNull: typeof DbNull;
  JsonNull: typeof JsonNull;
};

export type NullableJsonNullValueInput =
  (typeof NullableJsonNullValueInput)[keyof typeof NullableJsonNullValueInput];

export type JsonModelCreateInput = {
  json: JsonNullValueInput | InputJsonValue;
  jsonOpt?: NullableJsonNullValueInput | InputJsonValue;
};

// EXAMPLE INPUT SCHEMA
// ------------------------------------------------------

export const JsonNullValueInputSchema = z
  .enum(['JsonNull'])
  .transform((value) => (value === 'JsonNull' ? Prisma.JsonNull : value));

export const InputJsonValueSchema: z.ZodType<Prisma.InputJsonValue> = z.lazy(
  () =>
    z.union([
      z.string(),
      z.number(),
      z.boolean(),
      z.object({ toJSON: z.function(z.tuple([]), z.any()) }),
      z.record(z.lazy(() => z.union([InputJsonValueSchema, z.literal(null)]))),
      z.array(z.lazy(() => z.union([InputJsonValueSchema, z.literal(null)]))),
    ]),
);

export const JsonModelCreateInputSchema: z.ZodType<Prisma.JsonModelCreateInput> =
  z
    .object({
      json: z.union([
        z.lazy(() => JsonNullValueInputSchema),
        InputJsonValueSchema,
      ]), // complex input that can handle passed in `DbNull` of `JsonNull` strings
      jsonOpt: z
        .union([
          z.lazy(() => NullableJsonNullValueInputSchema),
          InputJsonValueSchema,
        ])
        .optional(),
    })
    .strict();

I think this should fix the problem menitoned by @jaschaephraim.

In @Jeromearsene's case I think the generated schema with the .custom.use() directive is actually valid since the field, even if it is not nullable, can accept a JsonNull value since this would be valid json. The only thing that would not be possible is to write the string "JsonValue" to the json field as typeof string. But I don't assume that this would be a real life use case. 😉

Would be useful to have a way of disabling union on json field when using custom.use to force using just the passed zod object. We're defining specific types using another prisma extension.

bostjanpisler avatar Oct 29 '24 20:10 bostjanpisler