There is one simple thing I really value in software development: the result of a function should not be ambiguous. Not null, not undefined, not “if something goes wrong, we will catch an exception somewhere above”, but an explicit result: success(data) or fault({ code: 'OUTPUT_SCHEMA_ERROR', message: 'Invalid output schema' }). I like it when the calling code immediately understands what happened: the operation either completed successfully and returned data, or it failed and returned a clear reason.
The idea from Elixir, but without fanaticism
I partially borrowed this idea from Elixir. In Elixir, it is common to return tuples like {:ok, value} and {:error, reason}. It is a very simple but powerful idea: a function does not return “something” that you then have to interpret. It explicitly says: everything is fine, here is the result. Or: something went wrong, here is the reason. I like the discipline behind this approach. When you work with code, especially in a team, this kind of predictability makes life much easier.
In TypeScript, I usually use a similar approach with Result:
export type Success<T> = {
status: 'success'
payload: T
}
export type Fault = {
status: 'fault'
code?: string
message?: string
details?: unknown
}
export type Result<T> = Success<T> | Fault
After that, a function can have an honest signature:
async function getUser(id: string): Promise<Result<UserDto>> {
// ...
}
From this signature, it is immediately clear: the function will either return a user or a clear error. This is not a complex architecture and not an attempt to force functional programming onto the entire project. It is just a small contract that makes function behavior more predictable.
Why I do not like implicit errors
In many projects, you can see code like this: const user = await getUser(id), followed by if (!user) { ... }. At first glance, this looks fine. But what does !user actually mean? Was the user not found? Is the database unavailable? Is access denied? Did the API return an unexpected format? Did the developer forget to handle an error inside the function? In a small project, this may be tolerable. In a larger project, these places start turning into a constant source of bugs.
That is why I prefer when a function returns not just data or emptiness, but a structured result:
const result = await getUser(id)
if (isFault(result)) {
return result
}
return success(result.payload)
There is no need to guess here. If it is fault, there is an error. If it is success, there is data. This code is slightly more verbose, but it is easier to read and maintain.
TypeScript helps prevent mistakes
This approach has another nice advantage: TypeScript starts working as an additional safety layer. If Result<T> is described as a discriminated union, TypeScript will not let you work with payload until you check the result status. For example, this code should produce a type error:
const result = await getUser(id)
console.log(result.payload)
First, you need to narrow the type:
const result = await getUser(id)
if (isFault(result)) {
return result
}
console.log(result.payload.name)
After the isFault(result) check, TypeScript understands that if we reached the next line, we are now dealing with Success<UserDto>. This is a small thing, but in practice it helps a lot. The code literally forces the developer to handle the error case before working with the data.
External data should not be trusted blindly
TypeScript checks the code we write, but it does not check reality. If an external API documentation promises an object with id: string, email: string, and balance: number, this does not mean that tomorrow in production you will not receive id as a number, email as null, and balance as a string. And if we simply write return data as UserDto, we are basically telling the application: “trust me, everything is correct there”. I prefer not to trust. I prefer to verify.
This is especially important at system boundaries: responses from external APIs, database query results, webhooks, gRPC responses, queue messages, DTOs between important application layers, configs, and environment variables. Zod works well for this:
const parsed = UserDtoSchema.safeParse(rawData)
if (!parsed.success) {
return fault({
code: 'OUTPUT_SCHEMA_ERROR',
message: 'Invalid output schema',
details: parsed.error
})
}
return success(parsed.data)
As a result, the function returns not “something that looks like UserDto”, but either guaranteed valid data or a clear error. I usually validate this kind of data on the server side. In some cases, I also validate the response on the client side, especially if the data is critical for the UI or business logic.
Why error codes matter
I do not like when an error is just a text message like Something went wrong or Invalid data. Such text is hard to use properly in logs, monitoring, tests, and client-side logic. I prefer when an error has a stable code: OUTPUT_SCHEMA_ERROR, USER_NOT_FOUND, FORBIDDEN, EXTERNAL_SERVICE_ERROR. The text can change, but the code should remain stable.
If I see OUTPUT_SCHEMA_ERROR in the logs, I immediately understand that the issue is not in the business logic, but in the fact that some layer returned data in the wrong format. If I see USER_NOT_FOUND, I know this is an expected business scenario. If I see EXTERNAL_SERVICE_ERROR, I know the problem is with an external service. This makes debugging and maintaining the project much easier.
Why I do not like hiding error meaning inside HTTP status codes
Another thing I do not like in APIs is when the meaning of an error is hidden only inside the HTTP status code. For example, you receive 404, and then you have to understand from context what exactly was not found. A user? An order? A file? An endpoint? Does the entity exist but the user has no access? Did the backend proxy the request to the wrong place? The same applies to 500: formally, it means “server error”, but for application code this is almost never enough. I need to understand what exactly happened: an external service did not respond, the response schema did not match, a record could not be created, a business rule was violated, or an unknown error occurred.
That is why I prefer to separate two levels: HTTP status is transport and infrastructure, while the response body is the result of the business operation. If the server successfully received the request, processed it, and returned a clear result, this is 200 OK for me, even if the business operation itself failed:
{
"status": "fault",
"code": "USER_NOT_FOUND",
"message": "User not found"
}
For me, this is a normal server response. The server did not crash, the endpoint exists, the request reached the application, and the code executed. The result of the operation is simply unsuccessful. But if I receive 500, that is no longer part of the normal business flow. That is a signal that an unhandled error, infrastructure problem, or some situation requiring investigation has occurred.
Throw vs Result
I do not think throw is useless. Exceptions are good for situations that are truly exceptional: infrastructure is broken, an unhandled error occurred, application state became invalid, or the code reached a branch it should never reach. But many errors in application code are expected: USER_NOT_FOUND, FORBIDDEN, VALIDATION_ERROR, OUTPUT_SCHEMA_ERROR, EXTERNAL_SERVICE_ERROR. These are not necessarily an “explosion” of the application. They are normal possible outcomes of an operation.
With throw, execution is interrupted and jumps to the nearest catch. Sometimes this is convenient, but in application logic this kind of magic often gets in the way. With Result, the flow remains linear:
const userResult = await getUser(id)
if (isFault(userResult)) {
return userResult
}
const orderResult = await createOrder(userResult.payload)
if (isFault(orderResult)) {
return orderResult
}
return success(orderResult.payload)
Yes, this is a bit more verbose. But you can see the entire scenario: where we got the user, where we created the order, and where possible errors were handled. An error here is not an exception to the flow. It is just one of the possible data types.
What about stack traces?
One argument against Result is that you can lose the stack trace that usually comes with throw new Error(). This is a fair point. I do not think fault should contain only a string. You can include technical details inside the error: the original Error, cause, stack, requestId, the external service response, and other data useful for logging.
For example:
return fault({
code: 'EXTERNAL_SERVICE_ERROR',
message: 'Payment provider request failed',
details: {
cause: error,
provider: 'stripe',
requestId,
}
})
The important thing is that you do not have to show these details to the user. But for logs and debugging, they can be very useful.
What this gives on the client side
On the client side, this approach makes response handling very simple. I do not need to build logic around a set of HTTP status codes and then once again try to understand what exactly was meant. I always look at the response body:
const result = await api.getUser(id)
if (isFault(result)) {
showError(result.message)
return
}
renderUser(result.payload)
If it is success, then payload contains data in the expected format. If it is fault, then there is a clear error with a code. This gives double control: the server does not expose data in a random format, and the client does not blindly accept whatever comes over the network.
Minimal Result helper
A basic helper can look something like this:
export type Success<T> = {
status: 'success'
payload: T
}
export type Fault = {
status: 'fault'
code?: string
message?: string
requestId?: string
details?: unknown
}
export type Result<T> = Success<T> | Fault
export function success<T>(payload: T): Success<T> {
return {
status: 'success',
payload,
}
}
export function fault(params: {
code?: string
message?: string
requestId?: string
details?: unknown
} = {}): Fault {
return {
status: 'fault',
...params,
}
}
export function isFault<T>(result: Result<T>): result is Fault {
return result.status === 'fault'
}
export function isSuccess<T>(result: Result<T>): result is Success<T> {
return result.status === 'success'
}
This is not complex architecture. It is just a shared contract. A function returns either an object with status: 'success' and payload, or an object with status: 'fault', an error code, and a message. That alone is enough to make the code much more predictable.
Is this a monad?
Formally, this approach is similar to Result / Either from functional programming. If you add methods like map, mapError, flatMap, or andThen, you can move towards a more monadic style. But I would not complicate this idea where it is not necessary.
In regular application development, I usually only need one simple rule: the result of an operation should be explicit. I am not trying to turn TypeScript code into Haskell. I just like the idea that a function should not force the calling code to guess.
Where I use this approach
I do not think every single function in a project should return Result. If a function formats a string, calculates a sum, or transforms a date, it does not necessarily need to be wrapped in success(). But if a function makes a network request, works with a database, calls an external service, receives a webhook, processes a payment, works with access rights, returns a DTO outside the current layer, or affects money, statuses, or important business entities, I prefer an explicit result.
In these places, it is better to write a little more code and get predictable behavior. In practice, this gives several benefits: the code is easier to read, errors become part of the normal data flow, tests are easier to write, monitoring is easier to connect, and the project is easier to maintain as the codebase grows. It also helps when working with AI-generated code. If a module must return Result<T>, and an external response must pass through a Zod schema, it is harder for generated code to turn into chaos. The contract limits the imagination.
Conclusion
I like writing backend code in a way that does not make the calling layer guess what happened. A function either returns valid data or a clear error. Data from the outside world goes through runtime validation. Errors have stable codes. HTTP status is responsible for the transport level, while the result of the business operation lives inside the response body.
For me, this is one of the signs of good application code: it does not just “work”, it also predictably explains what happened. This matters especially in projects with integrations, payments, webhooks, external APIs, queues, access roles, and complex business logic.
I often work with exactly these kinds of tasks: backend architecture, full-stack development, integrations, code audits, and gradually bringing existing projects into a more predictable state. If this approach feels close to how you want your project to be built, you can find more about me and my work here: petrtcoi.com.
Top comments (0)