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:
- Extending third-party library types
- 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 ✅
- Use
typeinstead ofinterface- Consistency and flexibility - Infer types from runtime values - Single source of truth
- Apply
.refine()on final schemas - Avoid transformation issues - Use
params.i18nfor errors - Support internationalization - Enable TypeScript strict mode - Catch more errors at compile-time
- Leverage tRPC for API types - No code generation needed
- Use type guards for unknown values - Safe type narrowing
- Document complex types with JSDoc - Better developer experience
Don'ts ❌
- Don't use
interface- Unless extending third-party types - Don't duplicate type definitions - Infer from single source
- Don't use
.refine()on base schemas - Breaks transformations - Don't hardcode error messages - Use translatable keys
- Don't use
any- Useunknownand type guards - Don't ignore TypeScript errors - Fix them, don't suppress
- Don't manually type API responses - Let tRPC infer
- 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