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 —
nullwherenullis 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:
findUsermight returnUserNotFoundError;placeOrdermight returnInsufficientInventoryErrororPaymentDeclinedError. 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/catchinside the handler) - Asynchronous code (
setTimeout,fetchcallbacks — 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
| Concept | C# / ASP.NET Core | TypeScript / NestJS + React |
|---|---|---|
| Exception base class | System.Exception | Error (no ApplicationException analog) |
| Checked exceptions | No (unlike Java, C# is unchecked) | No |
throw type constraint | Must be Exception-derived | Any value — you can throw 42 |
catch type narrowing | By type: catch (NotFoundException ex) | Must narrow inside body: if (err instanceof X) |
| Exception filters | when (condition) keyword | Not in language — use if inside catch |
| Global exception handler | IExceptionHandler / UseExceptionHandler | NestJS ExceptionFilter with @Catch() |
| Catch scope | Controller attribute [TypeFilter] | @UseFilters() decorator — same concept |
| Render error boundary | No (Blazor has ErrorBoundary component) | React ErrorBoundary class component |
| Unhandled async errors | TaskScheduler.UnobservedTaskException | process.on("unhandledRejection", ...) |
| Return-value errors | TryParse, out bool pattern | Result<T, E> — neverthrow, ts-results, or DIY |
| Error tracking | Application Insights | Sentry (@sentry/node, @sentry/nextjs) |
AggregateException | Yes, from Task.WhenAll | AggregateError (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.runInContextor 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 Concept | TypeScript / NestJS Equivalent | Notes |
|---|---|---|
System.Exception | Error | Minimal built-in hierarchy; extend manually |
ApplicationException | No direct equivalent | Use your own DomainError extends Error |
catch (SpecificException ex) | if (err instanceof SpecificError) inside catch (err) | No catch (Type) syntax in JS |
when (condition) exception filter | if (condition) inside catch body | Not a language feature in JS |
IExceptionHandler | @Catch() ExceptionFilter + app.useGlobalFilters() | Direct equivalent |
[TypeFilter(typeof(X))] | @UseFilters(X) on controller/handler | Same scoping concept |
AggregateException | AggregateError / Promise.allSettled | allSettled gives per-item results |
TaskScheduler.UnobservedTaskException | process.on("unhandledRejection", ...) | Node 15+: crashes process by default |
AppDomain.UnhandledException | process.on("uncaughtException", ...) | Last resort; always exit after |
TryParse / out bool pattern | Result<T, E> / neverthrow | ResultAsync for async operations |
Sentry.CaptureException (C# SDK) | Sentry.captureException() | Same API surface, same Sentry project |
Error Boundary (none / Blazor <ErrorBoundary>) | React ErrorBoundary class component | Catches render-phase errors only |
| Application Insights error tracking | Sentry @sentry/node + @sentry/nextjs | Sentry = errors; Grafana/Datadog = metrics |
throw new NotFoundException(...) (ASP.NET) | throw new NotFoundException(...) (NestJS built-in) | NestJS has a matching built-in hierarchy |
Object.setPrototypeOf workaround | Only needed when target < ES2015 | Set "target": "ES2020" or higher and skip it |
Further Reading
- TypeScript Handbook — Error Handling — The official docs do not have an “error handling” chapter, but the section on
unknownin catch blocks and type narrowing is directly relevant. - NestJS — Exception Filters — Complete coverage of filter scoping, binding, and inheritance.
- neverthrow — README and API — The canonical source for the
Result/ResultAsyncAPI, including chaining patterns and interop withPromise-based code. - Sentry — NestJS Integration Guide — Step-by-step setup including source maps, release tracking, and performance tracing for NestJS specifically.