M
You

I have a TypeScript codebase with a lot of async functions that use try/catch blocks everywhere. What are the best patterns to handle errors more elegantly?

AI
ChatAIGPT-4o

Great question! There are several elegant patterns for async error handling in TypeScript. Let me walk through the most effective ones:

1. Result Type Pattern

The most robust approach is using a discriminated union type:

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

async function fetchUser(id: string): Promise<Result<User>> {
  try {
    const user = await db.users.findById(id);
    return { success: true, data: user };
  } catch (err) {
    return { success: false, error: err as Error };
  }
}

// Usage — no try/catch at the call site
const result = await fetchUser('user-123');
if (result.success) {
  console.log(result.data.name);
} else {
  console.error(result.error.message);
}

2. Promise.allSettled for Parallel Operations

When running multiple async operations:

typescript
const [usersResult, postsResult] = await Promise.allSettled([
  fetchUsers(),
  fetchPosts(),
]);

if (usersResult.status === 'fulfilled') {
  processUsers(usersResult.value);
}

3. Global Error Boundary Wrapper

Create a higher-order function that wraps any async operation:

typescript
async function withErrorHandling<T>(
  fn: () => Promise<T>,
  fallback?: T
): Promise<T | undefined> {
  try {
    return await fn();
  } catch (error) {
    logger.error(error);
    return fallback;
  }
}

Which pattern fits your codebase best depends on whether you need the error details downstream or just want to suppress them gracefully.

M
You

The Result type pattern looks really clean. How would I handle this in a Next.js API route that also needs to return proper HTTP status codes?

AI
ChatAIGPT-4o

Perfect use case for the Result pattern in Next.js! Here's how to wire it up with proper HTTP semantics:

typescript
// lib/api-response.ts
import { NextResponse } from 'next/server';

type ApiError = {
  code: string;
  message: string;
  details?: unknown;
};

export function apiSuccess<T>(data: T, status = 200) {
  return NextResponse.json({ success: true, data }, { status });
}

export function apiError(error: ApiError, status = 500) {
  return NextResponse.json({ success: false, error }, { status });
}

// app/api/users/[id]/route.ts
export async function GET(
  request: Request,
  { params }: { params: { id: string } }
) {
  const result = await fetchUser(params.id);

  if (!result.success) {
    if (result.error instanceof NotFoundError) {
      return apiError({ code: 'USER_NOT_FOUND', message: 'User does not exist' }, 404);
    }
    return apiError({ code: 'INTERNAL_ERROR', message: 'Failed to fetch user' }, 500);
  }

  return apiSuccess(result.data);
}

You could also use a custom error class hierarchy to make the status code mapping automatic:

typescript
class AppError extends Error {
  constructor(
    public message: string,
    public statusCode: number,
    public code: string
  ) {
    super(message);
  }
}

class NotFoundError extends AppError {
  constructor(resource: string) {
    super(`${resource} not found`, 404, 'NOT_FOUND');
  }
}

This keeps your route handlers clean and ensures consistent error shapes across your entire API.

M
You

Could you also show me how to handle validation errors differently from runtime errors? I'm using Zod for schema validation.

AI
ChatAIGPT-4o

Absolutely — Zod integrates beautifully with this pattern. The key is distinguishing validation errors (client mistakes, 400) from runtime errors (server problems, 500):

typescript
import { z } from 'zod';

const CreateUserSchema = z.object({
  email: z.string().email('Invalid email format'),
  name: z.string().min(2, 'Name must be at least 2 characters'),
  role: z.enum(['admin', 'user', 'viewer']),
});

export async function POST(request: Request) {
  const body = await request.json();

  // Validate first — this is a client error
  const parsed = CreateUserSchema.safeParse(body);
  if (!parsed.success) {
    return apiError({
      code: 'VALIDATION_ERROR',
      message: 'Invalid request body',
      details: parsed.error.flatten().fieldErrors,
    }, 400);
  }

  // Now handle runtime errors separately
  const result = await createUser(parsed.data);
  if (!result.success) {
    return apiError({ code: 'CREATE_FAILED', message: result.error.message }, 500);
  }

  return apiSuccess(result.data, 201);
}

The flatten().fieldErrors gives you a clean object like:

json
{
  "email": ["Invalid email format"],
  "name": ["Name must be at least 2 characters"]
}

Which your frontend can map directly to form field errors. Clean separation of concerns — Zod owns validation shape, your Result type owns runtime outcomes.

ChatAI can make mistakes. Verify important information.