← Back to DevBytes

Error Handling Patterns in TypeScript

What is Error Handling in TypeScript?

Error handling in TypeScript is the practice of anticipating, catching, and responding to runtime failures using the language’s type system to enforce safety. While TypeScript eliminates many compile-time bugs through static types, runtime errors—such as network failures, invalid user input, file system exceptions, or logic mistakes—still occur. Effective error handling patterns leverage TypeScript’s types to model errors as explicit values, making failure cases visible and predictable. This approach helps developers build applications that fail gracefully, provide meaningful feedback, and are easier to debug and maintain.

Why Proper Error Handling Matters

🚀 Deploy your AI agent in 10 minutes

Managed Hermes hosting. Zero DevOps. 100M tokens/mo included.

Try it free →

Unhandled errors can crash applications, corrupt data, and create poor user experiences. In JavaScript, errors often surface as vague stack traces or silent failures, making debugging difficult. TypeScript adds a powerful layer: the ability to define precise error types and enforce their handling through the type checker. By treating errors as first-class citizens of your type system, you gain several advantages:

Investing in robust error handling patterns early transforms fragile, crash-prone code into resilient, production-ready software.

Common Error Handling Patterns

TypeScript supports several patterns for managing errors, ranging from traditional try/catch blocks to more functional approaches. Below are five widely used patterns, each with practical code examples.

1. Try/Catch with Type Narrowing

The classic try/catch mechanism remains essential, but TypeScript improves it by enabling type narrowing on caught errors. Because catch parameters default to unknown, you must check the error’s type before accessing properties. This prevents accidental assumptions and encourages deliberate handling.

class NetworkError extends Error {
  constructor(message: string, public statusCode: number) {
    super(message);
    this.name = 'NetworkError';
  }
}

class ValidationError extends Error {
  constructor(message: string, public field: string) {
    super(message);
    this.name = 'ValidationError';
  }
}

async function fetchData<T>(url: string): Promise<T> {
  try {
    const response = await fetch(url);
    if (!response.ok) {
      throw new NetworkError(
        `HTTP error ${response.status}`,
        response.status
      );
    }
    const data = await response.json();
    return data as T;
  } catch (error: unknown) {
    if (error instanceof NetworkError) {
      console.error(
        `Network error [${error.statusCode}]: ${error.message}`
      );
      // Retry logic or fallback
      throw error;
    }
    if (error instanceof ValidationError) {
      console.error(`Invalid field '${error.field}': ${error.message}`);
      // Inform the user about the specific field
      throw error;
    }
    if (error instanceof Error) {
      console.error(`General error: ${error.message}`);
      throw new Error('Fetch failed', { cause: error });
    }
    // Handle non-Error throws (should be avoided, but defensive code)
    throw new Error('Unknown error occurred');
  }
}

Key benefits: custom error classes (extending Error) allow precise filtering, and the unknown catch parameter forces type checks, eliminating unsafe access to .message or .stack.

2. The Result (Either) Pattern

Inspired by functional programming, the Result pattern replaces thrown exceptions with a return type that explicitly carries either a success value or an error. This makes error handling a normal part of the control flow, not an exceptional jump. TypeScript’s discriminated unions model this beautifully.

type Result<T, E = Error> =
  | { ok: true; value: T }
  | { ok: false; error: E };

function safeParse(json: string): Result<unknown, string> {
  try {
    const parsed = JSON.parse(json);
    return { ok: true, value: parsed };
  } catch (e) {
    const message = e instanceof Error ? e.message : String(e);
    return { ok: false, error: `Invalid JSON: ${message}` };
  }
}

// Usage
const result = safeParse('{"name": "Alice"}');
if (result.ok) {
  console.log('Parsed:', result.value);
} else {
  console.error('Parse error:', result.error);
}

// Composing Result operations
function fetchUser(id: string): Result<User, string> {
  // Simulated lookup
  const user = db.lookup(id);
  if (!user) {
    return { ok: false, error: `User ${id} not found` };
  }
  return { ok: true, value: user };
}

function processUser(data: unknown): Result<string, string> {
  const parseResult = safeParse(JSON.stringify(data));
  if (!parseResult.ok) return parseResult; // forward error

  const userResult = fetchUser(parseResult.value.id);
  if (!userResult.ok) return userResult;

  return { ok: true, value: `Hello, ${userResult.value.name}` };
}

This pattern forces callers to inspect the ok flag before accessing the value. It eliminates the need for try/catch in high-level orchestration and makes error propagation explicit.

3. Discriminated Unions for Error States

Beyond function returns, discriminated unions can represent the state of an entire operation (loading, success, error). This is especially useful in UI development and asynchronous workflows.

type LoadingState = { status: 'loading' };
type SuccessState<T> = { status: 'success'; data: T };
type ErrorState = { status: 'error'; error: Error };

type AsyncState<T> = LoadingState | SuccessState<T> | ErrorState;

function renderState<T>(state: AsyncState<T>): string {
  switch (state.status) {
    case 'loading':
      return 'Loading...';
    case 'success':
      return `Data: ${JSON.stringify(state.data)}`;
    case 'error':
      return `Error: ${state.error.message}`;
    default:
      // Exhaustiveness check – TypeScript will error if a case is missing
      const _exhaustive: never = state;
      return 'Unknown state';
  }
}

// Example usage in a React component (conceptual)
function UserProfile({ userId }: { userId: string }) {
  const [profileState, setProfileState] = useState<AsyncState<User>>({
    status: 'loading',
  });

  useEffect(() => {
    fetchProfile(userId)
      .then(data => setProfileState({ status: 'success', data }))
      .catch(error => setProfileState({ status: 'error', error }));
  }, [userId]);

  return <div>{renderState(profileState)}</div>;
}

The never exhaustiveness check ensures all possible states are handled. If a new status is added later, TypeScript will flag the default branch as an error until you handle it explicitly.

4. Async/Await and Promise Error Handling

Asynchronous code introduces additional error handling needs. TypeScript helps by typing the results of Promise.allSettled and by enforcing proper rejection handling. Here’s a robust pattern for multiple concurrent requests.

async function fetchMultiple<T>(urls: string[]): Promise<Result<T, Error>[]> {
  const promises = urls.map(async (url) => {
    try {
      const response = await fetch(url);
      if (!response.ok) {
        throw new Error(`HTTP ${response.status} for ${url}`);
      }
      const data = await response.json();
      return { ok: true as const, value: data as T };
    } catch (error) {
      const err = error instanceof Error ? error : new Error(String(error));
      return { ok: false as const, error: err };
    }
  });

  return Promise.all(promises); // all settle by construction
}

// Alternative using Promise.allSettled for partial results
async function fetchWithPartialResults(urls: string[]): Promise<void> {
  const results = await Promise.allSettled(
    urls.map(url => fetch(url).then(res => res.json()))
  );

  results.forEach((result, index) => {
    if (result.status === 'fulfilled') {
      console.log(`URL ${urls[index]} succeeded:`, result.value);
    } else {
      console.error(`URL ${urls[index]} failed:`, result.reason);
    }
  });
}

By wrapping each fetch in its own try/catch, you can return a Result type and avoid mixed arrays of fulfilled and rejected promises. Promise.allSettled gives you full visibility into each outcome without failing fast.

5. Validation with Type Guards and Assertions

Preventing errors starts with validating input at the boundaries of your system. TypeScript’s type guards and assertion functions let you narrow unknown data into trusted types and throw early if the data is malformed.

interface User {
  name: string;
  age: number;
}

function isUser(obj: unknown): obj is User {
  return (
    typeof obj === 'object' &&
    obj !== null &&
    'name' in obj &&
    'age' in obj &&
    typeof (obj as any).name === 'string' &&
    typeof (obj as any).age === 'number'
  );
}

function assertUser(obj: unknown): asserts obj is User {
  if (!isUser(obj)) {
    throw new TypeError(
      `Invalid user object: ${JSON.stringify(obj)}`
    );
  }
}

function processInput(input: unknown): void {
  assertUser(input);
  // TypeScript now knows `input` is User
  console.log(`Hello ${input.name}, you are ${input.age} years old.`);
}

// Using a validation layer that aggregates errors
type ValidationResult<T> = 
  | { valid: true; data: T }
  | { valid: false; errors: string[] };

function validateUser(input: unknown): ValidationResult<User> {
  const errors: string[] = [];
  if (typeof input !== 'object' || input === null) {
    return { valid: false, errors: ['Expected an object'] };
  }

  const obj = input as any;
  if (typeof obj.name !== 'string') errors.push('name must be a string');
  if (typeof obj.age !== 'number') errors.push('age must be a number');

  if (errors.length > 0) {
    return { valid: false, errors };
  }

  return { valid: true, data: { name: obj.name, age: obj.age } };
}

Type guards (obj is User) return boolean predicates that TypeScript uses to narrow types inside conditionals. Assertion functions (asserts obj is User) throw if the condition fails, providing a concise way to validate and narrow in a single statement.

Best Practices for TypeScript Error Handling

Conclusion

Error handling is not an afterthought—it is a core part of building reliable TypeScript applications. By leveraging the type system, you can turn unpredictable runtime failures into well-defined, manageable states. Patterns like typed try/catch, Result types, discriminated unions, and validation assertions give you the tools to express error scenarios clearly and enforce their handling at compile time. Adopting these patterns and adhering to best practices will result in code that is safer, easier to debug, and more resilient in the face of unexpected conditions. Start treating errors as values, and watch your TypeScript projects become significantly more robust.

🚀 Need a reliable AI agent for your project?

Deploy Hermes Agent in 10 minutes. Managed hosting, zero DevOps.

Get Started — $23.99/mo
← Back to all articles