Level up your Typescript game, functionally - Part 2

prashanthr

Prashanth R.

Posted on November 25, 2023

Level up your Typescript game, functionally - Part 2

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)

  1. 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.

  2. 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.

  3. 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.

  4. 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.

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

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

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

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

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.

[ Scala Monads By baeldung ]

Now that we've understood Monads, let's take a look at some example Monads in Scala.

  1. 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
Enter fullscreen mode Exit fullscreen mode
  1. 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
Enter fullscreen mode Exit fullscreen mode

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

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

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

Now let's use our new types!

const someValue: Option<number> = some(1) // { value: 1 }
const noValue: Option<number> = none() // {}
Enter fullscreen mode Exit fullscreen mode

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

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

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

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

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

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

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

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

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

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

Next let's create our matchers interface

interface Matchers<E extends Error, T, R1, R2> {
    left(error: E): R1
    right(value: T): R2
}
Enter fullscreen mode Exit fullscreen mode

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

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

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'

Enter fullscreen mode Exit fullscreen mode

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

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 🚀

💖 💪 🙅 🚩
prashanthr
Prashanth R.

Posted on November 25, 2023

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related