DEV Community

Cover image for Error Handling and logging policy helper for neverthrow
Camilo Andres Vera Ruiz
Camilo Andres Vera Ruiz

Posted on

Error Handling and logging policy helper for neverthrow

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({...})
}
Enter fullscreen mode Exit fullscreen mode

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({...})
}
Enter fullscreen mode Exit fullscreen mode

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 to UNEXPECTED_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 as UNEXPECTED_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({...})
}
Enter fullscreen mode Exit fullscreen mode

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
})
Enter fullscreen mode Exit fullscreen mode

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)