We can improve our error handling and composition by leveraging a Result class and several other tools from the functional programming world.
Instead of throwing errors, we wrap our results. Either the Result is an Error value, or a Success value, in the process documenting the possible errors. Callers must first examine and unwrap the Result, handling either the Success or Failure case. Paving the way for more functional programming and composition.
For a more complete introduction to the Result class and Railway Oriented Programming:
- https://fsharpforfunandprofit.com/rop/
- https://dev.to/_gdelgado/type-safe-error-handling-in-typescript-1p4n
- https://khalilstemmler.com/articles/enterprise-typescript-nodejs/handling-errors-result-class/
For implementations check GitHub #railway-oriented-programming; ROP in many programming languages (Python/Go/Java/C#/F# etc)
In these series I will share my findings during my (exciting) journey.
Imperative sample
const r = doSomeAction() // Result<string, SomeVariableIsInvalid | ServiceUnavailableError>
if (r.isErr()) { // r: Error<SomeVariableIsInvalid | ServiceUnavailableError>
if (r.error instanceof SomeVariableIsInvalid) {
ctx.body = r.error.message
ctx.statusCode = 400
} else {
ctx.statusCode = 500
}
return
}
// r: Ok<string>
ctx.body = r.value
ctx.statusCode = 200
doSomeAction
could be implemented like:
function doSomeAction(): Result<string, SomeVariableIsInvalid | ServiceUnavailableError> {
if (!someVariableIsValid) {
return err(new SomeVariableIsInvalid("some variable is not valid")
}
if (!isServiceAvailable()) {
return err(new ServiceUnavailableError("The service is currently unavailable")
}
return ok("success response")
}
Functional sample
doSomeAction() // Result<string, SomeVariableIsInvalid | ServiceUnavailableError>
.map(value => {
ctx.body = value
ctx.statusCode = 200
})
.mapErr(error => {
if (error instanceof SomeVariableIsInvalid) {
ctx.body = error.message
ctx.statusCode = 400
} else {
ctx.statusCode = 500
}
})
All "operators" must live on the Result object and thus extension is harder. (This is similar to how for instance RxJS started)
Functional Composition
doSomeAction() // Result<string, SomeVariableIsInvalid | ServiceUnavailableError>
.pipe(
map(value => {
ctx.body = value
ctx.statusCode = 200
}),
mapErr(error => {
if (error instanceof SomeVariableIsInvalid) {
ctx.body = error.message
ctx.statusCode = 400
} else {
ctx.statusCode = 500
}
})
)
The operators are now just functions, easy to extend and roll our own ;-) (RxJS v5.5 users may see some similarities here)
Data last
const pipeline = pipe(
map(value => {
ctx.body = value
ctx.statusCode = 200
}),
mapErr(error => {
if (error instanceof SomeVariableIsInvalid) {
ctx.body = error.message
ctx.statusCode = 400
} else {
ctx.statusCode = 500
}
})
)
pipeline(doSomeAction())
So pipeline
is now reusable. If only tc39 proposal-pipeline-operator would land soon, so that we get syntactic sugar that will hide some boiler plate and syntactic noise :)
Building on top
Further decomposition into separate functions, so that they become re-usable, or to separate the levels of abstraction so that the pipeline becomes easier to read.
const writeSuccessResponse = value => {
ctx.body = value
ctx.statusCode = 200
}
const writeErrorResponse = error => {
if (error instanceof SomeVariableIsInvalid) {
ctx.body = error.message
ctx.statusCode = 400
} else {
ctx.statusCode = 500
}
}
const pipeline = pipe(
map(writeSuccessResponse),
mapErr(writeErrorResponse)
)
Further decomposition:
const writeSuccessResponse = value => {
ctx.body = value
ctx.statusCode = 200
}
const writeDefaultErrorResponse = error => {
ctx.statusCode = 500
}
const writeSomeVariableIsInvalidErrorResponse = error => {
if (error instanceof SomeVariableIsInvalid) {
ctx.body = error.message
ctx.statusCode = 400
}
}
const pipeline = pipe(
map(writeSuccessResponse),
mapErr(writeDefaultErrorResponse),
mapErr(writeSomeVariableIsInvalidErrorResponse),
)
Perhaps another option:
const mapErrIf = (errorHandler: error => void, predicate: error => boolean) =>
error => {
if (!predicate(error)) { return }
errorHandler(error)
}
}
// usage
mapErrIf(_ => ctx.statusCode = 400, error => error instanceOf SomeVariableIsInvalid)
And there are of course many other options and forms of composition, let that be the reader's excercise ;-)
Framework and Sample code
I'm working on an application framework while exploring these topics, that leverages the pipeline composition extensively, Sample app included!
Source code:
-
fp-app framework
- sample app
- neverthrow pipe extensions
- Uses neverthrow fork (forked from gDelgado14/neverthrow)
What's Next
Next in the series, I plan to introduce the more advanced concepts like flatMap
, toTup
, tee
and others :)
Further reading
Be sure to also check out gcanti/fp-ts; a heavily functional programming oriented library, especially v2 is looking very promising due to similar pipe composition!
Top comments (0)