Error types in TypeScript: practical tips and trade-offs
JavaScript throws anything—strings, objects, null, undefined—but TypeScript's type system has to deal with that chaos. In a production system handling thousands of transactions per second, an unhandled error can cascade into silent failures or worse, corrupted state. We'll walk through error types in TypeScript: practical tips and trade-offs that let us build resilient error handling without drowning in try-catch boilerplate.
Caught errors are unknown — Anything can be thrown in JavaScript
When you write a try-catch block in JavaScript, the caught value has type unknown in TypeScript. This is correct and intentional. You can throw anything:
throw "string error";
throw 42;
throw { custom: "object" };
throw new Error("proper error");
throw null;A library you're using might throw a custom class. A promise rejection might pass a plain object. Legacy code might throw strings. TypeScript forces you to acknowledge this reality by treating caught values as unknown:
try {
await fetchUserData(userId);
} catch (error) {
// error is unknown
console.log(error); // ✓ allowed
console.log(error.message); // ✗ Property 'message' does not exist on type 'unknown'
}This strictness is a feature, not a bug. In a real e-commerce checkout flow, logging an error message without checking its type could crash your error handler, leaving the transaction in an undefined state. We've seen this happen—a third-party payment gateway threw an object without a message property, the error handler crashed trying to access it, and the payment confirmation logic never ran.
Narrow before use — Check instanceof Error before reading message
The practical solution is type narrowing. Check the type of the caught value before using its properties:
try {
await processPayment(order);
} catch (error) {
let message = "Unknown error";
if (error instanceof Error) {
message = error.message;
} else if (typeof error === "string") {
message = error;
} else if (typeof error === "object" && error !== null && "detail" in error) {
message = (error as any).detail;
}
logger.error({ message, error });
}This is verbose, yes. But in systems handling payment reconciliation or order fulfillment, this verbosity prevents silent errors. We've found that adding even a single generic error handler at the top level of request handlers catches most real-world cases:
function getErrorMessage(error: unknown): string {
if (error instanceof Error) {
return error.message;
}
return String(error);
}
function getErrorStack(error: unknown): string | undefined {
if (error instanceof Error) {
return error.stack;
}
return undefined;
}Use these utilities consistently across your codebase. They become your contract: "everywhere we handle errors, we extract message and stack the same way." That contract makes debugging easier and makes you confident that error context isn't lost.
In NestJS or Express middleware, wrap them once at the top level:
app.use((error: unknown, req: Request, res: Response, next: NextFunction) => {
const message = getErrorMessage(error);
const stack = getErrorStack(error);
logger.error({ message, stack, path: req.path });
res.status(500).json({ error: message });
});Typed domain errors — Custom classes with codes for expected failures
Not all errors are surprises. Payment declines, invalid input, rate limits—these are expected outcomes. Throwing generic Error for these cases loses information and mixes concerns.
Instead, create domain-specific error classes:
class DomainError extends Error {
constructor(
public code: string,
message: string,
public statusCode: number = 500,
) {
super(message);
this.name = this.constructor.name;
}
}
class PaymentDeclinedError extends DomainError {
constructor(reason: string) {
super("PAYMENT_DECLINED", `Payment declined: ${reason}`, 402);
}
}
class ValidationError extends DomainError {
constructor(
public field: string,
message: string,
) {
super("VALIDATION_ERROR", message, 400);
}
}
class InsufficientInventoryError extends DomainError {
constructor(
public sku: string,
public requested: number,
public available: number,
) {
super(
"INSUFFICIENT_INVENTORY",
`SKU ${sku}: requested ${requested}, available ${available}`,
409,
);
}
}Now your business logic is explicit:
async function checkoutOrder(
cart: CartItem[],
paymentToken: string,
): Promise {
// Validate input
if (!cart.length) {
throw new ValidationError("cart", "Cart is empty");
}
// Check inventory
for (const item of cart) {
const available = await getInventory(item.sku);
if (available < item.quantity) {
throw new InsufficientInventoryError(
item.sku,
item.quantity,
available,
);
}
}
// Process payment
const result = await stripe.charge({
amount: calculateTotal(cart),
token: paymentToken,
});
if (!result.success) {
throw new PaymentDeclinedError(result.decline_reason);
}
return createOrder(cart);
}In your HTTP handler, these errors map cleanly to responses:
app.post("/checkout", async (req, res) => {
try {
const order = await checkoutOrder(req.body.cart, req.body.token);
res.status(201).json(order);
} catch (error) {
if (error instanceof DomainError) {
return res.status(error.statusCode).json({
code: error.code,
message: error.message,
});
}
// Unknown error
logger.error({ error, path: req.path });
res.status(500).json({ error: "Internal server error" });
}
});Your API consumers get consistent error codes. Your logs are structured. Your team doesn't spend hours debugging "what went wrong?" because the error object itself tells you—it's a PAYMENT_DECLINED, not a mystery 500.
Results for expected failures — Return types make errors explicit
Domain errors are good, but they still use exceptions. In systems where certain failures are routine, exceptions add overhead and obscure the happy path. Consider a Result type instead:
type Result =
| { ok: true; value: T }
| { ok: false; error: E };
function ok(value: T): Result {
return { ok: true, value };
}
function err(error: E): Result {
return { ok: false, error };
}Rewrite your checkout to return results:
type CheckoutError =
| { type: "validation"; field: string; message: string }
| { type: "inventory"; sku: string; requested: number; available: number }
| { type: "payment_declined"; reason: string };
async function checkoutOrder(
cart: CartItem[],
paymentToken: string,
): Promise> {
if (!cart.length) {
return err({
type: "validation",
field: "cart",
message: "Cart is empty"
});
}
for (const item of cart) {
const available = await getInventory(item.sku);
if (available < item.quantity) {
return err({
type: "inventory",
sku: item.sku,
requested: item.quantity,
available,
});
}
}
const paymentResult = await stripe.charge({
amount: calculateTotal(cart),
token: paymentToken,
});
if (!paymentResult.success) {
return err({
type: "payment_declined",
reason: paymentResult.decline_reason,
});
}
const order = await createOrder(cart);
return ok(order);
}Your handler is straightforward—no try-catch needed:
app.post("/checkout", async (req, res) => {
const result = await checkoutOrder(req.body.cart, req.body.token);
if (!result.ok) {
const error = result.error;
if (error.type === "validation") {
return res.status(400).json({ code: "VALIDATION_ERROR", message: error.message });
}
if (error.type === "inventory") {
return res.status(409).json({
code: "INSUFFICIENT_INVENTORY",
sku: error.sku,
requested: error.requested,
available: error.available,
});
}
if (error.type === "payment_declined") {
return res.status(402).json({ code: "PAYMENT_DECLINED", reason: error.reason });
}
}
res.status(201).json(result.value);
});The trade-off: Results are explicit about failure paths, but they're more boilerplate. Use them for domain logic where failures are expected and recoverable. Reserve exceptions for truly exceptional cases—bugs, external service outages, resource exhaustion.
Common mistakes
- Catching without narrowing. Don't write
catch (error: Error)and assume it's an Error. The type system allows it for backward compatibility, but it's unsafe. Always narrow withinstanceof. - Mixing error strategies. Don't use domain errors in some functions and generic errors in others. Pick one pattern per system and stick to it. Your team needs to know where to look.
- Throwing in a try-finally without handling. If your cleanup code (finally block) throws, the original error is lost. Either handle it or chain it:
throw new Error("cleanup failed", { cause: originalError }). - Over-specifying Result types. If you return
Result<string, ErrorA | ErrorB | ErrorC>, you've recreated exception handling with more typing. Keep Result error types small—usually 2-3 variants per function.
Key takeaways
- Caught values are
unknownin TypeScript. Check type withinstanceof Errorbefore accessing properties. - Create domain-specific error classes with codes and status codes for expected failures. Map them consistently to HTTP responses.
- Use Result types for routine failures where control flow matters (checkout, validation, retries). Use exceptions for truly exceptional cases.
- Always have a top-level error handler that safely extracts message and stack from
unknownvalues. Share this logic across your app. - Log error codes, not just messages. Your future self debugging production will thank you.






Comments
Loading comments…