Guides

Examples & Patterns

Copy-paste patterns for the most common safeids use cases: databases, API routes, React, tRPC, and more.

1. Define all IDs in one file

Keep all branded types and generators in a single src/types/ids.ts. Import from there everywhere — no circular deps, easy to audit.

src/types/ids.ts
import { brand, createId } from 'safeids'
import { zodBrand } from 'safeids/zod'

// ── Types ────────────────────────────────────────────────────
export type UserId    = brand<string, 'UserId'>
export type OrderId   = brand<string, 'OrderId'>
export type ProductId = brand<string, 'ProductId'>
export type InvoiceId = brand<string, 'InvoiceId'>

// ── Generators ───────────────────────────────────────────────
export const newUserId    = () => createId<UserId>('usr')
export const newOrderId   = () => createId<OrderId>('ord')
export const newProductId = () => createId<ProductId>('prd')
export const newInvoiceId = () => createId<InvoiceId>('inv')

// ── Zod schemas ──────────────────────────────────────────────
export const userIdSchema    = zodBrand<UserId>('usr')
export const orderIdSchema   = zodBrand<OrderId>('ord')
export const productIdSchema = zodBrand<ProductId>('prd')
export const invoiceIdSchema = zodBrand<InvoiceId>('inv')

2. Database queries with Prisma

Branded IDs work seamlessly with Prisma. TypeScript prevents you from passing the wrong ID to the wrong query — even across deeply nested function calls.

src/services/orders.ts
import { prisma } from '@/lib/prisma'
import { fromString } from 'safeids'
import type { UserId, OrderId } from '@/types/ids'

export async function getOrdersForUser(userId: UserId) {
  return prisma.order.findMany({ where: { userId } })
}

export async function getOrder(orderId: OrderId) {
  return prisma.order.findUniqueOrThrow({ where: { id: orderId } })
}

// Usage — TypeScript enforces the correct ID at every call site
await getOrder(userId)
// ✗ Error: Argument of type 'UserId' is not assignable to 'OrderId'

3. Next.js App Router — route params

URL params arrive as plain strings. Cast them to branded types at the route boundary using fromString or a Zod schema — then the rest of the route is type-safe.

app/orders/[orderId]/page.tsx
import { fromString } from 'safeids'
import { orderIdSchema } from '@/types/ids'
import { getOrder } from '@/services/orders'

interface Props {
  params: { orderId: string }
}

export default async function OrderPage({ params }: Props) {
  // Option A: fromString (throws on invalid input)
  const orderId = fromString<OrderId>(params.orderId, 'ord')

  // Option B: Zod (structured error)
  const orderId = orderIdSchema.parse(params.orderId)

  const order = await getOrder(orderId) // orderId is OrderId ✓
  return <div>{order.id}</div>
}

4. Next.js App Router — API route

app/api/orders/route.ts
import { z } from 'zod'
import { userIdSchema, productIdSchema } from '@/types/ids'
import { createOrder } from '@/services/orders'

const Body = z.object({
  userId:     userIdSchema,
  productIds: z.array(productIdSchema).min(1),
})

export async function POST(req: Request) {
  const parsed = Body.safeParse(await req.json())
  if (!parsed.success) {
    return Response.json({ error: parsed.error.format() }, { status: 422 })
  }

  const { userId, productIds } = parsed.data
  // userId: UserId, productIds: ProductId[] — fully typed
  const order = await createOrder(userId, productIds)
  return Response.json(order, { status: 201 })
}

5. React component props

Pass branded IDs as component props to carry type safety into the UI layer.

import type { UserId, OrderId } from '@/types/ids'

interface OrderCardProps {
  orderId:  OrderId
  userId:   UserId
  onDelete: (id: OrderId) => void
}

export function OrderCard({ orderId, userId, onDelete }: OrderCardProps) {
  return (
    <div>
      <button onClick={() => onDelete(orderId)}>Delete</button>
      {/* onDelete(userId) would be a compile error here */}
    </div>
  )
}

6. tRPC procedures

server/routers/order.ts
import { z } from 'zod'
import { router, protectedProcedure } from '@/server/trpc'
import { orderIdSchema, userIdSchema } from '@/types/ids'
import { getOrder, getOrdersForUser } from '@/services/orders'

export const orderRouter = router({
  byId: protectedProcedure
    .input(z.object({ orderId: orderIdSchema }))
    .query(({ input }) => getOrder(input.orderId)),
    // input.orderId is OrderId — not string

  byUser: protectedProcedure
    .input(z.object({ userId: userIdSchema }))
    .query(({ input }) => getOrdersForUser(input.userId)),
})

7. Migrating an existing codebase

You don't have to migrate everything at once. Start with the most error-prone ID pairs, fix the type errors, and expand incrementally.

// Step 1: define the branded type alongside the old one
type UserId = brand<string, 'UserId'>

// Step 2: update the service that owns the entity
export async function getUser(id: UserId) { /* ... */ }

// Step 3: fix call sites one by one
// Old: getUser(someString)
// New: getUser(fromString<UserId>(someString, 'usr'))

// Step 4: update generators — replace crypto.randomUUID() with createId
const userId = createId<UserId>('usr')   // was: crypto.randomUUID()

// Step 5: repeat for the next entity