18 July, 20235 minute read

Drizzle’s killer feature is TypeScript

I’ve been using Drizzle as my ORM for a new side project because it supports running inside the edge runtime, and there’s one feature in particular that makes going back to Prisma difficult.

Prisma uses its own DSL for defining your database schema, whereas Drizzle defines your schema inside your TypeScript code. This is a really significant difference, and it greatly improves the developer experience. Prisma’s codegen step—and the limitations of its schema language—are a massive pain that’s only been made bearable by how nice Prisma’s migration tooling is.

Even though drizzle-kit is still in beta, it’s handled everything I’ve thrown at it so far and it also handles things like column renames far more elegantly than Prisma migrate does.

I’ll show you an example of how I’m using Drizzle’s TypeScript-defined schema to reduce boilerplate, and I’ll also overview how I’m setting up my Drizzle schema. I haven’t seen much in the way of established best practices here, so I’ve had to feel out a file layout that works well for me. Maybe you’ll like it as well.

The project setup

My project is a monorepo managed by Turborepo1 which allows me to partition my project along module boundaries. If you think you need microservices or microfrontends you’re usually wrong—you actually want modules 99% of the time.

One of the modules I have is called @project/db. All of my projects that use a database has this module, and up until recently it would always contain a Prisma schema file and associated machinery. In this project, it exports a serverful Drizzle client by default and a serverless/edge-friendly Drizzle client from @project/db/edge.

Both of these clients re-export a barrel file from schemas/. In this directory I write one file per database table. The Drizzle documentation shows an example of database relations being defined alongside tables, but I personally don’t do this and instead have a separate relations.ts file outside of the schemas/ folder. This is for two reasons:

  1. It’s hard to avoid circular imports if you’re defining relations inside your table files. You’ll inevitably get bitten by this as your schema grows.
  2. I’m using PlanetScale for my database, and Vitess—the underlying tech powering their platform—does not yet support foreign keys. I point drizzle-kit at my schemas/ folder to push schema changes, and if drizzle-kit sees relations then it generates DDL with foreign key definitions—which will fail to run on PlanetScale.

I really don’t like Drizzle’s query builder syntax, so I’m using the drizzle-kysely package which lets me use Kysely’s APIs with my Drizzle schema. I find Kysely queries a lot easier to read than Drizzle queries, and it also means I don’t need to re-export and import a bunch of cruft like eq elsewhere in my code.

The last bit worth mentioning is that I’m using drizzle-zod to turn my table definitions into Zod types. This is Drizzle’s magic sauce. I can make a change to my schema and instantly get both updated TypeScript types and runtime types because there’s no codegen step involved at all. When adding a feature I can tinker with my schema and then see what the actual code working with that altered schema might look like before committing to it.

In Prisma, any schema change requires a codegen step. I don’t mind codegen, but the problem here is that I usually end up needing to restart my TypeScript LSP for the newly generated Prisma types to actually get picked up by my editor. It’s a frustrating experience when you’re trying to quickly prototype a schema design.

A schema file

Here’s what a typical Drizzle schema file looks like in my project:

schemas/event.ts
Click to copy
export const event = mysqlTable('event', {  id: id('id').notNull().primaryKey(),  contentType: mysqlEnum('contentType', [/* ... */]).notNull(),  data: text('data').notNull(),  status: mysqlEnum('status', ['pending', 'success', 'error']).notNull().default('pending'),  // ...}); export const Event = createSelectSchema(event);export type Event = z.infer<typeof Event>;

You could take this concept and go further by exporting something like an EventInserter schema using createInsertSchema, but I personally prefer having only the one zod type that I then refine elsewhere in the code:

routers/events.ts
Click to copy
export const eventsRouter = router({  create: protectedProcedure.input(Event.pick({    data: true,  })).mutation(async (opts) => {    // ...  }),});

I prefer this single-schema approach for two reasons:

  1. I don’t think the model layer should know about the inputs your API layer is receiving. You might have multiple EventInserter types defined for different endpoints, and I think it’s better to colocate those types with the actual endpoint definition. The more generic Event type, in contrast, is useful almost everywhere for the purpose of writing helper functions that operate on your models.
  2. I really like using Drizzle’s customType function to define type-safe JSON columns. The default behavior in both Prisma and Drizzle is for JSON columns to receive a type that’s dangerously close to any, but Drizzle lets you specify something more specific using customType. The downside of customType right now is that the create*Schema functions in drizzle-zod bail out when they encounter a custom column type, and fall back to putting z.any() in your schema. You can fix this using the second parameter to “refine” your types, but you of course need to do this N times if you’re using multiple create*Schema functions.

The end result of this is that I get end-to-end typesafety from my database to my API to my frontend with zero codegen, and zero need to write any manual Zod types. In Prisma you always have a codegen step, and unless you’re using the (unofficial) zod-prisma generator you‘ll need to write manual Zod types.

Other Drizzle niceties

This isn’t even getting into some other gripes with how Prisma handles types. Enums get emitted as actual TypeScript enums, which are harder to work with than string union types. If you want to return an EventStatus Prisma enum from your TRPC procedure means either importing that enum from @prisma/client in your frontend code—which feels icky to me—or mapping the enum back to a string literal:

routers/events.ts
Click to copy
import { EventStatus as DBEventStatus } from '@prisma/client'; // defined in some isomorphic types or feature packagetype EventStatus = 'pending' | 'success' | 'error'; const EVENT_STATUS_MAP: Record<DBEventStatus, EventStatus> = {  [DBEventStatus.Pending]: 'pending',  // ...}; // convert backend-only Prisma enum to frontend-friendly union typeconst fromDBStatus = (status: DBEventStatus) => EVENT_STATUS_MAP[status]; // you'll need this if any of your procedures take in a statusconst toDBStstus = (status: EventStatus) => ...

In Drizzle, because everything gets defined in TypeScript code you don’t end up with this problem. You can define a z.enum inside your @company/types or @company/feat-events package and use that to define your schema:

schemas/event.ts
Click to copy
import { EventStatus } from '@company/types'; export const event = mysqlTable('event', {  // ...  status: mysqlEnum('status', EventStatus.options),});

You can make a change to that Zod type and not need to touch any boilerplate. You also avoid importing your database package from within the frontend—which I consider a win.

Conclusion

Drizzle isn’t perfect, but I think it’s a big step up from Prisma. I’m glad we have a high quality alternative now, because competition will ultimately be beneficial for the ecosystem in general.

If you’re going to use Drizzle, you should be forewarned that it doesn’t seem to scale well. My project is small and only has a few tables so far, but others are reporting huge slowdowns occurring with as little as 20 tables (#1, #2). Drizzle pushes the type system to its limits, and it will be interesting to see how this situation evolves over time.

Prisma avoids this issue because so much of the Prisma client is static thanks to the codegen step—there’s not a whole lot of inference happening inside @prisma/client. It would be remiss to not mention this drawback of Drizzle, as it’s a significant one.

Tread carefully if you think your project will require 20+ tables—but otherwise it’s the first ORM that I like using more than Prisma.



  1. No real reason for using Turbo. I already have experience using Nx for managing monorepos, and just wanted to try out Turbo. It seems alright so far.

Don't want to miss out on new posts?

Join 100+ fellow engineers who subscribe for software insights, technical deep-dives, and valuable advice.

Get in touch 👋

If you're working on an innovative web or AI software product, then I'd love to hear about it. If we both see value in working together, we can move forward. And if not—we both had a nice chat and have a new connection.
Send me an email at hello@sophiabits.com