Typing Prisma Json Fields? Yes, You Can!

ymc9 - Nov 7 - - Dev Community

SQL databases provide us with many benefits, the most important of which is strong schema enforcement. Yes, you pay the cost of migration when the schema changes, but the gain is far more significant - your code is clean because it can assume all data are in correct shapes.

However, once in a while, we want to break free from such strong guarantees for valid reasons. You may have some tiny objects that you want to attach to the main entities (e.g., metadata of an image) without formalizing them into a separate table. Or you need to store records with many possible sparse fields but want to avoid creating wide tables.

Prisma's JSON type provides a generic escape hatch for such scenarios. It allows storing arbitrary data and gives you a generic JsonValue type in the query results.

// schema.prisma

model Image {
  id Int @id @default(autoincrement())
  metadata Json
}
Enter fullscreen mode Exit fullscreen mode

// TS code

type Metadata {
  width: number
  height: number
  format: string
}

const image = await prisma.image.findFirstOrThrow();
// an explicit cast into the desired type
const metadata = image.metadata as Metadata;
console.log('Image dimensions:',
    metadata.width, 'by', metadata.height);
Enter fullscreen mode Exit fullscreen mode

This is not always ideal because, in practice, many people use JSON type in a "controlled" way - only data of specific fixed shapes are stored in a field. So, regaining some of the strong typing capabilities would be very beneficial.

ZenStack's new "strongly typed JSON field" feature is designed to address this need. It allows you to define shapes of JSON data in the schema, and "fixes" PrismaClient to return data with correct types. The feature is in preview and only supports PostgreSQL for now.

Using strongly typed JSON fields

The first step is to use the new type keyword to define the shape of the JSON data in the ZModel schema (a DSL extended from Prisma schema):

// schema.zmodel

type Metadata {
  width Int
  height Int
  format String
}

model Image {
  id Int @id @default(autoincrement())
  metadata Metadata @json
}
Enter fullscreen mode Exit fullscreen mode

Types have a structure similar to models but are not mapped to database tables. They only exist for typing and validation purposes. You can not have relations to other models in types. However, you can include fields of other types to form a nested structure.

When you run zenstack generate, the compiler will transform the typed JSON fields back into the regular Prisma Json type:

// schema.prisma

model Image {
  id Int @id @default(autoincrement())
  metadata Json
}
Enter fullscreen mode Exit fullscreen mode

So, where did the Metadata type go? It's compiled into a TypeScript type declaration, which is used to type the query results when you use the ZenStack-enhanced PrismaClient:

// TS code

import { enhance } from '@zenstackhq/runtime';

const db = enhance(prisma);
const image = await db.image.findFirstOrThrow();

// image.metadata is now directly typed as 
// { width: number, height: number, format: string }
console.log('Image dimensions:',
    image.metadata.width, 'by', image.metadata.height);
Enter fullscreen mode Exit fullscreen mode

When you create or update, the input is also properly typed so you get nice auto-completion and typechecking for the payload:

// TS code

await db.image.create({
  data: {
    metadata: {
      width: 1920,
      height: '1080', // <- type error here
      format: 'jpeg'
    }
  }
});
Enter fullscreen mode Exit fullscreen mode

Straightforward, isn't it? But the feature doesn't stop here.

How about some runtime validation?

For mutations, ZenStack also validates the input data's shape at runtime by deriving a Zod schema from the type declaration. You can also add additional constraints to fields the same way you can do with models:

// schema.zmodel

type Metadata {
  width Int @gt(0) @lt(10000)
  height Int @gt(0) @lt(10000)
  format String
}
Enter fullscreen mode Exit fullscreen mode

Mutation calls violating these constraints will be rejected:

// TS code
await db.image.create({
  data: {
    metadata: {
      width: 1920,
      height: 10800, // <- runtime error here
      format: 'jpeg'
    }
  }
});
Enter fullscreen mode Exit fullscreen mode
Error calling enhanced Prisma method `image.create`: denied by policy:
image entities failed 'create' check, input failed validation:
Validation error: Number must be less than 10000 at "metadata.height"
Enter fullscreen mode Exit fullscreen mode

Is it really type-safe?

JSON fields are meant to hold arbitrary data types, so there isn't really a way to guarantee data consistency. As such, to preserve enough flexibility, ZenStack doesn't validate if the query results comply with the type declaration. This effectively means you can't trust the TypeScript typings alone if you know the column contains mixed data.

One way to mitigate the problem is to validate the data with the generated Zod schemas explicitly:

import { MetadataSchema } from '@zenstackhq/runtime/zod/models';

const image = await db.image.findFirstOrThrow();
const metadata = MetadataSchema.parse(image.metadata);
Enter fullscreen mode Exit fullscreen mode

Next steps

One area that's not addressed by this feature yet is the filtering part. The where clause still follows Prisma's Json filter format:

// find images with width greater than 102
const images = await db.image.findMany({
  where: {
    metadata: { path: ['width'], gt: 1024 }
  }
});
Enter fullscreen mode Exit fullscreen mode

We can potentially "enhance" that part to provide a typed experience like:

const images = await db.image.findMany({
  where: {
    metadata: { width: { gt: 1024 } }
  }
});
Enter fullscreen mode Exit fullscreen mode

Is it useful, or can it be confusing (as it looks the same as relation filters)? Let us know by leaving a comment below. You can also learn more about this feature in the official guide.

About ZenStack

ZenStack is a TypeScript toolkit that systematically extends Prisma ORM's power. Besides strongly typed JSON fields, it offers a set of other capabilities that may greatly simplify your full-stack development:

  • Authorization rules in schema
  • Auto RESTful API generation
  • Frontend query hooks generation
  • ...

Make sure you check it out if you're using Prisma.

. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .