Level up your Typescript game, functionally - Part 2
Prashanth R.
Posted on November 25, 2023
Welcome back to another post in the series "Level up your Typescript game". This is the second post in the series.
In the earlier post, we looked at some Typescript trends, some challenges in handling complex apps and a better pattern to model errors to avoid the try/catch hellscape. In this post, let's look at some new concepts and take some learnings from other languages and apply it to Typescript.
There are a few different types of programming languages:
(Source 1 | Source 2)
Procedural - A procedural language follows a sequence of statements or commands in order to achieve a desired output. Examples: C, C++, Java, Pascal, BASIC etc.
Object Oriented - An Object Oriented language treats a program as a group of objects composed of data and program elements, known as attributes and methods. Objects can be reused within a program or in other programs. Examples: Java, C++, PHP, Ruby etc.
Functional - Functional programming languages are constructed by applying and composing functions. It is a declarative programming paradigm in which function definitions are trees of expressions that map values to other values. Examples: Scala, F#, Erlang, Haskell, Elixir etc.
Scripting - Scripting languages are used to automate repetitive tasks, manage dynamic web content, or support processes in larger application. Examples: Javascript, Node.JS, Python, Bash, Perl etc.
Logical - A logic programming language expresses a series of facts and rules to instruct the computer on how to make decisions. Examples: Prolog, Absys, Datalog, Alma-0
All these different types of languages have their own pros and cons, design trade offs and respective use cases.
Typescript and Javascript are scripting languages by nature and we only have access to features that are part of the ECMAScript standard; which is evolving constantly and new features are added all the time. However can we take a few concepts and advanced features from other languages and apply them to Typescript now? Let's explore below.
For the remainder of this post, I want to focus on functional programming concepts because those ideas seem to have the most benefit for building modern complex applications. We'll consider languages like Scala and Rust and look at how they do things to achieve maximum productivity and effectiveness.
In many functional programming languages like Scala
and Haskell
, there is a concept called Monad
. For the rest of this post we'll focus on features of the Scala
programming language in particular.
Monads are nothing more than a mechanism to sequence computations around values augmented with some additional feature. Such features are called effects. A monad adds an effect to a value wrapping it around a context. In Scala, one way to implement a monad is to use a parametric class on the type.
class Lazy[+A](value: => A) {
private lazy val internal: A = value
}
The Lazy type wraps a value of type A, provided by a “call-by-name” parameter to the class constructor to avoid eager evaluation.
But how do we sequence computations over a value wrapped inside a monad?
Since it is cumbersome to extract the monad’s wrapped value to apply operations, we need another function that can handle this for us. In Scala this is called the flatMap
function. A flatMap
function takes as input another function from the value of the type wrapped by the monad to the same monad applied to another type. A monad must provide a flatMap function to be considered one.
def flatMap[B](f: (=> A) => Lazy[B]): Lazy[B] = f(internal)
// Using our earlier example
val lazyString42: Lazy[String] = lazyInt.flatMap { intValue =>
Lazy(intValue.toString)
}
We changed the value inside the monad without extracting it. So, any chain of flatMap invocation lets us make any sequence of transformations to the wrapped value.
Monads by nature also must also follow three laws
- Left identity
Applying a function f using the flatMap function to a value x lifted by the unit function is equivalent to applying the function f directly to the value x.
Monad.unit(x).flatMap(f) = f(x)
// With the earlier example
Lazy(x).flatMap(f) == f(x)
- Right identity
Application of the flatMap function using the unit function as the function f results in the original monadic value
x.flatMap(y => Monad.unit(y)) = x
// With the earlier example
Lazy(x).flatMap(y => Lazy(y)) == Lazy(x)
- Associativity
Applying two functions f and g to a monad value using a sequence of flatMap calls is equivalent to applying g to the result of the application of the flatMap function using f as the parameter.
x.flatMap(f).flatMap(g) = o.flatMap(x => f(x).flapMap(g))
// With the earlier example
Lazy(x).flatMap(f).flatMap(g) == f(x).flatMap(g)
If a monad satisfies the three laws, then we guarantee that any sequence of applications of the unit and the flatMap functions leads to a valid monad — in other words, the monad’s effect to a value still holds. Monads and their laws define a design pattern from a programming perspective, a truly reusable code resolving a generic problem.
Now that we've understood Monads, let's take a look at some example Monads in Scala.
-
Option
- An option type represents an optional value aka a value that is either defined or not. This provides type safety and also ensures that if the optional value is used anywhere in the code; it is functionally aware using it's monad properties.
val maybeInt: Option[Int] = Some(42) // Value defined
val maybeInt2: Option[Int] = None // No value defined
-
Either
- An Either type models a disjoint union that represents a value that is exactly one of two types.
val eitherValue: Either[String, Int] = Right(42) // Right type
val eitherValue2: Either[String, Int] = Left("42") // Left type
It is most commonly used to model Errors as opposed to throwing exceptions
val eitherSuccessOrError: Either[Error, Int] = Right(42)
val eitherSuccessOrError: Either[Error, Int] = Left(Error("no 42"))
// instead of
if (someCondition) 42 else throw new Error("no 42") else
Note: This type of composition for modeling errors of operations very similar to what we saw in our previous post with the tuple type.
Now let's try to implement these monads in Typescript so we can use these functional concepts.
The Option
type
// We wrap the value inside an object
type Some<T> = { value: T }
// We want something better than null or undefined
type None = Record<string, never>
// The Option type
type Option<T> = Some<T> | None
It's going to get cumbersome to keep wrapping our values to represent Some<T>
and None
so let's create some helper methods.
// Wrapper to create a Some<T> value
const some = <T>(value: T): Some<T> => ({ value })
// Wrapper to create a None value
const none = (): None => ({})
Now let's use our new types!
const someValue: Option<number> = some(1) // { value: 1 }
const noValue: Option<number> = none() // {}
That's really cool! Now how do we use this in a functional way?
If I want to know whether a value is defined (Some(value)) or not (None), I have to inspect my Option
type
const isDefined = <T>(option: Option<T>) => !!option?.value
This works but it's not really scaleable enough to use everywhere.
What would be cool is if we can pattern match on the type. This is implemented in a lot of functional languages. For example in Scala, this is how we can match on the Option
type.
val option: Option[Int] = Some(42)
option match {
case Some(value) => println(s"Value is defined: $value")
case None => println("Value is not defined")
}
Pattern matching is great! It allows you to functionally determine intent from the type and perform operations based on the tree.
Unfortunately Javascript doesn't support pattern matching...yet!
There's an ECMAScript proposal that is in the works to add this feature to the language! It's going to look something like this.
match (res) {
when ({ status: 200, body, ...rest }): handleData(body, rest)
when ({ status, destination: url }) if (300 <= status && status < 400):
handleRedirect(url)
when ({ status: 500 }) if (!this.hasRetried): do {
retry(req);
this.hasRetried = true;
}
default: throwSomething();
}
Well it's still a work in progress and might take some time to be added to the language. In the meantime however, let's build our own pattern matching mechanism.
To mirror the case match, let's create an interface to support the tree paths.
// Option Matchers
interface Matchers<T, R1, R2> {
none(): R1
some(value: T): R2
}
The Matchers
interface takes in a type T
for the exists path of the Option
, a type R1
for the response type of the none()
path and a type R2
for the response type of the some(v)
path.
Next, let's build a function to actually do the matching
// Option match function
const match =
<T, R1, R2>(matchers: Matchers<T, R1, R2>) =>
(option: Option<T>) =>
'value' in option ? matchers.some(option.value) : matchers.none()
The above match function takes in the matchers interface object and applies it to the actual Option<T>
value. Internally it checks the signature of the Some<T>
type and if it exists, it calls the matchers.some(..)
fn and if not the matchers.none()
fn to satisfy both tree paths.
Now let's see this in action
const someValue: Option<number> = some(42) // { value: 42 }
const noValue: Option<number> = none() // {}
const result = match({
some: (value) => value
none: () => undefined
})(someValue) // 42
const result2 = match({
some: (value) => value
none: () => 'default'
})(noValue) // 'default'
This seems to work really nicely! Now let's compose this in a pipeline flow. Due to the match
functions functional nature, we can apply it easily. A pipeline pipes the result of each operation as an input to the next operation in a sequence.
op1 => op2(op1_result) => op3(op2_result) => ...
To create our pipeline, I'm going to use the pipe
function from the NodeJS ramda library instead of building my own.
const optionPipeline = pipe(
() => some(42),
match({
some: (value) => value + 1,
none: () => undefined,
}),
(input) => input.toString()
) // { value: 42 } => 42 + 1 => 42.toString() => '42'
Notice how this flows really nicely! We created an Option<Int>
, passed it through our custom match function which added 1 to our Option if it exists and then converted the result to a string.
Imagine using this to build complex pipelines in your apps; with the functional type safety you don't have to worry about those pesky "what if this is undefined?" checks!
Next let's look at the Either
type and define that in Typescript
The Either
Type
type Right<T> = { value: T }
// We are using the Left type to model errors
type Left<E = Error> = { error: E }
// The Either Type
type Either<E = Error, T> = Right<T> | Left<E>
With the new Either<E, T>
type, we are primarily modeling a union that can be either a Left
type or Error and a Right
type that can be a success value type.
Just like we did with the Option
type, let's create some utilities and match functions to make the type practical.
Wrappers for the Either type can be done this way
const right = <T>(value: T): Right<T> => ({ value })
const left = <E>(error: E): Left<E> => ({ error })
Next let's create our matchers interface
interface Matchers<E extends Error, T, R1, R2> {
left(error: E): R1
right(value: T): R2
}
Similar to the Option
case, the matchers for the Either
type now takes in an extra argument for the Error type since Either consists of 2 generic types (left error type and right value) as opposed to the Option that only had 1 generic Type (the value)
And our match function now looks like this
const match =
<E extends Error, T, R1, R2>(matchers: Matchers<E, T, R1, R2>) =>
(either: Either<E, T>) =>
'value' in either
? matchers.right(either.value)
: matchers.left(either.error)
We are using a similar implementation for the match function as we did in the Option
case by inspecting the either object and looking for a property (value
) that is in the Right
case ({ value }
) but not in the left case ({ error }
).
Note that this is just one implementation but you can also have your left and right types take any shape or form and update the match function to match your signatures. Another common option to do this is to use a discriminator type as follows.
type Right<T> = { typename: 'right', value: T }
type Left<E> = { typename: 'left', error: E }
And your match function can inspect the typename
property instead.
But circling back, let's use our new types in a practical pipeline flow
const right: Either<Error, number> = right(42) // { value: 42 }
const left: Either<Error, number> = left(new Error('error')) // { error: Error('error') }
const eitherPipeline = pipe(
() => right,
match({
right: (value) => value + 1,
left: () => undefined,
}),
(input) => input.toString()
) // { value: 42 } => 42 + 1 => 42.toString() => '42'
And there you have it, we implemented a functional type for Either
that is practical and can scale.
Here's another functional use case. Let's redo our tuple implementation from Part 1 using the Either
type instead of the Tuple type.
const getDataFromDB =
async (ctx: Context): Promise<Either<Error, Data>> => {
try {
const result = await ctx.db.primary.getOne()
return right(result)
} catch (err) {
console.error('Error getting data from DB', err)
return left(err)
}
}
const app = async () => {
const eitherData = await getDataFromDB(ctx)
match({
left: (err) => throw err,
right: (value) => value
})(eitherData)
}
In my opinion, this looks like a very pragmatic way to compose results of operations.
In the next and final post, let's look at an alternative type to the Either
type that takes inspiration from the Rust
programming language. We will also summarize what we have learned along with a handy library that encompasses all these concepts.
Bonus: If you like the idea of pattern matching, also check out this library that can help you with pattern matching use cases until the ECMAScript pattern matching standard is added to Javascript.
Congrats on making it to the end of this post! You have leveled up 🍄🍄
If you found this post helpful, please upvote/react to it and share your thoughts and feedback in the comments.
Onwards and upwards 🚀
Posted on November 25, 2023
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.