Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

1.8 — Error Handling: Exceptions vs. the JS Way

For .NET engineers who know: C# exception hierarchy, try/catch/finally, exception filters, IExceptionHandler / global exception middleware in ASP.NET Core You’ll learn: How JavaScript’s minimal error model works, why the JS community reached for return-value patterns, and how to build a disciplined error-handling strategy in TypeScript across NestJS and React Time: 15-20 minutes


The .NET Way (What You Already Know)

C# gives you a rich, typed exception hierarchy. System.Exception sits at the root, below it SystemException and ApplicationException, and below those hundreds of concrete types (ArgumentNullException, InvalidOperationException, DbUpdateConcurrencyException, and your own custom hierarchy).

// C# — Custom exception hierarchy
public class DomainException : ApplicationException
{
    public DomainException(string message) : base(message) { }
}

public class OrderNotFoundException : DomainException
{
    public int OrderId { get; }
    public OrderNotFoundException(int orderId)
        : base($"Order {orderId} was not found.")
    {
        OrderId = orderId;
    }
}

public class InsufficientInventoryException : DomainException
{
    public string Sku { get; }
    public int Requested { get; }
    public int Available { get; }

    public InsufficientInventoryException(string sku, int requested, int available)
        : base($"SKU {sku}: requested {requested}, available {available}.")
    {
        Sku = sku;
        Requested = requested;
        Available = available;
    }
}

You catch by type, leveraging the hierarchy:

// C# — catch by type, exception filters
try
{
    await orderService.PlaceOrderAsync(dto);
}
catch (OrderNotFoundException ex) when (ex.OrderId > 0)
{
    // exception filter: only catches if the condition is true
    logger.LogWarning("Order {OrderId} not found", ex.OrderId);
    return NotFound(ex.Message);
}
catch (DomainException ex)
{
    // catches any remaining DomainException subclass
    return BadRequest(ex.Message);
}
catch (Exception ex)
{
    // last-resort catch
    logger.LogError(ex, "Unexpected error placing order");
    return StatusCode(500, "Internal server error");
}
finally
{
    metrics.IncrementOrderAttempts();
}

And you have global exception handling in ASP.NET Core via IExceptionHandler (or the classic UseExceptionHandler middleware), which gives you one place to translate unhandled exceptions into consistent HTTP responses:

// C# — Global exception handler (ASP.NET Core 8+)
public class GlobalExceptionHandler : IExceptionHandler
{
    public async ValueTask<bool> TryHandleAsync(
        HttpContext context,
        Exception exception,
        CancellationToken cancellationToken)
    {
        var (statusCode, title) = exception switch
        {
            OrderNotFoundException => (404, "Not Found"),
            DomainException => (400, "Bad Request"),
            _ => (500, "Internal Server Error")
        };

        context.Response.StatusCode = statusCode;
        await context.Response.WriteAsJsonAsync(
            new ProblemDetails { Title = title, Detail = exception.Message },
            cancellationToken);

        return true;
    }
}

The model is: throw everywhere, catch at the boundary, translate to HTTP once. It works well because the CLR guarantees that anything thrown is an Exception — you always know what you’re catching.


The JavaScript Way

What JavaScript Actually Has

JavaScript’s built-in Error class is surprisingly thin:

// TypeScript — the Error class
const e = new Error("something went wrong");
e.message;  // "something went wrong"
e.name;     // "Error"
e.stack;    // runtime-specific stack trace string

// Built-in subclasses (all inherit Error)
new TypeError("expected string, got number");
new RangeError("index out of bounds");
new SyntaxError("unexpected token");
new ReferenceError("x is not defined");
new URIError("malformed URI");
new EvalError("...");  // essentially never used

That is the entirety of the standard hierarchy. There is no ApplicationException. There is no checked exception system. There is no AggregateException (though Promise.allSettled and AggregateError partially fill that gap). The standard library throws on programmer errors — TypeError, RangeError — but for domain errors, there is no conventional base class.

Extending Error in TypeScript

You can extend Error, but there is a well-known pitfall with TypeScript and transpilation targets below ES2015:

// TypeScript — extending Error correctly
export class DomainError extends Error {
    constructor(message: string) {
        super(message);
        // Required when targeting ES5 or ES2015 with TypeScript's
        // downlevel emit: the prototype chain breaks without this.
        Object.setPrototypeOf(this, new.target.prototype);
        this.name = new.target.name; // "DomainError", not "Error"
    }
}

export class OrderNotFoundError extends DomainError {
    constructor(public readonly orderId: number) {
        super(`Order ${orderId} was not found.`);
        Object.setPrototypeOf(this, new.target.prototype);
    }
}

export class InsufficientInventoryError extends DomainError {
    constructor(
        public readonly sku: string,
        public readonly requested: number,
        public readonly available: number,
    ) {
        super(`SKU ${sku}: requested ${requested}, available ${available}.`);
        Object.setPrototypeOf(this, new.target.prototype);
    }
}

The Object.setPrototypeOf(this, new.target.prototype) call is the TypeScript equivalent of a footgun warning label. If you target ES2015 or higher (which you should in 2026 — see your tsconfig.json target field), you do not need it. But if you ever find instanceof returning false for a custom error class in production, this is why.

The Critical Difference: You Can Throw Anything

In C#, the compiler enforces that throw accepts only Exception-derived types. In JavaScript, you can throw any value:

// TypeScript — this compiles and runs without error
throw "a plain string";
throw 42;
throw { code: "ERR_BAD", detail: "something" };
throw null;
throw undefined;

This means your catch block cannot assume it received an Error:

// TypeScript — safe catch pattern
try {
    await riskyOperation();
} catch (err) {
    // err has type: unknown (with noUncheckedIndexedAccess and strict mode)
    // err has type: any (without strict)

    if (err instanceof Error) {
        console.error(err.message, err.stack);
    } else {
        // someone threw a non-Error value; handle defensively
        console.error("Non-Error thrown:", String(err));
    }
}

With "useUnknownInCatchVariables": true (enabled by TypeScript’s strict flag since 4.4), err in a catch block has type unknown, which forces you to narrow it before use. This is the correct behavior and you should have strict: true in your tsconfig.json.

A utility function to normalize caught values is worth having in your shared utilities:

// TypeScript — normalize unknown thrown value to Error
export function toError(thrown: unknown): Error {
    if (thrown instanceof Error) return thrown;
    if (typeof thrown === "string") return new Error(thrown);
    if (typeof thrown === "object" && thrown !== null) {
        return new Error(JSON.stringify(thrown));
    }
    return new Error(String(thrown));
}

// Usage
try {
    await riskyOperation();
} catch (err) {
    const error = toError(err);
    logger.error({ err: error }, "Operation failed");
}

try/catch/finally — What Is the Same, What Is Different

The syntax is identical to C#. The behavior differences are subtle:

// TypeScript — try/catch/finally, parallel to C#
async function fetchOrder(id: number): Promise<Order> {
    try {
        const row = await db.order.findUniqueOrThrow({ where: { id } });
        return mapToOrder(row);
    } catch (err) {
        if (err instanceof OrderNotFoundError) {
            throw err; // re-throw — no wrapping needed
        }
        // Prisma throws PrismaClientKnownRequestError with code "P2025"
        // when findUniqueOrThrow finds nothing. Translate it.
        if (isPrismaNotFoundError(err)) {
            throw new OrderNotFoundError(id);
        }
        throw err; // unknown error — let it propagate
    } finally {
        // runs regardless of outcome, same as C#
        metrics.recordDbQuery("order.findUnique");
    }
}

The key behavioral difference from C# is: finally runs even when the function is async and the catch block re-throws. JavaScript Promises handle this correctly. What is different from C# is that there are no when exception filters in JS — you do type narrowing inside the catch body.


The Result Pattern — When Not to Throw

The JavaScript community, especially TypeScript engineers influenced by Rust and functional programming, has embraced the Result<T, E> pattern for expected failure modes. The idea: rather than throwing for conditions that are part of normal operation (user not found, validation failed, payment declined), return a discriminated union that the caller is forced to handle.

This is not how C# normally works, but it has a clear analog: TryParse methods (int.TryParse, Dictionary.TryGetValue) that return bool and output the value via out parameters. The Result pattern is the functional version of that.

Rolling Your Own (Simple, No Dependencies)

// TypeScript — simple Result type
type Result<T, E extends Error = Error> =
    | { ok: true; value: T }
    | { ok: false; error: E };

function ok<T>(value: T): Result<T, never> {
    return { ok: true, value };
}

function err<E extends Error>(error: E): Result<never, E> {
    return { ok: false, error };
}

// Usage
async function findUser(
    email: string,
): Promise<Result<User, UserNotFoundError | DatabaseError>> {
    try {
        const row = await db.user.findUnique({ where: { email } });
        if (!row) return err(new UserNotFoundError(email));
        return ok(mapToUser(row));
    } catch (thrown) {
        return err(new DatabaseError(toError(thrown)));
    }
}

// Caller is forced to check
const result = await findUser("alice@example.com");
if (!result.ok) {
    if (result.error instanceof UserNotFoundError) {
        return { status: 404, body: "Not found" };
    }
    return { status: 500, body: "Database error" };
}
const user = result.value; // TypeScript knows this is User here

Using neverthrow

neverthrow is the most widely adopted Result library for TypeScript. It follows the Rust/Haskell model closely and adds methods like .map(), .mapErr(), .andThen() (flat-map) that let you chain operations without nested if (!result.ok) checks:

pnpm add neverthrow
// TypeScript — neverthrow
import { Result, ok, err, ResultAsync } from "neverthrow";

// ResultAsync wraps Promise<Result<T, E>> with the same chaining API
function findUser(email: string): ResultAsync<User, UserNotFoundError | DatabaseError> {
    return ResultAsync.fromPromise(
        db.user.findUniqueOrThrow({ where: { email } }),
        (thrown) => new DatabaseError(toError(thrown)),
    ).andThen((row) =>
        row ? ok(mapToUser(row)) : err(new UserNotFoundError(email)),
    );
}

function getProfile(
    email: string,
): ResultAsync<Profile, UserNotFoundError | DatabaseError | ProfileError> {
    return findUser(email)
        .andThen((user) => fetchProfile(user.id)) // chains only on ok
        .map((profile) => enrichProfile(profile)); // transforms value on ok
}

// At the call boundary
const result = await getProfile("alice@example.com");
result.match(
    (profile) => res.json(profile),       // ok branch
    (error) => res.status(mapStatus(error)).json({ message: error.message }),
);

The C# mental model for .andThen() is SelectMany / flatMap over a Maybe<T> or an Either<L, R>. If you have used Railway Oriented Programming in F# or functional patterns in C#, this will feel familiar.

When to Use Result vs. Throw

This is where the community has strong opinions. Here is the opinionated recommendation for this stack:

Throw (exceptions) for:

  • Programmer errors — null where null is impossible, index out of bounds, contract violations. These are bugs, not expected failure modes. Crashing loudly is correct.
  • Infrastructure failures with no reasonable local recovery — database is completely unreachable, file system full. These bubble up to your global handler.
  • Anywhere in your stack where a calling NestJS controller or React error boundary will catch and handle them. The framework does the heavy lifting.

Return Result for:

  • Domain operations with multiple expected outcomes the caller must handle: findUser might return UserNotFoundError; placeOrder might return InsufficientInventoryError or PaymentDeclinedError. These are not exceptional — they are anticipated branches.
  • Service-layer functions called by other service functions, where the caller wants to compose operations and handle errors uniformly.
  • Functions that can fail in multiple ways and the caller needs to distinguish the error type to respond differently.

The rule of thumb: if a caller should always handle the failure case, use Result. If a failure is genuinely unexpected and the framework should catch it, throw.


NestJS: Exception Filters vs. ASP.NET Exception Middleware

NestJS’s equivalent of ASP.NET’s IExceptionHandler / UseExceptionHandler is the Exception Filter. The conceptual mapping is direct.

ASP.NET Core                         NestJS
─────────────────────────────────    ─────────────────────────────────
IExceptionHandler                    ExceptionFilter (interface)
app.UseExceptionHandler(...)         app.useGlobalFilters(...)
[TypeFilter(typeof(MyFilter))]       @UseFilters(MyFilter)  (controller/route)
ProblemDetails response shape        custom or HttpException shape

Built-in HttpException Hierarchy

NestJS ships with a small but practical exception hierarchy:

import {
    HttpException,
    BadRequestException,    // 400
    UnauthorizedException,  // 401
    ForbiddenException,     // 403
    NotFoundException,      // 404
    ConflictException,      // 409
    UnprocessableEntityException, // 422
    InternalServerErrorException, // 500
} from "@nestjs/common";

// In a controller or service — throw NestJS HTTP exceptions
// and they are automatically translated to HTTP responses
throw new NotFoundException(`Order ${id} not found`);
throw new BadRequestException({ message: "Invalid input", fields: errors });

NestJS’s default exception filter catches anything that is an HttpException and writes a JSON response. Anything that is not an HttpException gets a generic 500 response. This is the equivalent of ASP.NET Core’s default exception handler behavior.

Writing a Global Exception Filter

For a real application you want one place that translates your domain errors to HTTP responses — exactly like ASP.NET’s IExceptionHandler:

// TypeScript — NestJS global exception filter
// src/common/filters/global-exception.filter.ts
import {
    ExceptionFilter,
    Catch,
    ArgumentsHost,
    HttpException,
    HttpStatus,
    Logger,
} from "@nestjs/common";
import { Request, Response } from "express";
import { DomainError, OrderNotFoundError, InsufficientInventoryError } from "../errors";

@Catch() // no argument = catch everything (like catch (Exception ex) in C#)
export class GlobalExceptionFilter implements ExceptionFilter {
    private readonly logger = new Logger(GlobalExceptionFilter.name);

    catch(exception: unknown, host: ArgumentsHost): void {
        const ctx = host.switchToHttp();
        const response = ctx.getResponse<Response>();
        const request = ctx.getRequest<Request>();

        const { status, message } = this.resolveError(exception);

        if (status >= 500) {
            this.logger.error(
                { err: exception, path: request.url },
                "Unhandled exception",
            );
        }

        response.status(status).json({
            statusCode: status,
            message,
            path: request.url,
            timestamp: new Date().toISOString(),
        });
    }

    private resolveError(exception: unknown): { status: number; message: string } {
        // NestJS HTTP exceptions — already translated
        if (exception instanceof HttpException) {
            return {
                status: exception.getStatus(),
                message: String(exception.message),
            };
        }

        // Domain errors — translate to HTTP status codes
        if (exception instanceof OrderNotFoundError) {
            return { status: HttpStatus.NOT_FOUND, message: exception.message };
        }
        if (exception instanceof InsufficientInventoryError) {
            return { status: HttpStatus.UNPROCESSABLE_ENTITY, message: exception.message };
        }
        if (exception instanceof DomainError) {
            return { status: HttpStatus.BAD_REQUEST, message: exception.message };
        }

        // Truly unexpected
        return {
            status: HttpStatus.INTERNAL_SERVER_ERROR,
            message: "An unexpected error occurred.",
        };
    }
}

Register it globally in main.ts:

// TypeScript — main.ts
import { NestFactory } from "@nestjs/core";
import { AppModule } from "./app.module";
import { GlobalExceptionFilter } from "./common/filters/global-exception.filter";

async function bootstrap() {
    const app = await NestFactory.create(AppModule);
    app.useGlobalFilters(new GlobalExceptionFilter());
    await app.listen(3000);
}
bootstrap();

You can also scope filters to a specific controller or route with the @UseFilters() decorator, which is the NestJS equivalent of ASP.NET’s [TypeFilter] or [ExceptionFilter] attributes.


React: Error Boundaries

On the frontend, React’s equivalent of a global exception handler for rendering errors is the Error Boundary. It is a class component (the one remaining valid use case for class components in 2026) that implements componentDidCatch and getDerivedStateFromError. When any component in its subtree throws during rendering, React calls the boundary instead of crashing the entire page.

// TypeScript — React error boundary
// src/components/ErrorBoundary.tsx
import React, { Component, ErrorInfo, ReactNode } from "react";

interface Props {
    fallback: ReactNode | ((error: Error) => ReactNode);
    children: ReactNode;
    onError?: (error: Error, info: ErrorInfo) => void;
}

interface State {
    error: Error | null;
}

export class ErrorBoundary extends Component<Props, State> {
    state: State = { error: null };

    static getDerivedStateFromError(error: Error): State {
        return { error };
    }

    componentDidCatch(error: Error, info: ErrorInfo): void {
        this.props.onError?.(error, info);
        // Sentry.captureException(error, { extra: info }); // see Sentry section
    }

    render(): ReactNode {
        if (this.state.error) {
            const { fallback } = this.props;
            return typeof fallback === "function"
                ? fallback(this.state.error)
                : fallback;
        }
        return this.props.children;
    }
}

// Usage — wrap your application or sections of it
function App() {
    return (
        <ErrorBoundary
            fallback={(err) => (
                <div role="alert">
                    <h2>Something went wrong</h2>
                    <p>{err.message}</p>
                </div>
            )}
            onError={(err, info) => console.error(err, info)}
        >
            <Router />
        </ErrorBoundary>
    );
}

Error boundaries only catch errors that occur during rendering, in lifecycle methods, and in constructors of components in their subtree. They do not catch errors in:

  • Event handlers (use regular try/catch inside the handler)
  • Asynchronous code (setTimeout, fetch callbacks — these happen outside React’s call stack)
  • The error boundary itself

For async data-fetching errors, TanStack Query surfaces them through the error property of its query result, and you render an error state from the component. React 19 introduces an onCaughtError / onUncaughtError callback on createRoot, but error boundaries remain the primary mechanism.


Node.js: Unhandled Rejection and Uncaught Exception Handlers

In .NET, unhandled exceptions in background threads crash the process (unless AppDomain.UnhandledException or TaskScheduler.UnobservedTaskException is wired up). In Node.js, the equivalents are:

// TypeScript / Node.js — process-level error handlers
// src/main.ts or a dedicated setup file

// Equivalent to AppDomain.UnhandledException for async code.
// In Node.js 15+, an unhandled rejection terminates the process by default.
// In older versions it was a warning. Always handle this explicitly.
process.on("unhandledRejection", (reason: unknown, promise: Promise<unknown>) => {
    const error = reason instanceof Error ? reason : new Error(String(reason));
    logger.fatal({ err: error }, "Unhandled promise rejection — process will exit");
    // Give Sentry time to flush before exiting
    Sentry.captureException(error);
    Sentry.flush(2000).then(() => process.exit(1));
});

// Equivalent to AppDomain.UnhandledException for synchronous throws.
// This is your last-resort handler. The process is in an undefined state.
process.on("uncaughtException", (error: Error) => {
    logger.fatal({ err: error }, "Uncaught exception — process will exit");
    Sentry.captureException(error);
    Sentry.flush(2000).then(() => process.exit(1));
});

// Graceful shutdown on SIGTERM (equivalent to Windows Service stop / ASP.NET
// Core application lifetime cancellation token)
process.on("SIGTERM", () => {
    logger.info("SIGTERM received — shutting down gracefully");
    server.close(() => process.exit(0));
});

NestJS registers these handlers for you when you use NestFactory.create, but if you are writing raw Node.js scripts or custom bootstrap code, you need them explicitly.

The critical rule: do not swallow unhandled rejections. The pattern process.on("unhandledRejection", () => {}) — doing nothing — is a production debugging nightmare. Always log and always exit or re-throw. The process is corrupt at that point.


Sentry Integration

Sentry fills the role of Application Insights for error tracking in this stack. The integration hooks directly into your exception filter (NestJS) and error boundary (React).

NestJS

pnpm add @sentry/node @sentry/profiling-node
// TypeScript — Sentry init in main.ts (before everything else)
import * as Sentry from "@sentry/node";
import { nodeProfilingIntegration } from "@sentry/profiling-node";

Sentry.init({
    dsn: process.env.SENTRY_DSN,
    environment: process.env.NODE_ENV,
    integrations: [nodeProfilingIntegration()],
    tracesSampleRate: process.env.NODE_ENV === "production" ? 0.1 : 1.0,
    profilesSampleRate: 1.0,
    beforeSend(event, hint) {
        // Suppress 4xx errors from Sentry — they are expected
        const status = (hint?.originalException as any)?.status;
        if (status && status >= 400 && status < 500) return null;
        return event;
    },
});

In your global exception filter, add Sentry.captureException(exception) for server errors:

// TypeScript — updated GlobalExceptionFilter with Sentry
import * as Sentry from "@sentry/node";

// Inside the catch() method, after resolveError:
if (status >= 500) {
    Sentry.captureException(exception, {
        extra: { path: request.url, method: request.method },
        user: { id: request.user?.id }, // if you have auth context
    });
}

Next.js / React

pnpm add @sentry/nextjs

Sentry’s Next.js SDK wraps the instrumentation.ts file:

// TypeScript — instrumentation.ts (Next.js 15+ / App Router)
export async function register() {
    if (process.env.NEXT_RUNTIME === "nodejs") {
        const Sentry = await import("@sentry/nextjs");
        Sentry.init({
            dsn: process.env.NEXT_PUBLIC_SENTRY_DSN,
            tracesSampleRate: 0.1,
        });
    }
}

Wire the error boundary to Sentry:

// TypeScript — ErrorBoundary.tsx with Sentry
import * as Sentry from "@sentry/nextjs";

componentDidCatch(error: Error, info: ErrorInfo): void {
    Sentry.captureException(error, { extra: { componentStack: info.componentStack } });
    this.props.onError?.(error, info);
}

Sentry groups errors by their stack trace fingerprint, deduplicates across users, and shows you the release version and user context. The beforeSend callback is the equivalent of Application Insights’ ITelemetryProcessor — use it to filter noise (4xx errors, bot traffic) before they hit your quota.


Designing Error Hierarchies in TypeScript

Given everything above, here is the recommended hierarchy design for a NestJS + React project:

// TypeScript — error hierarchy
// src/common/errors/index.ts

// ─── Base ────────────────────────────────────────────────────────────────────

export class AppError extends Error {
    constructor(
        message: string,
        public readonly code: string,
        public readonly metadata?: Record<string, unknown>,
    ) {
        super(message);
        Object.setPrototypeOf(this, new.target.prototype);
        this.name = new.target.name;
    }
}

// ─── Domain errors (expected, part of the domain model) ──────────────────────

export class DomainError extends AppError {}

export class NotFoundError extends DomainError {
    constructor(resource: string, id: string | number) {
        super(`${resource} '${id}' not found.`, "NOT_FOUND", { resource, id });
    }
}

export class ConflictError extends DomainError {
    constructor(message: string) {
        super(message, "CONFLICT");
    }
}

export class ValidationError extends DomainError {
    constructor(
        message: string,
        public readonly fields?: Record<string, string[]>,
    ) {
        super(message, "VALIDATION_ERROR", { fields });
    }
}

// ─── Infrastructure errors (unexpected, environment-level failures) ───────────

export class InfrastructureError extends AppError {}

export class DatabaseError extends InfrastructureError {
    constructor(cause: Error) {
        super("A database error occurred.", "DATABASE_ERROR");
        this.cause = cause; // Node.js 16.9+ Error.cause support
    }
}

export class ExternalServiceError extends InfrastructureError {
    constructor(service: string, cause: Error) {
        super(`External service '${service}' failed.`, "EXTERNAL_SERVICE_ERROR", {
            service,
        });
        this.cause = cause;
    }
}

Map this to HTTP status codes in your global exception filter:

// TypeScript — status code mapping
function resolveHttpStatus(error: unknown): number {
    if (error instanceof NotFoundError) return 404;
    if (error instanceof ConflictError) return 409;
    if (error instanceof ValidationError) return 422;
    if (error instanceof DomainError) return 400;
    if (error instanceof InfrastructureError) return 503;
    return 500;
}

This keeps the HTTP concern out of your domain model — your services throw NotFoundError, not NotFoundException. The filter translates at the boundary, which is the same separation you get in ASP.NET with IExceptionHandler.


Key Differences

ConceptC# / ASP.NET CoreTypeScript / NestJS + React
Exception base classSystem.ExceptionError (no ApplicationException analog)
Checked exceptionsNo (unlike Java, C# is unchecked)No
throw type constraintMust be Exception-derivedAny value — you can throw 42
catch type narrowingBy type: catch (NotFoundException ex)Must narrow inside body: if (err instanceof X)
Exception filterswhen (condition) keywordNot in language — use if inside catch
Global exception handlerIExceptionHandler / UseExceptionHandlerNestJS ExceptionFilter with @Catch()
Catch scopeController attribute [TypeFilter]@UseFilters() decorator — same concept
Render error boundaryNo (Blazor has ErrorBoundary component)React ErrorBoundary class component
Unhandled async errorsTaskScheduler.UnobservedTaskExceptionprocess.on("unhandledRejection", ...)
Return-value errorsTryParse, out bool patternResult<T, E>neverthrow, ts-results, or DIY
Error trackingApplication InsightsSentry (@sentry/node, @sentry/nextjs)
AggregateExceptionYes, from Task.WhenAllAggregateError (limited) — Promise.allSettled for granular results

Gotchas for .NET Engineers

Gotcha 1: instanceof Fails Across Module Boundaries

In C#, type identity is determined by the assembly-qualified type name. In JavaScript, instanceof checks the prototype chain — and the prototype is specific to the module instance. If your error classes are instantiated in one bundle chunk and the catch block is in another, instanceof can return false for what looks like the same class.

This is most likely to bite you in:

  • Monorepos where the error classes live in a shared package that gets bundled twice (once into the API, once into a worker)
  • Node.js environments with multiple copies of the same package in node_modules (pnpm’s strict mode prevents this; npm and yarn can allow duplicate installs)
  • Code that uses vm.runInContext or worker threads that don’t share the same module registry

The fix: ensure error classes are a single shared dependency, and use pnpm’s strict mode to prevent duplicate packages. As a defensive measure, you can check error.name or error.code string properties instead of instanceof for errors that cross process boundaries.

Gotcha 2: async Functions Swallow Throws as Rejected Promises

In C#, a throw inside a method propagates synchronously up the call stack, regardless of whether the method is async. In JavaScript, a throw inside an async function does not propagate synchronously — it becomes a rejected Promise. If that promise is not awaited and not .catch()ed, it becomes an unhandled rejection.

// This looks like it throws synchronously, but it does not
async function doWork(): Promise<void> {
    throw new Error("This becomes a rejected Promise, not a synchronous throw");
}

// Caller without await — the error is silently lost in older Node.js
doWork(); // Missing await — in Node 15+, this crashes the process on next tick

// Correct
await doWork(); // error propagates to the caller's catch block

This is the same problem Article 1.7 covers with async/await, but it is especially treacherous in error handling because .NET engineers instinctively assume throw stops execution immediately and propagates up. In an async context in JS, it does stop the function, but the propagation is through the Promise chain, not the synchronous call stack.

Gotcha 3: NestJS’s Default Filter Does Not Catch What You Think

NestJS ships with a default HttpExceptionFilter that handles HttpException and its subclasses. Anything that is NOT an HttpException — including your own custom domain errors — gets caught by NestJS’s internal catch-all, which returns a plain { statusCode: 500, message: "Internal server error" } response and logs the full error internally.

This means: if you throw new OrderNotFoundError(id) from a service and you have not registered a global filter that handles it, the client gets a 500, not a 404. The error is logged to the NestJS internal logger but not sent to Sentry and not translated.

The fix is the global exception filter shown above. Register it before your application starts listening, and test it explicitly — throw a custom domain error from a test endpoint and verify the status code and Sentry capture.

Gotcha 4: Error Boundaries Do Not Catch Event Handler Errors

A React error boundary catches errors thrown during rendering. It does not catch errors thrown inside onClick, onSubmit, or any other DOM event handler. This surprises .NET engineers who expect a top-level handler to catch everything.

// This error is NOT caught by an error boundary above this component
function DeleteButton({ id }: { id: number }) {
    async function handleClick() {
        try {
            await deleteOrder(id); // may throw
        } catch (err) {
            // Must handle here — the error boundary will not see this
            toast.error("Failed to delete order");
        }
    }
    return <button onClick={handleClick}>Delete</button>;
}

Use try/catch inside event handlers. For async mutations, TanStack Query’s useMutation has an onError callback that gives you a consistent place to handle mutation errors without wrapping every mutate() call in try/catch.

Gotcha 5: The Stack Trace Is Often Useless in Production Without Source Maps

In .NET, stack traces reference your original C# source files with line numbers. In production Node.js and browser JavaScript, the code is compiled and minified — stack traces point to mangled variable names and single-line bundles.

Source maps fix this. NestJS with ts-node or SWC in production mode generates source maps. Sentry’s SDK automatically resolves source maps if they are uploaded during deployment. Without source maps, your Sentry errors will show at t.<anonymous> (main.js:1:45821) — useless for debugging.

Configure source map upload in your CI pipeline:

# Example: upload source maps to Sentry during deployment
pnpm dlx @sentry/cli releases files $SENTRY_RELEASE upload-sourcemaps ./dist

Hands-On Exercise

This exercise builds the complete error handling layer for a NestJS order management API. You will implement every pattern from this article: custom error hierarchy, global exception filter, Sentry integration, and a Result-based service method.

Prerequisites: A running NestJS project (from Track 4 exercises, or nest new order-api).

Step 1 — Create the error hierarchy

Create src/common/errors/index.ts with AppError, DomainError, NotFoundError, ValidationError, and DatabaseError exactly as shown in the “Designing Error Hierarchies” section above.

Step 2 — Create a Result type

Install neverthrow: pnpm add neverthrow

Create src/common/result.ts that re-exports Result, ResultAsync, ok, err from neverthrow.

Step 3 — Write an OrderService method using Result

// src/orders/orders.service.ts
import { Injectable } from "@nestjs/common";
import { PrismaService } from "../prisma/prisma.service";
import { ResultAsync, ok, err } from "neverthrow";
import { NotFoundError, DatabaseError } from "../common/errors";
import { toError } from "../common/utils/to-error";

@Injectable()
export class OrdersService {
    constructor(private readonly prisma: PrismaService) {}

    findOne(id: number): ResultAsync<Order, NotFoundError | DatabaseError> {
        return ResultAsync.fromPromise(
            this.prisma.order.findUnique({ where: { id } }),
            (thrown) => new DatabaseError(toError(thrown)),
        ).andThen((row) =>
            row ? ok(mapToOrder(row)) : err(new NotFoundError("Order", id)),
        );
    }
}

Step 4 — Write the controller, throwing instead of returning

In the controller, call the service and translate the Result to an exception or a response. Controllers are your boundary — they should throw (or use NestJS HttpException) rather than return Result:

// src/orders/orders.controller.ts
@Get(":id")
async findOne(@Param("id", ParseIntPipe) id: number) {
    const result = await this.ordersService.findOne(id);
    return result.match(
        (order) => order,
        (error) => { throw error; }, // domain error propagates to global filter
    );
}

Step 5 — Register the global exception filter

Implement and register GlobalExceptionFilter in main.ts as shown in the NestJS section.

Step 6 — Verify behavior

Use curl or the Swagger UI to request an order that does not exist. Verify you receive a 404 with your error shape, not a 500. Introduce a deliberate bug (throw an untyped object) and verify the global filter handles it gracefully and returns a 500 with no stack trace exposed to the client.

Step 7 — Add Sentry (optional but recommended)

Install @sentry/node, initialize in main.ts, and add Sentry.captureException to the global filter for 5xx responses. Trigger a 500 and verify the event appears in your Sentry dashboard with a readable stack trace.


Quick Reference

.NET ConceptTypeScript / NestJS EquivalentNotes
System.ExceptionErrorMinimal built-in hierarchy; extend manually
ApplicationExceptionNo direct equivalentUse your own DomainError extends Error
catch (SpecificException ex)if (err instanceof SpecificError) inside catch (err)No catch (Type) syntax in JS
when (condition) exception filterif (condition) inside catch bodyNot a language feature in JS
IExceptionHandler@Catch() ExceptionFilter + app.useGlobalFilters()Direct equivalent
[TypeFilter(typeof(X))]@UseFilters(X) on controller/handlerSame scoping concept
AggregateExceptionAggregateError / Promise.allSettledallSettled gives per-item results
TaskScheduler.UnobservedTaskExceptionprocess.on("unhandledRejection", ...)Node 15+: crashes process by default
AppDomain.UnhandledExceptionprocess.on("uncaughtException", ...)Last resort; always exit after
TryParse / out bool patternResult<T, E> / neverthrowResultAsync for async operations
Sentry.CaptureException (C# SDK)Sentry.captureException()Same API surface, same Sentry project
Error Boundary (none / Blazor <ErrorBoundary>)React ErrorBoundary class componentCatches render-phase errors only
Application Insights error trackingSentry @sentry/node + @sentry/nextjsSentry = errors; Grafana/Datadog = metrics
throw new NotFoundException(...) (ASP.NET)throw new NotFoundException(...) (NestJS built-in)NestJS has a matching built-in hierarchy
Object.setPrototypeOf workaroundOnly needed when target < ES2015Set "target": "ES2020" or higher and skip it

Further Reading