Level up your Typescript game, functionally - Part 1

prashanthr

Prashanth R.

Posted on November 25, 2023

Level up your Typescript game, functionally - Part 1

Welcome to the first post in the series "Level up your Typescript game, functionally".

Typescript is awesome. It is basically Javascript with superpowers. I can't imagine writing Javascript code today without types.

Statistics

Let's take a look at the state of programming languages. When we compare 2020 to 2023, we can see that there is rise in the popularity of Typescript, Python and GO.

Language % of coders % of coders % change
2020 2023
Javascript 67.70 63.61 πŸ”» 4.09
Typescript 25.40 38.87 πŸ”Ί 13.47
HTML/CSS 63.10 52.97 πŸ”» 10.3
Python 44.10 49.28 πŸ”Ί 5.18
SQL 54.70 48.66 πŸ”» 6.04
Java 40.20 30.55 πŸ”» 9.65
Shell (Bash etc.) 33.10 32.37 πŸ”» 0.73
C# 31.40 27.62 πŸ”» 3.78
PHP 26.20 18.58 πŸ”» 7.62
C++ 23.90 22.42 πŸ”» 1.48
Go 8.80 13.24 πŸ”Ί 4.44

Source: Stack Overflow Survey 2020 & 2023

Let's take a look at the rising popularity of Typescript when compared to Javascript in the web ecosystem.

js-vs-ts-balance-img

Source: 2022 State of JS

We can certainly infer that Typescript adoption has grown and is steadily on the rise.

By the way, here's a fun documentary video about the history of typescript. It has a lot of interesting tidbits about timing and decisions that were made in the early days.

State of the union

Today there's tonnes of information and communities around typescript.

Note: If you are new to typescript, I highly recommend heading over to TotalTypescript and checking out the professional tutorials by the experienced Matt Pocock

Here's some sample type safe JavaScript code using Typescript.

type User = {
  id: number,
  name: string
}

const getUser = (): User => {
  return {
    id: 1,
    name: 'John'
  }
}
Enter fullscreen mode Exit fullscreen mode

The above code ensures that at compile time we have checked our types and are operating on safe code.

What about asynchronous code involving I/O?

Well...this is what it looked like before 2017.

const aPromise = (): Promise<Data> => {
  // Some API call / IO 
  return Promise.resolve(data)
}

const main = () => {
  aPromise().then(data => {
      // Do something with result
  })
}

main().then(...)
Enter fullscreen mode Exit fullscreen mode

It doesn't look great but it got better after the addition of async/await which was introduced as part of the ECMAScript 2017 standard.

const aPromise = async () => {
  // Some API call / IO 
  return Promise.resolve(data)
}

const main = async () => {
  const result = await aPromise()
  // Do something with result
}
Enter fullscreen mode Exit fullscreen mode

But what about errors? Well...we can use try/catch

const main = async () => {
  try {
     const result = await aPromise()
    // Do something with result
  } catch (err) {
    // Do something with error
  } finally { // optional block
    // No matter what happens run this code
  }
}
Enter fullscreen mode Exit fullscreen mode

This definitely works and does a much better job handling errors and unexpected things with I/O. But to be honest, it's not the cleanest code to look at.

Also what happens when you have to write a lot of I/O code that can potentially fail?

You get into try/catch hellscape of course.


// This promise will succeed
const promise1 = async () => { return Promise.resolve(1) }

// This promise will return an error
const promise2 = async () => { return Promise.reject(new Error('boom') }

// This promise could return an error or succeed
const promise3 = async () => { 
  const promiseResults = [
    Promise.resolve(1), 
    Promise.reject(new Error('boom')
  ]

 // Randomly pick the success or error promise
 return promiseResults[
   Math.floor(
     Math.random() * promiseResults.length
   )
 ]
}

const getRequiredData = async () => Promise.all(
  promise1, 
  promise2
)

const getResult = async (data) => promise3(data)

// Catch and throw
const app1 = async () => {
   try {
     const data = await getRequiredData()
     const result = await getResult(data)
     return result
   } catch(err) {
      // Note that we can't tell what promise failed from the try block
      console.error(`Something failed in the promise block`, err)
      throw err
   }
}

// OR don't catch anything and let it bubble up. 
// Looks nicer but no utility
const app2 = async () => {
  const result = await getResult(await getRequiredData())
  return result
}

Enter fullscreen mode Exit fullscreen mode

Notice the difference between app1 and app2 above.

In the app1 case, we catch everything in the chain and can handle it but we have no information on what caused the error or what the source was, we simply know that something failed in the higher order operation. Furthermore, writing try/catch blocks for every async call in our entire app flow is going to get pretty messy, pretty fast.

In the case of app2, we don't do any error handling and let everything bubble up to the caller. If this was the root call, our app would have crashed with no handling; and even worse, if we didn't have a process monitor to reboot our app when it fails, we're out of luck unless we manually intervene.

Both of the above cases are not ideal. We can do better. We must do better.

Why are exceptions bad?

Here's a good breakdown from Dan Imhoff's post

Exceptions are not type-safe. TypeScript cannot model the behavior of exceptions. Checked exceptions (like in Java) will likely never be added to TypeScript. Not all JavaScript exceptions are Error objects (you can throw any value). JavaScript exceptions have always been and will always be type-unsafe.

Exceptions are often difficult to understand. It is unclear whether or not a function throws exceptions unless it is explicitly documented. Even source code inspection makes it difficult to know if exceptions might be thrown because of how exceptions propagate. On the other hand, if a Result-returning function is inspected, the error cases become immediately clear because errors must be returned.

Exceptions (may) have performance concerns. This isn’t hard science and likely completely negligible, but JavaScript engines in the past have struggled to optimize functions that use exceptions because pathways become unpredictable when the call stack can be interrupted at any time from anywhere.

So in summary, exceptions are pretty bad and unreliable to code against.

Now let's take a look at a solution to model our operations without using exceptions by creating custom functional types in Typescript.

The ResultTuple type

First let's represent our success and error using unit types

// An operation result can be anything
type OpResult<T> = T

// An operation error can be any custom error type or the Error type itself (default)
type OpError<E = Error> = E
Enter fullscreen mode Exit fullscreen mode

Next, let's create a common type to represent the result and/or error of an operation.

// A unified type to represent the operation 
// result with both the success and error types.
// Either one or both can be defined
type ResultTuple<T, E = Error> = [ OpResult<T>?, OpError<E>? ]
Enter fullscreen mode Exit fullscreen mode

Let's say we have a function that takes in a list of numbers and sums them. This is what the signature would look like

const add = (nums: number[]): number => {
  return nums.reduce((acc, c) => acc + c, 0)
}
Enter fullscreen mode Exit fullscreen mode

To be more declarative, using the newly created ResultTuple type we can now represent the same function as follows.

// Return type with explicit result and implicit Error type
const add = (nums: number[]): ResultTuple<number> => {
  // first argument is the success type
  // second argument is undefined; same as [result, undefined]
  return [
    nums.reduce((acc, c) => acc + c, 0)
  ]
}
// OR Return type with explicit Error type
const add = (nums: number[]): ResultTuple<number, Error> => {
  return [
    nums.reduce((acc, c) => acc + c, 0)
  ]
}
Enter fullscreen mode Exit fullscreen mode

We have created an explicit definition for the function that has both a success type and the error type. So callers can infer more from the result via ResultTuple.

const app = () => {
  // We can easily infer error or result from the operation
   const [result, error] = add([1,2,3])

   // If error is defined, do something with it
   if (error) { throw error }

   // If result is defined, do something with it
   if (result) { return result }
}
Enter fullscreen mode Exit fullscreen mode

With this approach to the add function, we can de-structure both the result and error from the function call and act appropriately. This makes so much more sense because it's declarative and explicit and you know what you're getting when you call the add function.

Now, let's look at an error handling case

const divide = (dividend: number, divisor: number): number => {
  if (divisor === 0) throw new Error('Cannot divide by zero')
  return dividend/divisor 
}

// Rewrite the same function using our ResultTuple type

const divide = (
  dividend: number, 
  divisor: number
): ResultTuple<number> => {
  if (divisor === 0) {
    return [
      undefined,
      new Error('cannot divide by zero')
    ]
  }
  return [dividend/divisor]
}

const app = () => {
  const [result, error] = divide(1, 0)
  if (error) { throw error }
  if (result) { return result }
}
Enter fullscreen mode Exit fullscreen mode

This works really well, doesn't it?

Now let's take a look at an asynchronous example. As we've seen before; this is how we would do it if we only used try/catch to handle our exceptions everywhere.

// A sample result data type
type Data = { id: string, value: string }

const getDataFromDB = 
  async (ctx: Context): Promise<Data> => {
    try {
      const result = await ctx.db.primary.getOne()
      return result
    } catch (err) {
      console.error('Error getting data from DB', err)
      throw err
    }
  }

// We have to catch here too and throw
const app = async () => {
  try {
   const data = await getDataFromDB(ctx)
  } catch (err) {
    throw err
  } 
}
Enter fullscreen mode Exit fullscreen mode

Now let's add in the new paradigm of ResultTuple and this is what we would get

const getDataFromDB = 
  async (ctx: Context): Promise<ResultTuple<Data>> => {
    try {
      const result = await ctx.db.primary.getOne()
      return [result]
    } catch (err) {
      console.error('Error getting data from DB', err)
      return [undefined, err]
    }
  }

// The wrapper call becomes really simple
const app = async () => {
   const [result, error] = await getDataFromDB(ctx)
   if (error) { throw error }
   if (result) { return result } 
}
Enter fullscreen mode Exit fullscreen mode

Using this pattern, our operations become more explicit and are super easy to reason about.

Note that we only used try/catch around the fn ctx.db.primary.getOne() to get data from the DB assuming we don't control this operation and it's behavior. We could also easily create a wrapper for the DB so we can catch errors internally and expose the the ResultTuple to callers.

You can also get really loose or strict with the types. For example, what if you wanted to define your own error type, you can do it this way.

// Custom error class that extends the JS Error interface
class AppError implements Error {
   name: string
   message: string
   constructor(message: string) {
    this.name = 'AppError',
    this.message = message
   }
}

// We create a new ResultTuple which overloads our Error type
// to be our base AppError instead of the default Error interface
type CustomResult<T, E = AppError> = ResultTuple<T, E>
Enter fullscreen mode Exit fullscreen mode

Then you can use CustomResult<T, AppError> or CustomResult<T> everywhere instead of ResultTuple<T, E>.

const mySuccessFn = (): CustomResult<string> => { 
  return ["hello world"]
}
// OR
const myErrorFn = (): CustomResult<string> => {
  return [undefined, new AppError('boom')]
}
Enter fullscreen mode Exit fullscreen mode

Using this pattern you can get more advanced with your error handling too

class AppError implements Error { ... } // see above
class DBError extends AppError { ... }
class OtherError extends AppError { ... }

// We can use the same custom result type
type CustomResult<T, E = AppError> = ResultTuple<T, E>

// Example use case where op fails when fetching data from DB
// Note that CustomResult already has AppError as the default
// Error type which means any class that inherits AppError 
// such as DBError can be safely returned
const getFromDB = (): Promise<CustomResult<Data>> => {
  try {
    ...
  } catch (err) {
    return [undefined, new DBError(...)] // works!
  }
}
Enter fullscreen mode Exit fullscreen mode

The original definition of ResultTuple was designed in a way where either argument was optional. In order to tighten the type and usage, we can re-write it so that at most one argument should be specified that way each operation can only return a result or error but not both.

// Original definition where either argument can be present or undefined
type ResultTuple<T, E = Error> = [ OpResult<T>?, OpError<E>? ]

// New definition where only one argument can be defined
type ResultTuple<T, E = Error> = 
  [ OpResult<T>, undefined ] | [ undefined, OpError<E> ]

Enter fullscreen mode Exit fullscreen mode

The new definition ensures that either the result or error is defined but not both at the same time. This is even better because this makes all operations that return this type choose one or the other and return data correctly. This is probably the safer pattern but it depends on your use case. What if you have operations that return partial results and errors? In that case, you are better off using the original definition so you can return both partial results and errors so callers can do as they please with that information. For example return [partialResults, partialErrors]

So we went through a lot of stuff in this article but in summary we saw how we went from traditional typescript programming to a pattern of functionally representing the results of operations using the ResultTuple type which is more declarative and scales based on practical use cases and avoids the Exception handling nightmare.

What if we wanted to make this even better? Typescript is so powerful and packed with so many features that we could use it to our advantage to boost our productivity and be declarative for complex use cases and flows.

In the next post, we will take a deeper look at functional programming and how we can apply it to Typescript to truly level up your code!

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