Level up your Typescript game, functionally - Part 1
Prashanth R.
Posted on November 25, 2023
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.
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'
}
}
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(...)
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
}
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
}
}
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
}
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
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>? ]
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)
}
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)
]
}
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 }
}
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 }
}
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
}
}
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 }
}
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>
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')]
}
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!
}
}
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> ]
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 π
Posted on November 25, 2023
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.