Documentation
Documentation
Introduction

Getting Started

Getting StartedInstallationQuick StartProject Structure

Architecture

Architecture OverviewTech StacktRPC MiddlewareDesign Principles

Patterns

Code Patterns & ConventionsFeature ModulesError HandlingType Safety

Database

DatabaseSchema DefinitionDatabase OperationsMigrationsCaching

API

tRPCProceduresRouterstRPC Proxy Setup
APIsOpenAPIREST Endpoints

Auth & Access

AuthenticationConfigurationOAuth ProvidersRolesSession Management
AuthorizationUser RolesPermissions

Routing & i18n

RoutingDeclarative RoutingNavigation
InternationalizationTranslationsLocale Routing

Components & UI

ComponentsButtonsFormsNavigationDialogs
StylesTailwind CSSThemingTypography

Storage

StorageConfigurationUsageBuckets

Configuration

ConfigurationEnvironment VariablesFeature Flags

Templates

Template GuidesCreate New FeatureCreate New PageCreate Database TableCreate tRPC RouterAdd Translations

Development

DevelopmentCommandsAI AgentsBest Practices

Type Safety

Type safety conventions and Zod schema patterns

Overview

This project embraces end-to-end type safety from database to UI. TypeScript, Zod, and tRPC work together to catch errors at compile-time and provide excellent developer experience with autocomplete and inline documentation.

Type vs Interface

Always Use type

Rule: Use type instead of interface for all type definitions.

Why?

  • Consistency across the codebase
  • Better for unions and intersections
  • More flexible (can represent primitives, unions, tuples)
  • Works better with Zod's z.infer<>

Examples

// ✅ Good - Use type
export type User = {
  id: string;
  name: string;
  email: string;
};

export type UserRole = "admin" | "user" | "guest";

export type ApiResponse<T> = {
  success: boolean;
  data: T;
};

// ❌ Bad - Don't use interface
interface User {
  id: string;
  name: string;
  email: string;
}

When Interface is Acceptable

Only use interface when:

  1. Extending third-party library types
  2. Declaration merging is explicitly needed (rare)
// Extending library types
interface CustomNextPageProps extends NextPageProps {
  customProp: string;
}

Zod Schemas

Schema Structure

Every feature should define Zod schemas for validation and type inference:

import { z } from "zod";
import { baseTableSchema } from "@/db/enums";

// Base schema with all database fields
export const userSchema = baseTableSchema.extend({
  name: z.string().min(2).max(100),
  email: z.string().email(),
  age: z.number().int().min(0).max(150).optional(),
});

// Infer TypeScript type from schema
export type User = z.infer<typeof userSchema>;

// Upsert schema (create/update) - omits auto-generated fields
export const userUpsertSchema = userSchema
  .omit({ id: true, createdAt: true, updatedAt: true })
  .partial({ age: true });

export type UserUpsert = z.infer<typeof userUpsertSchema>;

Schema Transformations

Zod provides powerful transformations:

// Pick specific fields
const userPublicSchema = userSchema.pick({ 
  id: true, 
  name: true 
});

// Omit sensitive fields
const userSafeSchema = userSchema.omit({ 
  password: true, 
  apiKey: true 
});

// Make all fields optional
const userPartialSchema = userSchema.partial();

// Make specific fields optional
const userWithOptionalEmail = userSchema.partial({ email: true });

// Make all fields required
const userRequiredSchema = userSchema.required();

Critical: .refine() Placement

The Problem

Zod's .refine() breaks schema transformations like .omit(), .pick(), and .partial().

GitHub Issue: colinhacks/zod#5192

The Rule

Always apply .refine() on the final schema (upsert/create/update), never on base schemas.

Incorrect Pattern ❌

// ❌ DON'T: Refine on base schema
export const organizationSchema = baseTableSchema.extend({
  name: z.string().min(3).max(100),
  permissions: z
    .record(z.string(), z.array(z.string()))
    .refine(
      (val) => validatePermissions(val),
      { message: "Invalid permissions" }
    ),
});

// This FAILS or loses the refinement!
export const organizationUpsertSchema = organizationSchema.omit({ 
  id: true,
  createdAt: true,
  updatedAt: true,
});

Correct Pattern ✅

// ✅ DO: Keep base schema clean
export const organizationSchema = baseTableSchema.extend({
  name: z.string().min(3).max(100),
  permissions: z.record(z.string(), z.array(z.string())),
});

export type Organization = z.infer<typeof organizationSchema>;

// ✅ DO: Apply refinements at the upsert level
export const organizationUpsertSchema = organizationSchema
  .omit({ id: true, createdAt: true, updatedAt: true })
  .partial({ permissions: true })
  .refine(
    (val) => !val.permissions || validatePermissions(val.permissions),
    {
      params: { i18n: "invalid_permissions" },
      path: ["permissions"],
    }
  );

export type OrganizationUpsert = z.infer<typeof organizationUpsertSchema>;

Validation Function Example

function validatePermissions(
  permissions: Record<string, string[]>
): boolean {
  const validFeatures = ["users", "billing", "settings"];
  
  for (const [feature, actions] of Object.entries(permissions)) {
    // Check if feature is valid
    if (!validFeatures.includes(feature)) return false;
    
    // Check if actions are valid
    const validActions = ["read", "write", "delete"];
    for (const action of actions) {
      if (!validActions.includes(action)) return false;
    }
  }
  
  return true;
}

Custom Error Messages

Use .message() for Simple Errors

For simple validation errors, use .message():

export const apiKeySchema = z.object({
  name: z
    .string()
    .min(3, { message: "Name must be at least 3 characters" })
    .max(50, { message: "Name must not exceed 50 characters" }),
  prefix: z
    .string()
    .regex(/^[a-z0-9_]+$/, { 
      message: "Prefix can only contain lowercase letters, numbers, and underscores" 
    }),
});

Use params.i18n for Translatable Errors

For errors that need translation, use params.i18n in .refine():

export const userUpsertSchema = userSchema
  .omit({ id: true, createdAt: true, updatedAt: true })
  .refine(
    (val) => val.password === val.confirmPassword,
    {
      params: { i18n: "passwords_do_not_match" },
      path: ["confirmPassword"],
    }
  )
  .refine(
    (val) => !isWeakPassword(val.password),
    {
      params: { i18n: "password_too_weak" },
      path: ["password"],
    }
  );

Translation file (src/messages/dictionaries/en/validation.json):

{
  "passwords_do_not_match": "Passwords do not match",
  "password_too_weak": "Password is too weak. Use at least 8 characters with letters, numbers, and symbols."
}

Prefer i18n Over Hardcoded Messages

// ❌ Bad - Hardcoded message
.refine(
  (val) => val.age >= 18,
  { message: "You must be at least 18 years old" }
)

// ✅ Good - Translatable message
.refine(
  (val) => val.age >= 18,
  { params: { i18n: "age_requirement" } }
)

Type Inference

Infer from Runtime Values

Always infer types from runtime values when possible:

// ✅ Good - Single source of truth
export const userRoles = ["admin", "user", "guest"] as const;
export type UserRole = typeof userRoles[number];
//                     ^? "admin" | "user" | "guest"

// ❌ Bad - Duplicated definition
export type UserRole = "admin" | "user" | "guest";
export const userRoles: UserRole[] = ["admin", "user", "guest"];

Infer from Zod Schemas

// ✅ Good - Type inferred from schema
export const userSchema = z.object({
  id: z.string().uuid(),
  name: z.string(),
  role: z.enum(["admin", "user", "guest"]),
});

export type User = z.infer<typeof userSchema>;

// ❌ Bad - Duplicated types
export type User = {
  id: string;
  name: string;
  role: "admin" | "user" | "guest";
};

export const userSchema = z.object({
  id: z.string().uuid(),
  name: z.string(),
  role: z.enum(["admin", "user", "guest"]),
});

Infer from Drizzle Tables

import { pgTable, text, timestamp } from "drizzle-orm/pg-core";

// Define table
export const users = pgTable("users", {
  id: text("id").primaryKey(),
  name: text("name").notNull(),
  email: text("email").notNull().unique(),
  createdAt: timestamp("created_at").defaultNow().notNull(),
});

// ✅ Infer types from table
export type User = typeof users.$inferSelect;
export type NewUser = typeof users.$inferInsert;

TypeScript Strict Mode

Required tsconfig.json Settings

Location: tsconfig.json

{
  "compilerOptions": {
    "strict": true,
    "strictNullChecks": true,
    "noImplicitAny": true,
    "noUnusedLocals": true,
    "noUnusedParameters": true,
    "noImplicitReturns": true,
    "noFallthroughCasesInSwitch": true
  }
}

Handle Nullable Values

// ❌ Bad - May be null/undefined
function greet(user: User) {
  return `Hello, ${user.name}`;
}

// ✅ Good - Handle null/undefined
function greet(user: User) {
  return `Hello, ${user.name ?? "Guest"}`;
}

// ✅ Good - Type narrowing
function greet(user: User) {
  if (!user.name) {
    return "Hello, Guest";
  }
  return `Hello, ${user.name}`;
}

Avoid any

// ❌ Bad - Loses type safety
function handleError(error: any) {
  console.error(error.message);
}

// ✅ Good - Use unknown and type guards
function handleError(error: unknown) {
  if (error instanceof Error) {
    console.error(error.message);
  } else {
    console.error("Unknown error");
  }
}

tRPC Type Safety

End-to-End Type Safety

tRPC provides type safety from server to client without code generation:

Server (src/trpc/procedures/routers/users.ts):

export const userRouter = createTRPCRouter({
  getById: authProcedure
    .input(z.object({ id: z.string().uuid() }))
    .query(async ({ ctx, input }) => {
      const user = await ctx.db.users.getById(input.id);
      return {
        success: true,
        payload: user,
      };
    }),
  
  update: authProcedure
    .input(userUpsertSchema)
    .mutation(async ({ ctx, input }) => {
      const updated = await ctx.db.users.update(input);
      return {
        success: true,
        message: ctx.t("toasts.success.saved"),
        payload: updated,
      };
    }),
});

Client (anywhere in the app):

"use client";

import { api } from "@/trpc/client";

export function UserProfile({ userId }: { userId: string }) {
  // ✅ Full type inference - no code generation needed
  const { data } = api.users.getById.useQuery({ id: userId });
  //    ^? { success: boolean; payload: User | null; }
  
  const updateMutation = api.users.update.useMutation();
  
  const handleUpdate = (userData: UserUpsert) => {
    updateMutation.mutate(userData);
    //                    ^? Type-checked against userUpsertSchema
  };
  
  if (!data?.payload) return <div>Loading...</div>;
  
  return (
    <div>
      <h1>{data.payload.name}</h1>
      {/* ✅ TypeScript knows exact shape of data.payload */}
    </div>
  );
}

Input Validation

tRPC automatically validates input against Zod schemas:

// Server
export const apiKeyRouter = createTRPCRouter({
  create: authProcedure
    .input(apiKeyUpsertSchema) // ✅ Validated before handler runs
    .mutation(async ({ ctx, input }) => {
      // input is guaranteed to match apiKeyUpsertSchema
      return ctx.db.apiKeys.create(input);
    }),
});

// Client
const mutation = api.apiKey.create.useMutation();

// ✅ Type-checked
mutation.mutate({
  name: "My API Key",
  prefix: "pk",
  permissions: { users: ["read", "write"] },
});

// ❌ TypeScript error
mutation.mutate({
  name: 123, // Type error: expected string
  invalidField: true, // Type error: property doesn't exist
});

Common Patterns

Conditional Types

export type ApiResponse<T> = {
  success: boolean;
  message: string;
  payload: T extends void ? never : T;
};

// Usage
type UserResponse = ApiResponse<User>;
//   ^? { success: boolean; message: string; payload: User; }

type VoidResponse = ApiResponse<void>;
//   ^? { success: boolean; message: string; }

Discriminated Unions

export type Result<T, E> =
  | { success: true; data: T }
  | { success: false; error: E };

function handleResult<T, E>(result: Result<T, E>) {
  if (result.success) {
    // TypeScript knows result.data exists
    console.log(result.data);
  } else {
    // TypeScript knows result.error exists
    console.error(result.error);
  }
}

Type Guards

export function isUser(value: unknown): value is User {
  return (
    typeof value === "object" &&
    value !== null &&
    "id" in value &&
    "name" in value &&
    "email" in value
  );
}

// Usage
function greet(data: unknown) {
  if (isUser(data)) {
    // TypeScript knows data is User
    return `Hello, ${data.name}`;
  }
  return "Hello, Guest";
}

Utility Types

// Partial - all fields optional
type PartialUser = Partial<User>;

// Required - all fields required
type RequiredUser = Required<User>;

// Pick - select specific fields
type UserPublic = Pick<User, "id" | "name">;

// Omit - exclude specific fields
type UserSafe = Omit<User, "password" | "apiKey">;

// Record - create object type
type UserRoles = Record<string, "admin" | "user" | "guest">;

// Extract - extract from union
type AdminOrUser = Extract<UserRole, "admin" | "user">;

// Exclude - remove from union
type NonGuestRoles = Exclude<UserRole, "guest">;

Best Practices

Do's ✅

  1. Use type instead of interface - Consistency and flexibility
  2. Infer types from runtime values - Single source of truth
  3. Apply .refine() on final schemas - Avoid transformation issues
  4. Use params.i18n for errors - Support internationalization
  5. Enable TypeScript strict mode - Catch more errors at compile-time
  6. Leverage tRPC for API types - No code generation needed
  7. Use type guards for unknown values - Safe type narrowing
  8. Document complex types with JSDoc - Better developer experience

Don'ts ❌

  1. Don't use interface - Unless extending third-party types
  2. Don't duplicate type definitions - Infer from single source
  3. Don't use .refine() on base schemas - Breaks transformations
  4. Don't hardcode error messages - Use translatable keys
  5. Don't use any - Use unknown and type guards
  6. Don't ignore TypeScript errors - Fix them, don't suppress
  7. Don't manually type API responses - Let tRPC infer
  8. Don't skip Zod validation - Always validate external input

Real-World Examples

Example 1: API Key Schema

Location: src/features/api-keys/schema.ts

import { z } from "zod";
import { baseTableSchema } from "@/db/enums";

// Constants
export const MIN_API_KEY_NAME = 3;
export const MAX_API_KEY_NAME = 50;

// Base schema
export const apiKeySchema = baseTableSchema.extend({
  name: z.string().min(MIN_API_KEY_NAME).max(MAX_API_KEY_NAME).nullable(),
  prefix: z.string().min(2).max(20).nullable(),
  key: z.string(),
  userId: z.uuid(),
  permissions: z.record(z.string(), z.array(z.string())).optional(),
  expiresAt: z.date().nullable(),
});

export type ApiKey = z.infer<typeof apiKeySchema>;

// Upsert schema with refinement
export const apiKeyUpsertSchema = apiKeySchema
  .omit({ id: true, createdAt: true, updatedAt: true, key: true })
  .partial({ userId: true, expiresAt: true })
  .refine(
    (val) => !val.permissions || validatePermissions(val.permissions),
    {
      params: { i18n: "invalid_permissions" },
      path: ["permissions"],
    }
  );

export type ApiKeyUpsert = z.infer<typeof apiKeyUpsertSchema>;

// Validation helper
function validatePermissions(
  permissions: Record<string, string[]>
): boolean {
  const validFeatures = ["feature-1", "feature-2"];
  const validSubfeatures: Record<string, string[]> = {
    "feature-1": ["subfeature-A", "subfeature-B"],
    "feature-2": ["subfeature-C"],
  };
  
  for (const [feature, subfeatures] of Object.entries(permissions)) {
    if (!validFeatures.includes(feature)) return false;
    
    const allowed = validSubfeatures[feature];
    if (!allowed) return false;
    
    for (const sub of subfeatures) {
      if (!allowed.includes(sub)) return false;
    }
  }
  
  return true;
}

Example 2: Organization Schema

import { z } from "zod";
import { baseTableSchema } from "@/db/enums";

// Constants
export const MIN_ORG_NAME = 2;
export const MAX_ORG_NAME = 100;

// Enums
export const organizationRoles = ["owner", "admin", "member"] as const;
export type OrganizationRole = typeof organizationRoles[number];

// Base schema
export const organizationSchema = baseTableSchema.extend({
  name: z.string().min(MIN_ORG_NAME).max(MAX_ORG_NAME),
  slug: z.string().min(2).max(50).regex(/^[a-z0-9-]+$/),
  ownerId: z.uuid(),
  settings: z.object({
    allowInvites: z.boolean(),
    requireApproval: z.boolean(),
  }).optional(),
});

export type Organization = z.infer<typeof organizationSchema>;

// Create schema (no slug - generated server-side)
export const organizationCreateSchema = organizationSchema
  .omit({ id: true, createdAt: true, updatedAt: true, slug: true, ownerId: true })
  .refine(
    (val) => !isReservedSlug(generateSlug(val.name)),
    {
      params: { i18n: "reserved_organization_name" },
      path: ["name"],
    }
  );

export type OrganizationCreate = z.infer<typeof organizationCreateSchema>;

// Update schema
export const organizationUpdateSchema = organizationSchema
  .omit({ createdAt: true, updatedAt: true, ownerId: true })
  .partial({ slug: true, settings: true });

export type OrganizationUpdate = z.infer<typeof organizationUpdateSchema>;

Next Steps

  • Apply patterns to features: Feature Modules
  • Handle errors properly: Error Handling
  • Explore tRPC setup: tRPC Documentation
  • Create a new feature: New Feature Template

On this page

Overview
Type vs Interface
Always Use type
Examples
When Interface is Acceptable
Zod Schemas
Schema Structure
Schema Transformations
Critical: .refine() Placement
The Problem
The Rule
Incorrect Pattern ❌
Correct Pattern ✅
Validation Function Example
Custom Error Messages
Use .message() for Simple Errors
Use params.i18n for Translatable Errors
Prefer i18n Over Hardcoded Messages
Type Inference
Infer from Runtime Values
Infer from Zod Schemas
Infer from Drizzle Tables
TypeScript Strict Mode
Required tsconfig.json Settings
Handle Nullable Values
Avoid any
tRPC Type Safety
End-to-End Type Safety
Input Validation
Common Patterns
Conditional Types
Discriminated Unions
Type Guards
Utility Types
Best Practices
Do's ✅
Don'ts ❌
Real-World Examples
Example 1: API Key Schema
Example 2: Organization Schema
Next Steps