Back to Blog
August 19, 202611 min read1 views

TypeScript for Full-Stack Projects: Patterns That Prevent Bugs

Next.jsTypeScriptWeb Development
JL
Written by Julius Legaspi
LinkedInGitHub
Share

TypeScript for Full-Stack Projects: Patterns That Prevent Bugs

Introduction

TypeScript has become the professional standard for JavaScript development. But simply adding types isn't enough — you need to use TypeScript's features strategically to prevent bugs.

After building several production full-stack applications with TypeScript, I've developed patterns that catch bugs at compile time rather than runtime. These patterns have saved countless hours of debugging and prevented production incidents.

Pattern 1: Strict Type Configuration

The Problem

Default TypeScript configuration is too lenient. It allows any types, implicit returns, and other unsafe patterns.

The Solution

Use a strict tsconfig.json:

{
  "compilerOptions": {
    "strict": true,
    "noImplicitAny": true,
    "strictNullChecks": true,
    "strictFunctionTypes": true,
    "strictBindCallApply": true,
    "strictPropertyInitialization": true,
    "noImplicitThis": true,
    "alwaysStrict": true,
    "noUnusedLocals": true,
    "noUnusedParameters": true,
    "noImplicitReturns": true,
    "noFallthroughCasesInSwitch": true,
    "forceConsistentCasingInFileNames": true
  }
}

Why It Matters

  • Catches null/undefined errors — Most JavaScript bugs come from accessing properties on null or undefined
  • Prevents accidental any — Forces explicit typing
  • Ensures complete coverage — No unused variables or parameters

Pattern 2: Discriminated Unions for State

The Problem

Boolean flags and multiple optional properties create impossible states:

// Bad: Multiple boolean flags
interface UserState {
  isLoading: boolean
  isError: boolean
  user: User | null
  error: Error | null
}

// This state is impossible but TypeScript allows it
const state: UserState = {
  isLoading: true,
  isError: true,
  user: null,
  error: null
}

The Solution

Use discriminated unions:

// Good: Discriminated union
type UserState =
  | { status: 'idle' }
  | { status: 'loading' }
  | { status: 'success'; user: User }
  | { status: 'error'; error: Error }

// Now impossible states are prevented
const state: UserState = { status: 'loading' }

// TypeScript knows user exists when status is 'success'
if (state.status === 'success') {
  console.log(state.user.name) // Safe!
}

Why It Matters

  • Prevents impossible states — Only valid states are allowed
  • Narrowing is automatic — TypeScript knows what properties exist based on status
  • Self-documenting — The type describes all possible states

Pattern 3: Branded Types for IDs

The Problem

String IDs are interchangeable, leading to bugs:

// Bad: All IDs are just strings
function getUser(userId: string) { /* ... */ }
function getPost(postId: string) { /* ... */ }

// This compiles but is wrong!
const user = getUser(postId) // Bug!

The Solution

Use branded types:

// Good: Branded types
type UserId = string & { readonly __brand: 'UserId' }
type PostId = string & { readonly __brand: 'PostId' }

function createUserId(id: string): UserId {
  return id as UserId
}

function createPostId(id: string): PostId {
  return id as PostId
}

function getUser(userId: UserId) { /* ... */ }
function getPost(postId: PostId) { /* ... */ }

const userId = createUserId('123')
const postId = createPostId('456')

// Now this is a compile error!
const user = getUser(postId) // Error: PostId is not assignable to UserId

Why It Matters

  • Prevents ID mix-ups — Can't pass wrong ID type to function
  • Self-documenting — Function signatures clearly show what IDs are expected
  • Compile-time safety — Catch errors before runtime

Pattern 4: Zod for Runtime Validation

The Problem

TypeScript types are erased at runtime. External data (API responses, user input) can be anything:

// Bad: No runtime validation
async function fetchUser(id: string): Promise<User> {
  const response = await fetch(`/api/users/${id}`)
  return response.json() // This might not be a User!
}

The Solution

Use Zod for runtime validation:

import { z } from 'zod'

// Define schema
const UserSchema = z.object({
  id: z.string(),
  name: z.string(),
  email: z.string().email(),
  age: z.number().positive()
})

// Derive type from schema
type User = z.infer<typeof UserSchema>

// Validate at runtime
async function fetchUser(id: string): Promise<User> {
  const response = await fetch(`/api/users/${id}`)
  const data = await response.json()
  return UserSchema.parse(data) // Throws if invalid
}

// Safe parsing with error handling
async function fetchUserSafe(id: string): Promise<User | null> {
  try {
    const response = await fetch(`/api/users/${id}`)
    const data = await response.json()
    return UserSchema.parse(data)
  } catch (error) {
    console.error('Invalid user data:', error)
    return null
  }
}

Why It Matters

  • Runtime safety — Validates data at runtime, not just compile time
  • Self-documenting — Schema describes the data structure
  • Error handling — Provides meaningful error messages

Pattern 5: Strict Function Types

The Problem

Loose function types allow bugs:

// Bad: Loose function type
function processData(callback: Function) {
  callback('data', 123)
}

// This compiles but is wrong!
processData((x, y) => {
  console.log(x.toUpperCase()) // Bug: x might not be a string
})

The Solution

Use strict function types:

// Good: Strict function type
function processData(callback: (data: string, count: number) => void) {
  callback('data', 123)
}

// Now TypeScript enforces the signature
processData((x, y) => {
  console.log(x.toUpperCase()) // Safe: x is definitely a string
})

Why It Matters

  • Type safety — Function arguments are strictly typed
  • IntelliSense — Better autocomplete and documentation
  • Refactoring safety — Changes to function signature are caught at compile time

Pattern 6: Exhaustive Switch Statements

The Problem

Switch statements can miss cases:

// Bad: No exhaustive check
type Status = 'pending' | 'success' | 'error'

function getStatusColor(status: Status): string {
  switch (status) {
    case 'pending':
      return 'yellow'
    case 'success':
      return 'green'
    // Missing 'error' case!
  }
}

The Solution

Use exhaustive checking:

// Good: Exhaustive check
type Status = 'pending' | 'success' | 'error'

function getStatusColor(status: Status): string {
  switch (status) {
    case 'pending':
      return 'yellow'
    case 'success':
      return 'green'
    case 'error':
      return 'red'
    default:
      const exhaustiveCheck: never = status
      return exhaustiveCheck
  }
}

Why It Matters

  • Covers all cases — Compiler ensures every case is handled
  • Safe refactoring — Adding new cases forces updates everywhere
  • Self-documenting — Shows all possible values

Pattern 7: Utility Types for Common Patterns

The Problem

Repetitive type definitions:

// Bad: Repetitive types
interface CreateUserRequest {
  name: string
  email: string
  password: string
}

interface UpdateUserRequest {
  name?: string
  email?: string
  password?: string
}

The Solution

Use built-in utility types:

// Good: Utility types
interface CreateUserRequest {
  name: string
  email: string
  password: string
}

type UpdateUserRequest = Partial<CreateUserRequest>

// Other useful utility types
type UserWithoutPassword = Omit<User, 'password'>
type UserPreview = Pick<User, 'id' | 'name' | 'email'>
type NullableUser = User | null

Why It Matters

  • DRY principle — Don't repeat type definitions
  • Maintainability — Changes propagate automatically
  • Clarity — Intent is clear from type names

Pattern 8: Strict API Response Types

The Problem

Loose API response types:

// Bad: Any response
async function fetchApi<T>(url: string): Promise<T> {
  const response = await fetch(url)
  return response.json()
}

// No type safety!
const user = await fetchApi<User>('/api/user')

The Solution

Strict API response types:

// Good: Strict response types
interface ApiResponse<T> {
  data: T
  success: boolean
  error?: string
}

async function fetchApi<T>(url: string): Promise<ApiResponse<T>> {
  const response = await fetch(url)
  const data = await response.json()
  
  // Validate response structure
  if (!data || typeof data.success !== 'boolean') {
    throw new Error('Invalid API response')
  }
  
  return data
}

// Usage
const response = await fetchApi<User>('/api/user')
if (response.success) {
  console.log(response.data.name) // Safe!
} else {
  console.error(response.error)
}

Why It Matters

  • Consistent responses — All APIs follow the same structure
  • Error handling — Clear error types and messages
  • Type safety — Response data is properly typed

Pattern 9: Strict Component Props

The Problem

Loose component props:

// Bad: Loose props
interface ButtonProps {
  onClick?: Function
  children?: any
  variant?: string
}

function Button({ onClick, children, variant }: ButtonProps) {
  return (
    <button onClick={onClick} className={`btn btn-${variant}`}>
      {children}
    </button>
  )
}

The Solution

Strict component props:

// Good: Strict props
interface ButtonProps {
  onClick?: () => void
  children: React.ReactNode
  variant?: 'primary' | 'secondary' | 'danger'
  disabled?: boolean
}

function Button({ onClick, children, variant = 'primary', disabled }: ButtonProps) {
  return (
    <button 
      onClick={onClick} 
      className={`btn btn-${variant}`}
      disabled={disabled}
    >
      {children}
    </button>
  )
}

Why It Matters

  • Clear API — Props are self-documenting
  • Type safety — Only valid variants are allowed
  • Better IntelliSense — Autocomplete shows available options

Pattern 10: Strict Database Queries

The Problem

Unsafe database queries:

// Bad: Unsafe query
async function getUser(id: string) {
  const result = await db.query(`SELECT * FROM users WHERE id = '${id}'`)
  return result.rows[0] // Might be undefined!
}

The Solution

Safe database queries with Prisma:

// Good: Safe query with Prisma
async function getUser(id: string): Promise<User | null> {
  const user = await prisma.user.findUnique({
    where: { id }
  })
  return user // Properly typed as User | null
}

// With validation
async function getUserOrThrow(id: string): Promise<User> {
  const user = await prisma.user.findUnique({
    where: { id }
  })
  
  if (!user) {
    throw new Error(`User ${id} not found`)
  }
  
  return user // Properly typed as User
}

Why It Matters

  • SQL injection prevention — Prisma parameterizes queries
  • Type safety — Query results are properly typed
  • Null safety — Forces handling of missing records

Real-World Example: Complete API Layer

Let me show how these patterns work together in a real API layer:

// schemas/user.ts
import { z } from 'zod'

export const CreateUserSchema = z.object({
  name: z.string().min(1),
  email: z.string().email(),
  password: z.string().min(8)
})

export const UpdateUserSchema = CreateUserSchema.partial()

export type CreateUserInput = z.infer<typeof CreateUserSchema>
export type UpdateUserInput = z.infer<typeof UpdateUserSchema>

// types/user.ts
export interface User {
  id: string
  name: string
  email: string
  createdAt: Date
  updatedAt: Date
}

export type UserWithoutPassword = Omit<User, 'password'>

// services/user.ts
import { CreateUserInput, UpdateUserInput } from '@/schemas/user'
import { User, UserWithoutPassword } from '@/types/user'

export async function createUser(input: CreateUserInput): Promise<User> {
  // Zod validation happens automatically
  const validated = CreateUserSchema.parse(input)
  
  const user = await prisma.user.create({
    data: validated
  })
  
  return user
}

export async function updateUser(
  id: string, 
  input: UpdateUserInput
): Promise<User | null> {
  const validated = UpdateUserSchema.parse(input)
  
  const user = await prisma.user.update({
    where: { id },
    data: validated
  })
  
  return user
}

export async function getUser(id: string): Promise<User | null> {
  const user = await prisma.user.findUnique({
    where: { id }
  })
  
  return user
}

export async function getUserOrThrow(id: string): Promise<User> {
  const user = await getUser(id)
  
  if (!user) {
    throw new Error(`User ${id} not found`)
  }
  
  return user
}

// api/users/route.ts
import { NextRequest, NextResponse } from 'next/server'
import { CreateUserSchema } from '@/schemas/user'
import { createUser, getUser } from '@/services/user'

export async function POST(request: NextRequest) {
  try {
    const body = await request.json()
    const user = await createUser(body)
    
    return NextResponse.json(user, { status: 201 })
  } catch (error) {
    if (error instanceof z.ZodError) {
      return NextResponse.json(
        { error: 'Validation failed', details: error.errors },
        { status: 400 }
      )
    }
    
    return NextResponse.json(
      { error: 'Internal server error' },
      { status: 500 }
    )
  }
}

export async function GET(request: NextRequest) {
  const { searchParams } = new URL(request.url)
  const id = searchParams.get('id')
  
  if (!id) {
    return NextResponse.json(
      { error: 'Missing user ID' },
      { status: 400 }
    )
  }
  
  const user = await getUser(id)
  
  if (!user) {
    return NextResponse.json(
      { error: 'User not found' },
      { status: 404 }
    )
  }
  
  return NextResponse.json(user)
}

Conclusion

TypeScript is more than just adding types — it's about using the type system strategically to prevent bugs. By using these patterns, you can catch errors at compile time, write self-documenting code, and build maintainable applications.

Key takeaways:

  1. Start with strict configuration — Enable all strict options
  2. Use discriminated unions — Prevent impossible states
  3. Brand your IDs — Prevent type mix-ups
  4. Validate at runtime — Use Zod for external data
  5. Be exhaustive — Handle all cases in switch statements

The key insight is that TypeScript's type system is a tool for preventing bugs, not just adding documentation. Use it strategically, and you'll catch errors before they reach production.


Need help implementing TypeScript patterns in your project? Contact me to discuss your architecture.

Comments (0)

Loading comments...