About a year and a half ago, I discovered the Result pattern from functional programming, allowing me to get a type safe error handling, in typescript. There is multiple libraries for that, such as ts-results
, neverthrow
and the more ambitious Effect
but all implement the same idea.
I successfully used this pattern in my production apps using the ts-results
package at first, and then changing to neverthrow
(due to be more complete and better maintained), and it turned to be the path that allowed me to write more robust apps, with better observability, getting a consistent logging policy and robust error handling.
_
Add diagrams to example the value of this
_
Initial Aproach
At first I used it directly in all my functions and methods like this.
type FunctionError = {
code: 'UNEXPECTED_ERROR' | 'GENERIC_ERROR_1' | 'GENERIC_ERROR_2',
data?: Record<string, unknown>
}
export async function genericFunction(input: {...})
: Promise<Result<{...}, FunctionError>> {
// ...
// Something went wrong
return err({code: 'GENERIC_ERROR_1'})
// All ok
return ok({...})
}
Then when I need it to call a method or a function inside another, I did something like this:
type FunctionError = {
code: 'UNEXPECTED_ERROR' | 'UPPER_ERROR',
data?: Record<string, unknown>
}
export upperFunction(): Result<{}, UpperFunctionError> {
const res = genericFunction({...})
if (res.isErr()) {
switch(res.error.code) {
case 'UNEXPECTED_ERROR':
return err({ code: 'UNEXPECTED_ERROR' })
case 'GENERIC_ERROR_1':
const customError = { code: 'UNEXPECTED_ERROR' }
logger.error('This should not happen', {
...customError,
params: {...},
})
return customError
case 'GENERIC_ERROR_2':
return err({ code: 'UPPER_ERROR' })
}
}
// All ok, executing the next step
// ...
return ok({...})
}
That works, as a first approximation and of course can be improved in many ways, using the functional programming tools that the library gave us, and sure there will be cases when some errors are expected and even desired, so the logic will translate them into behaviors instead of another errors. but this should wrap the main idea.
One may think that it may be simpler to just combine the upper function error types (the external one) with the generic function error types (the internal one), and directly return the value, when the error doesn't match a desired value, but that will result in errors that doesn't make any sense in upper layers, let say something like 'GROUP_NOT_FOUND' returned from a function to get the purchasable items for an e-commerce.
The Problem
Looking at the code we can imagine how complex error handling can get, especially in complex services that implement use cases, the method can turn into 80% repetitive error handling, where we have to take into account: when to log and what to log and try to be consistent over all the services, something that even with a LLM code assistant helping us in the code editor, we can get it wrong.
The Solution
Wouldn't it be nice that we had a helper that implements a consistent set of rules, and it required us to handle all the posible error cases, with type safety and autocomplete. Well I just wrote something for that, you can find it in a gist in my GitHub.
How to use it
The idea with this helper is managing a logging policy and an error handling methodology similar in what is found in the Rust Language, where we split the error in expected workable errors, and critical error that should stop the program execution, and cause a system redeployment.
The handler implements the next set of rules
- All the
reservedErrorCodes
get automatically translated toUNEXPECTED_ERROR
, they are treated as non critical unexpected errors, that means that when this error is returned, let say in catch in a query function, we must log all the failure details, and the error will propagate asUNEXPECTED_ERROR
over all the function calls, that way the error is logged only once. - When the error is translated to
PANIC
the handler will call the panic function to log the cause of the critical error and finalize the process and trigger a server redeployment, depending on the project and framework, we may need modify this function. - When the expected error is translated to
UNEXPECTED_ERROR
that means when an error that it should not happen is detected, (e.g. USER_ALREADY_EXIST when we just deleted the user so probably a bug), the error is logged with error level, including all the data given to the handler. - When the error is translated to the same error or another expected the error, the error is just log with log level, that way it help us to follow the execution trace.
The next example include all this cases
import {Result, ok, err} from 'neverthrow'
import { logger } from 'logger'
import {errorHandlerResult, type ErrorBuilder} from 'utils/errorHandler'
export type GenericServiceInput = {
param1: string,
param2: number
}
export type GenericServiceOutput = {
param1: string,
param2: number
}
// This build an error type with the given codes plus the default UNEXPECTED_ERROR
export type GenericServiceError = ErrorBuilder<{
code: 'ERROR1' | 'ERROR2'
}>
export function genericService(input: GenericServiceInput) : Result<GenericServiceOutput, GenericServiceError> {
const res = processData({
param1: input.param1,
param2: 'test',
param3: input.param2
})
if (res.isErr()) return errorHandlerResult(res.error, logger, {
// The compiler force us to managing all the posible error codes, getting autocompletion for all the input codes and all the posible output codes
// This detects a unmanageable error and crash the app, so we add all the posible info to log, PANIC doesn't need to be defined in the returnable errors, the handler knows that this will throw
'PROCESS_DATA_ERROR1': {
code: 'PANIC',
data:
message: 'processData is not available',
extraParams: { ...input }
},
// The same error is keep so just log as log level, no additional data required
'ERROR1': { code: 'ERROR1' },
// error translation, log as log level, no additional data required
'PROCESS_DATA_ERROR2': { code: 'ERROR2' },
// Translation to UNEXPECTED_ERROR so include all the posible data and log as error
'PROCESS_DATA_ERROR3': {
// Keep inside the error object
code: 'UNEXPECTED_ERROR',
data: {...},
// Just for logging
message: 'processData failed when it should work ...',
extraParams: { ...input }
}
})
// Everything ok and typescript knows
console.log(res.value)
return ok({...})
}
Applying this, we can reduce the error handling code in our service, and avoid the manual logging, not only that but we can changue the logging rules in any moment (modifying the provided handler) without having to changue that in every service.
The handler includes both an errorHandlerResult
and errorHandler
so we can directly construct the result error object or not, depending on the case, e.g. we can use the standalone errorHandler
inside a map like method from neverthrow Result, that automatically transforms the returned value into a result value.
Extra Recommendations
- The handler always receive the logger instance that should be used to log the error, that ways is easy to integrate with custom logging solutions, such as pino logger. Is reccommended to implement a correlationId to follow the execution trace for each service call.
- About another logs cases, that of course depend on the project, but a reasonable rule of thumb is to log with log level, all the mutations in a consistent format like
logger.log('', {
param1: '123',
param2: 123
})
I think that following this rules, will help you to make you system more robust and observable, of course letting tracing out of the box.
If you like the idea, and have another log policy idea, let me known in the comment.
Hope it helps.
Top comments (0)