option types in the wild

September 22, 2024

functional programmingtypescripttype theoryhaskellrustgo

Before you get to the main content of the article, I just wanted to leave you with a quick note detailing what you can expect from reading this post.

I will be using a mix of Haskell, Go, Rust, and TypeScript across the post. In case you aren't comfortable with these, let me know and I just might conjure up an example in your preferred language! Regardless of the situation, I would encourage you to explore these ideas in your preferred language. I am certain that there's potential to learn quite a bit on the journey.

Error handling is fundamental to how we write programs. They are the guardrails designed to keep performing operations in a specific manner. The main issue I have is that it is often a pain point in many languages. The tools at your disposal to deal with errors are often weak and fragile, leading to a poor developer experience. Many modern programming languages are adapting to address this concern having seen the practical impact that certain simple ideas can have in helping improving this experience. In this article, I want to show you the importance of certain ideas that help us get better at error handling.

I hope that this will be as interesting for you as it has been for me.

I also have a npm package out for option types. Yes, along with the other million that are there. Source   package

Happy Learning!

contents

current state / the problems i see

In this section, we try to get a grasp of the problem, or at least try to reason why certain points that I mention are actually problems and need to be looked into.

defence coding

I love doing “defence coding”.

What do I mean by that?

It is the act of safeguarding your program against all of the inconsistencies and errors that may occur during runtime. Everybody does it, like I do, but in my head, I think I spend a considerable amount of time on these aspects of a program.

So, you ask, “What’s bothering you?”

I say, not all of my tools place enough importance to it.

For example, take Go:

db, err := pgx.Connect(“postgres://user:password@postgres:5432/postgresdb”)

if err != nil {
    // Place your error handling code here
} 

I’d say, if you’re a lazy programmer like me, these parts are not really fun while writing code. Everything is just left to the programmer’s discretion (do I trust myself? lol) or if error’s go unhandled, the program simply crashes.

There are other scenarios where you could be dealing with different kinds of errors, but all you get to deal with is one error instance reaching you and the developer has to make sure that all of the appropriate error types are checked and handle them accordingly.

Here's a quick example to show you what I mean:

Many of you might’ve written something similar in other languages.

try {
  // Code that might throw an error
} catch (error) {
  if (error instanceof TypeError) {
    console.error("TypeError:", error.message);
  } else if (error instanceof RangeError) {
    console.error("RangeError:", error.message);
  } else {
    // yes, i gave up…please leave me alone :’(
    console.error("Unknown error:", error.message);
  }
}

Here’s another thing that may happen, like me, you get tired of handling each of the numerous error types that you might encounter and build some abstraction perhaps. Will it scale? Will it not? Who knows. Because the problem here is not about the explicit handling per se, but more about where in our code it occurs or after what sequence of events the error has occurred.

We can’t run from errors.

Errors are here to stay.

We just need to get better at dealing with them.

If these issues weren't painful enough, there's a bigger one waiting for us.

the null pointer exception problem

We've all been there. We know, for the most part, that this is an issue.

Let’s take a simple division function:

function divide(a: number, b: number): number {
  if (b === 0) {
	throw new Error(“b is 0”);
  }
  return a / b;
}

This is okay, we can work with this. It’s perfectly fine in its functionality. But the only issue I have is with how we’re dealing with the error. In case if this function was found in a library that I was using for my application, the type system isn’t helping me to know that running this function with certain inputs is can result in an exception.

To that you say, I’ll just do this:

function divide(a: number, b: number): number | null {
  if (b === 0) {
    return null;
  }
  return a / b;
}

Again, works fine. But the issue I have with this is the following.

Every time I make a call to this function, I am supposed to now handle the null. I forget to do this, everything goes out of the window. According to the compiler and every other tool you may have at your disposal, this is okay. To me, this ringing all sorts of alarm bells. Yeah, I’m like the “anger” emotion in inside out in real life. (iykyk)

const result = divide(10, 2);

if (result !== null) {
  const quotient = result;
  console.log(`Quotient: ${quotient}`);
} else {
  console.log("Division failed");
}

const badResult = divide(5, 0);
// Runtime error: will occur anywhere I try to use badResult.
// unless I deliberately check for the null everywhere

There’s plenty of work that needs to be done while writing code that you don’t end up here. The point I am trying to drive here is that, again, the expectation shouldn’t be to help you produce programs that are NPE free, but rather help you avoid it as much as possible and help you deal with such situations with grace.

In summary, we've explored the following issues with the current state of error handling in many languages:

  • Error handling in most cases is verbose
  • Compiler provides little or no help in matching errors, the emphasis is on the developer to ensure that they handle all of the scenarios.
  • The null-pointer exception problem

Relying on the status quo means leaving the door open to uncaught exceptions and unpredictable bugs. Pursuing options like these not only results in safer programs but also reduces long-term maintenance costs.

what do I want?

So, here’s what I want:

  • Support from the type system (to make sure I’ve really handled all scenarios)
  • Better error matching capabilities (no, I don’t want to see that switch statement again)
  • Avoid the usage of constructs like null, nil, undefined to define the absence of a valid value

the state-of-the-art

In some languages like Haskell, Rust, and Scala that come with robust and strict type systems the state-of-the art is on offer from the get go. In some languages as we’ll see going forward, it takes a bit of work to get there, but you can get there and get satisfactory results.

But, what does the state-of-the-art look like?

Well, it’s all the things that I mentioned above in the “What do I want?” section.

  • The language has constructs to help you match the absence of a value or errors.
  • The absence of values is not represented using null's or other similar values
  • The function signature will contain information that a real value may or may not be returned, helping the programmer to anticipate and prepare for the usage of the said function.

Before we go into detail about what the state-of-the-art is all about. I would to like to highlight some of the reasons that are at the foundation of "Why are certain languages are better at handling errors than others?"

Many of the reasons lie in programming language design and the next section will focus on providing details of some of them to help us answer the question.

To avoid these pitfalls, we need a deeper, mathematical way of thinking about types and values. Enter Category Theory, which lays the groundwork for how we can model absence and presence of values in a more structured manner.

a short primer on category theory

Let me first start by telling you a bit about Type Theory and Type Systems in general, so that you could make sense of the question “Why are different languages better at handling errors than others?”

Programming Languages like Rust and Haskell are what is known as strongly-typed and static programming languages. They’re also the state-of-the-art when it comes to typed languages. The compiler aids the developer to write “safely” typed programs. They seem like a complete paradigm shift for the most part. But produce excellent results.

Then there are languages like JavaScript, python that known as dynamic languages. The compilers used here don’t care about types as much everything in the program is data or functions. The runtime is where things are addressed.

Then come languages like Java, C, C++, and Go that are statically-typed but are known as weakly-typed languages. They have static types, but the emphasis is on the programmer to ensure that the types are in order. The compiler provides little or no help in this regard.

Then come languages like TypeScript, and C-sharp are what are known as Gradually typed languages. They neither seem to be fully in the statically typed class nor the dynamically typed class. I’m glad that there’s representation for everybody here at least.

Now, you have some idea to answer the question that was posed above.

The idea of types itself, comes from Category theory in mathematics. It’s just a fancy way of describing patterns and connections among entities within an enclosed space.

There are 2 main entities that we deal with in Category theory:

  • Objects
  • Morphisms

For us, this is how we can map them to type theory:

  • Types => Objects
  • Functions => Morphisms

If you think about it, that’s all our programs are. Now, here’s where the difference is born. Languages like Rust and Haskell have provisions to help you link these Objects (types) and morphisms better than some of the other statically typed languages we’ve mentioned.

adt's

Enter, Data Types. Algebraic Data Types.

They are one of the biggest reasons why Haskell and Rust are in a tier of their own and every other language is trying to learn from them.

They’re magical but they come at a premium. Yes, with great typing comes great responsibility.

What they essentially do is the following:

  • Help define types
  • Provide you with type constructors (methods that help produce values of said type)
  • Help you implement methods for values of this type.

While the first two points are pretty straightforward and I will show you examples for the same. I must say that the last point here is something that we will resist going into deep detail. This involves something known as Type Classes and they are a big old burly beast that we will eventually tame in a later post. What you need to understand is that these methods I am talking about are similar to implementing an interface in a lot of other languages.

Here’s what I think ADTs are best at doing: (yes, just take my word for it, please)

Composition: because Type Classes are independent entities within the program, just as much as the types. All types that “implement” these classes are immediately composable.

Here is a small example why ADTs and type classes help with composition:

type Maybe a = Just a | Nothing

-- NOTE: this is an ADT that allows us to encapsulate
-- `some` value or `no` value, but just the presence of
-- such a type would help us understand that we need to
-- prepare to check what such a value might actually be
-- and then act accordingly.

-- Safe division function
safeDivide :: Int -> Int -> Maybe Int
safeDivide _ 0 = Nothing  -- Division by zero
safeDivide x y = Just (x `div` y)

-- Calculate average salary
averageSalary :: String -> String -> Maybe Int
averageSalary totalStr countStr = do
    total <- read totalStr
    count <- read countStr
    safeDivide total count

-- Process and format result - highlights one of the strengths of using a ADT, composition
processData :: String -> String -> String
processData total count =
    case averageSalary total count of
        Just avg -> "Average salary: $" ++ show avg
        Nothing  -> "Error: Could not calculate average"

print $ processData "5000" "2"   -- Valid case
print $ processData "5000" "0"   -- Division by zero
print $ processData "5000" "two" -- Parsing error

Quick Note:
we’re not discussing Object-Oriented Programming(OOP) here. You can come at me for not mentioning OOP and composition in the same breath. What you need to understand is that it wouldn’t be beneficial in terms of the subject of the post since the underlying error handling would remain unaffected since classes are products of the type system itself. They don’t help you address the problem in any better*.* Unless you make some changes to the way you write programs.

The main point of discussing Category Theory was the following:

By applying concepts from category theory, languages like Haskell and Rust enforce that every possible outcome—whether success or failure—is handled at the type level, ensuring that errors are managed before they happen

mimicing the Option in TypeScript

Option types are also items that have birthed from the mere fact that there are languages that support ADTs. We can safely encapsulate any value and provide functions that any value of said type can execute. Yes, this can even be done for null-values too. That’s what makes this special.

Now, that you know about some underlying mechanics of these things, we can start to put all of these things together.

Let’s produce a version of an Option Type in Typescript, shall we?

we will try our best to mimic the Option or Maybe types in Rust and Haskell respectively. Trying > to take the best aspects from these worlds and bring it to the TypeScript. Maybe this could inspire you to introduce it in your own languages. So, we'll start off by seeing what they look like in Haskell and then use that knowledge to implement similar things within TypeScript

So here’s what such an option type would look like in Haskell:

type Maybe a = Some a | None

exampleMaybe1 :: Maybe String
exampleMaybe1 = Some “hello, world”

exampleMaybe2 :: Maybe Int
exampleMaybe2 = None

Here’s a better example of it with the same divide function that we saw earlier:

divide :: Int -> Int -> Maybe Int
divide a 0 = None
divide a b = Some (a `div` b)

-- Usage
case divide 10 2 of
    Some num -> putStrLn $ “quotient: " ++ num
    Nothing -> "Cannot divide by zero"

Here’s the breakdown of the differences:

  • Every time we deal with such a value, we are forced to explicitly handle each of the cases. This also comes down to the ability of programming languages to support type level pattern matching. Basically, the strength of Rust and Haskell lie in the fact that they will push you to make sure that you’ve explicitly handled all of the cases unlike other languages where it is upto the discretion of the developer. This signals clear intent. We’re asked to be sure of what we might be dealing with.
  • Null is not a thing any more. The Option or Maybe is your best bet. And None will represent the absence of a value.
  • Additionally, both Haskell and Rust provide you with a rich set of methods to deal with these “optional” values thereby making your life easier and the error handling a lot more comfortable.

Quick Note:
Now that I have explained a few of these items, I hope the golang programmers reading this are quite satisfied with how golang is designed. It doesn’t go the distance like Haskell and Rust do, but they are indeed very conscious and intentional in the direction that they’re heading in. It’s indeed a great language given its intended purpose and the deliberate sacrifices that comes with the territory.

The main difference between how Haskell or Rust does this and TypeScript is unable to is just down to the fact that TypeScript is categorised as a “structurally-typed” language and the other two are described as “nominally-typed” languages. The difference being in the way the types are checked for programs.

Here’s a quick example:

newtype UserId = UserId Int 
newtype ProductId = ProductId Int 

userId :: UserId
userId = UserId 

productId :: ProductId 
productId = ProductId 1

 if I do anything remotely close to comparing

 userId == productId, it wouldn’t be allowed

 since they each are different types. Names included.

Here’s the same in TypeScript, but it would be completely okay:

type User = { id: number };

type Product = { id: number };

const user: User = { id: 1 };
const product: Product = { id: 1 };

const sameStructure: User = product;

Here, even though we know that the variables user and product are visibly of different types. The compiler is completely ok with it since the inherent structure of both User and Product are the same, the compiler is compliant with this. That is one of the reasons why, even with typescript, robust applications come at a price. We would need to be intentional with our time to have great types and functions supporting these types since the types in the language have a completely different nature.

different flavors of Option within TypeScript

Okay, we’re here finally. How do we model the Maybe type in Typescript.

I will show you 2 flavours.

flavor 1

Here’s flavour 1, with the least amount of time and effort to produce something similar to what Rust and Haskell possess.

type Option<T> = {ok: true, value: T} | {ok:false, value?: never}

// or you could choose to split like so

type Some<T> = {ok:true, value: T};
type None = {ok: false, value?: never};
type Option<T> = Some<T> | None;

// some helper functions to help construct values of type Option
const Some = <T>(value: T): Option<T>  = > { ok: true, value, };
const None = <T>(): Option<T> => {ok: false};  

flavor 2

There’s not much to explain here. But this really shows some “desperation” to have the choice of building applications with these “Option” types. I am also aware that a lot of typescript libraries in the wild do it and do it well within the constraints. That’s the next flavour I am about to show does. It’s a bit more “robust” and “practical” and mimics the functionality available in Rust and Haskell very well to a certain extent.

type Option<T> = (Some<T> | None) & IOption<T>;
type Some<T> = { ok: true; value: T };
type None = { ok: true; value: never };

interface IOption<T> {
  get: () => T;
  get_or_else: (def: T) => T;
  is_some(): this is Some<T>;
  is_none(): this is None;
  map<U>(fn: (val: T) => U): Option<U>;
}

class OptionClass<T> implements IOption<T> {
  readonly ok: boolean;
  readonly value: T | never;
  constructor(ok: boolean, value: T) {
    this.ok = ok;
    this.value = value;
  }
  get(): T {
    if (this.ok) {
      return this.value;
    }
    throw new Error("it was none dude");
  }
  get_or_else(def: T): T {
    if (this.ok) {
      return this.value;
    }
    return def;
  }
  is_some(): this is Some<T> {
    return this.ok;
  }

  is_none(): this is None {
    return !this.is_some();
  }
  map<U>(fn: (val: T) => U): Option<U> {
    if (!this.ok) {
      return None();
    }
    return Some(fn(this.value));
  }
}

function Some<T>(value: T): Option<T> {
  const option = Object.create(OptionClass.prototype);
  option.ok = true;
  option.value = value;
  return option;
}

function None<T>(): Option<T> {
  const option = Object.create(OptionClass.prototype);
  option.ok = false;
  option.value = false;
  return option;
}

conclusion

In the journey we've taken through different programming languages, one thing stands out: error handling doesn’t have to be a painful afterthought. By relying on safer constructs like Option Types, we move away from risky practices such as using null or unchecked errors, and instead, build programs that are robust, predictable, and maintainable.

The beauty of concepts like Option or Maybe types lies in their intentionality -- they force us, as developers, to address the edge cases and potential pitfalls upfront. This not only reduces bugs but also makes the code more self-explanatory. Every function clearly signals its intent, whether it’s returning a value or nothing at all, giving us greater confidence in our code.

But what does this mean for you? It means that there’s a better way forward -- one that’s already used by languages like Rust and Haskell, and can be brought into TypeScript or any language you work with.

Pursuing these better options isn’t just about being trendy or academic; it’s about writing code that is easier to debug, maintain, and extend.

So as you continue writing software, I encourage you to explore and adopt Option Types or similar patterns in your projects. Let’s move beyond the status quo and take steps toward a future where our programs handle errors with grace, and where edge cases are a feature, not a bug.

Happy coding, and may your values always be handled option-ally!

Ciao.