A service function can fail in two ways that its caller needs to handle differently. A request may be invalid, a record may not exist, or a business rule may block the operation. Those are expected domain outcomes. A database connection that went down, a broken assumption, or a programming bug is not a normal choice for the caller.
Use Result<T, E> for the first group and let the second group throw an exception. This is not a style rule for every project. It makes the contract explicit: the type describes what the caller can decide about, while throw preserves the interruption of a path that needs diagnosis or higher-level handling.
This article is a spoke of the guide on typing fetch success and error results in TypeScript. That guide covers network and JSON outcomes. If the input has not been checked yet, see how to validate external JSON before using it in TypeScript. This page moves up one layer and asks what the service should promise after an adapter has handled those inputs.
I am not presenting production experience or a library comparison. I compiled a local fixture with TypeScript 5.9.3 using strict and skipLibCheck to check the Result narrowing and the shape of the example. The fixture measures neither performance nor whether one policy fits every domain.

Short answer
- Return
Result<T, E>when the failure belongs to the outcome the caller can choose how to handle.- Throw when the function cannot offer a safe local recovery or an implementation assumption has broken.
- At an HTTP or queue boundary, translate expected domain errors into protocol responses and keep unexpected faults visible to observability.
- Use a tagged error and preserve
cause; do not turn every failure intoResult<T, Error>or catch everything with one message.
The difference is what the caller can decide
The first question is not whether Result feels more functional or try/catch feels more idiomatic. Ask whether the caller has a normal decision for the case.
| Service situation | Can the caller decide? | Channel that usually communicates it best |
|---|---|---|
| Input that violates a business rule | Yes, it can inform the client or correct the input | Result<T, E> |
| Record not found, when that is part of the contract | Yes, it can return 404, create the record, or take another path | Result<T, E> |
| Known state conflict | Yes, it can return 409 or ask for a fresh read | Result<T, E> |
| Database outage or broken dependency | There is no safe local decision that completes the request | exception with context |
| Impossible invariant or programming bug | It is not an expected domain outcome | exception, alert, and investigation |
The classification depends on the contract. NotFound can be a Result in a user lookup and an exception in an internal routine that received an ID which should exist. The same external problem can be expected for an adapter and unexpected for the business rule that calls it.
The TypeScript Handbook on narrowing documents how a shared literal field lets the compiler reduce a discriminated union to one variant. That is what makes a Result a checkable contract instead of an object with two optional fields.
How to model a Result for a service
Start with two variants that cannot exist at the same time. The ok discriminant tells both the compiler and the reader which field is available.
type Success<T> = {
ok: true;
value: T;
};
type Failure<E> = {
ok: false;
error: E;
};
type Result<T, E> = Success<T> | Failure<E>;
type PlaceOrderError =
| { kind: "not-found"; orderId: string }
| { kind: "conflict"; reason: "already-closed" }
| { kind: "invalid-input"; field: "quantity" };
type Order = {
id: string;
status: "created" | "closed";
};
The error needs a discriminant too. Result<Order, Error> says only that some error object exists. PlaceOrderError gives the consumer a set of cases it can map to a decision, such as 404, 409, or 422. That list is not a promise that no exception can escape. It is the expected part of the contract.
A service can use this union without another library:
type OrderRepository = {
load(orderId: string): Promise<Order | null>;
save(order: Order): Promise<void>;
};
async function placeOrder(
orderId: string,
quantity: number,
repository: OrderRepository,
): Promise<Result<Order, PlaceOrderError>> {
if (!Number.isInteger(quantity) || quantity < 1) {
return {
ok: false,
error: { kind: "invalid-input", field: "quantity" },
};
}
const order = await repository.load(orderId);
if (!order) {
return { ok: false, error: { kind: "not-found", orderId } };
}
if (order.status === "closed") {
return {
ok: false,
error: { kind: "conflict", reason: "already-closed" },
};
}
await repository.save(order);
return { ok: true, value: order };
}
The example is illustrative: the quantity does not change the order, and persistence is not connected to a database. The contract point is that expected inputs and states return an explicit variant. If repository.load throws because the database is unavailable, this Result does not swallow that fault.
When is an exception clearer?
throw stops the current function and looks for the first catch in the call stack, as described by the MDN reference for the throw statement. Use that interruption when continuing with a normal value could make the caller believe the work completed.
An exception does not have to be a context-free message. When adding context, preserve the original cause:
class UnexpectedServiceError extends Error {
constructor(message: string, options?: ErrorOptions) {
super(message, options);
this.name = "UnexpectedServiceError";
}
}
async function saveWithContext(
order: Order,
repository: OrderRepository,
): Promise<void> {
try {
await repository.save(order);
} catch (cause) {
throw new UnexpectedServiceError("order repository failed", { cause });
}
}
The cause property keeps the original cause when an error receives a more useful message, as explained in the MDN documentation for Error.cause. A log can record the original error and the service context while the public response omits infrastructure details.
Do not catch everything merely to return a Result. If a database outage becomes { ok: false, error: ... } without a clear caller decision, the type looks more uniform but the incident signal is weaker. The contract should say who can recover, not just hide the stack.
The API boundary translates both paths
The controller or handler knows the external protocol. It can turn an expected failure into HTTP and let an unexpected fault reach error middleware, a logger, or the appropriate retry mechanism. This division fits the Model-Controller-Service architecture for TypeScript backends as long as the service does not learn the final response format.
function statusFor(error: PlaceOrderError): number {
switch (error.kind) {
case "invalid-input":
return 422;
case "not-found":
return 404;
case "conflict":
return 409;
}
}
async function handlePlaceOrder(
orderId: string,
quantity: number,
repository: OrderRepository,
): Promise<Response> {
try {
const result = await placeOrder(orderId, quantity, repository);
if (!result.ok) {
return Response.json(
{ error: result.error.kind },
{ status: statusFor(result.error) },
);
}
return Response.json(result.value, { status: 200 });
} catch (error) {
console.error("unexpected place-order failure", error);
return Response.json(
{ error: "internal-error" },
{ status: 500 },
);
}
}
The if (!result.ok) narrows the value to Failure<PlaceOrderError>. The switch also makes the protocol policy visible. If someone adds a new error variant, the explicit return and compiler checks make the review point easy to find.
This handler is not a universal middleware model. A framework may already have a global exception translator, in which case the service can let UnexpectedServiceError escape and the handler can return the 500 response centrally. The rule stays the same: translate expected domain errors where the protocol is known, and do not turn an operational fault into a partial success.
How do you choose without adding ceremony?
Use these questions for each failure that crosses the service boundary:
- Can it happen with valid input or in a normal domain state?
- Does the caller have a defined action, such as correcting data, returning 404, or retrying with another version?
- Does the consumer need to distinguish it from other failures to choose the flow?
- If the function returns an error, can someone still observe the cause, request ID, and operation that failed?
If the first three answers are yes, Result<T, E> usually makes the contract more honest. If the last answer is no, the problem is not choosing between Result and exceptions. The diagnostic policy is missing.
The hybrid path is often the simplest one in TypeScript applications:
- adapters turn external responses and known validation failures into domain errors;
- services return
Resultfor normal use-case decisions; - unexpected faults preserve their cause and rise as exceptions;
- controllers translate domain results into HTTP, queue messages, or events;
- tests cover each variant without treating an unexpected fault as a valid output.
This split also helps coding agents and reviewers: the signature shows the path that must be handled, while a broad catch does not become permission to ignore errors. The type does not replace tests, logs, or permission boundaries, but it reduces the implicit part of the conversation.
What should you test before adopting the policy?
The most useful test does not measure exception speed. It checks that the contract has not changed silently:
- invalid input returns the expected error variant;
- a missing record does not call persistence;
- a conflict returns its discriminant and the corresponding HTTP status;
- a repository exception does not become a successful
Result; - the handler does not expose a dependency message or
cause; - a new
PlaceOrderErrorvariant forces a review of theswitch.
The fixture for this article was compiled with strict, noEmit, target: ES2022, and lib: ["ES2022", "DOM"]. It checks the shape of the examples and the narrowing, but it does not run a database or simulate an HTTP server. In a real project, put these cases in the repository's test runner and include the adapter, database, and protocol failure that actually exist in the system.
Sources and limits
The narrowing behavior used by Result comes from the TypeScript Handbook on narrowing. The control flow of throw and catch comes from the MDN reference for throw, and preserving the original cause comes from the MDN reference for Error.cause. The MDN reference for Promise.prototype.catch describes the rejection boundary an async function presents to its caller.
The 422, 404, 409, and 500 choices in the example are illustrative policy, not a universal HTTP recommendation. I did not compare libraries such as neverthrow or Effect, measure overhead, or claim a dominant community convention. AI assistance supported research, drafting, image generation, translation, and consistency review; it did not run the fixture or replace source verification and editorial accountability.