RS
Snippets
HomeSnippetsQuery BuilderDocumentation
Welcome to Snippets
Semantic Types and Drizzle ORM Architecture
Total Posts

0

Total Commits

0

(v1: 0, v2: 0)
Total Deployments

0

Latest commit:Unable to fetch commit info
9/17/2025
Latest deployment:
pending
9/17/2025
v2
Started 9/17/2025

Built by Remco Stoeten with a little ❤️

Articles

Semantic Types and Drizzle ORM Architecture

Learn how to create semantic types in TypeScript and integrate them with Drizzle ORM for better type safety and developer experience in Next.js applications.

At Brainstud, where I worked on a Next.js + Laravel e-learning platform for Dutch students, I learned a typing strategy that's simple yet incredibly effective. This approach has become my go-to for improving code clarity and speeding up developer onboarding.

The problem with basic TypeScript types like string, boolean, and number is that they don't tell the full story. When you see a string parameter, you don't know if it's an email, a UUID, a timestamp, or just any old text. This becomes especially problematic as teams grow and codebases scale.

Instead of using generic types like:

type ID = string | number

We can create semantic types that clearly express intent:

export type UUID = string
export type Time = string

This simple change makes code self-documenting and prevents common mistakes.

Semantic Types

The beauty of this approach is its simplicity. We're not creating complex type hierarchies or fancy abstractions—just clear, semantic names for common patterns.

Every database record typically needs an ID and timestamps. Instead of repeating this pattern everywhere, we can create reusable base types:

import type { Time, UUID } from "./base"
    
export type Timestamps = {
  createdAt: Time
  updatedAt: Time
  deletedAt?: Time
}
 
export type BaseEntity = {
    id: UUID
} & Timestamps

Use them in your entities:

export type Post = BaseEntity & {
    title: string
    content: string
    authorId: UUID
}

This approach provides better type safety and consistency across your application.

Drizzle ORM Schema Design

This semantic typing approach isn't tied to any specific framework—I've used it with Hono.js, Next.js server functions, and Drizzle ORM. The real magic happens when you apply this pattern to your database schemas.

Instead of manually defining the same timestamp and ID fields in every table, we can extract them into reusable helpers:

src/db/schema-helpers/base.ts
import { uuid, timestamp } from "drizzle-orm/pg-core"
import type { UUID, Time } from "@/api/types/base"
 
export function timestampsSchema(opts?: { withDeleted?: boolean }) {
  return {
    createdAt: timestamp("created_at", { withTimezone: true })
      .notNull()
      .defaultNow()
      .$type<Time>(),
    updatedAt: timestamp("updated_at", { withTimezone: true })
      .notNull()
      .defaultNow()
      .$type<Time>(),
    ...(opts?.withDeleted
      ? { deletedAt: timestamp("deleted_at", { withTimezone: true }).$type<Time>() }
      : {}),
  }
}
 
export function baseEntitySchema(opts?: { withDeleted?: boolean }) {
  return {
    id: uuid("id").primaryKey().defaultRandom().$type<UUID>(),
    ...timestampsSchema(opts),
  }
}

Use them in your table definitions:

src/features/blog/api/schemas/posts.schema.ts
import { pgTable, text, boolean } from "drizzle-orm/pg-core"
import { baseEntitySchema } from "./schema-helpers/base"
 
export const posts = pgTable("posts", {
  ...baseEntitySchema({ withDeleted: true }),
  content: text("content").notNull(),
  published: boolean("published").notNull().default(false),
})

Server Functions and Actions

With our semantic types and schema helpers in place, we can now use them throughout our application. The type safety flows from the database schema all the way to your API endpoints:

src/features/blog/api/posts.ts
export async function createPost(data: Post): Promise<Post> {
  const [post] = await db.insert(posts).values(data).returning()
  return post
}
 
export async function getPostsByAuthor(authorId: UUID): Promise<Post[]> {
  return await db.select().from(posts).where(eq(posts.authorId, authorId))
}

For form handling with validation:

src/actions/posts.ts
"use server"
 
import { z } from "zod"
 
const createPostSchema = z.object({
  title: z.string().min(1),
  content: z.string().min(1),
  authorId: z.string().uuid()
})
 
export async function createPostAction(formData: FormData) {
  const data = createPostSchema.parse({
    title: formData.get("title"),
    content: formData.get("content"),
    authorId: formData.get("authorId")
  })
 
  const [post] = await db.insert(posts).values(data).returning()
  return post
}

Conclusion

After using this approach across multiple projects, I've seen firsthand how semantic types transform the developer experience. New team members can understand the codebase faster, bugs related to type mismatches become rare, and the code becomes self-documenting.

The beauty is in the simplicity—we're not over-engineering anything, just giving our types meaningful names and creating reusable patterns. This approach scales beautifully from small projects to large enterprise applications.

For more advanced patterns, check out the Query Builder I've built to help generate these patterns automatically, or explore modular schema design for larger applications.

Handy MCP-servers

Previous Page

Abstractions layer

Next Page

On this page

Semantic TypesDrizzle ORM Schema DesignServer Functions and ActionsConclusion
Sep 17, 2025
4 min read
631 words