One Simple Trick to Better Types

1 month ago 2
<< Back to all Blogs

Brandon Barker17 September 2025

Ever wondered why you're always checking unnecessary conditionals or null/undefined states? Why your code is full of defensive if statements that feel redundant?

The problem isn't your coding - it's that your types allow impossible states. When you model data incorrectly, you end up with these unnecessary checks throughout your codebase, whether you're writing Rust, TypeScript, Go, or any other typed language.

Thinking of types as sets of values gives you a simple mental model to eliminate this entirely.

Types Are Sets

Every type can be thought of as a set of possible values it can take:

  • boolean is the set {true, false}
  • i32 is the set of values from -2_147_483_648 to 2_147_483_647
  • A union like "a" | "b" | "c" is the set {"a", "b", "c"}

Cardinality

In set theory, cardinality means the size of a set. In any typed language, it tells us how many possible values a type can hold.

  • Notation: |THING|
  • |boolean| = 2
  • |number (i32)| = 2^32
TypePossible ValuesCardinalityNotes
nullnull1Only one value, super simple
"a" | "b""a", "b"2Two choices
boolean | undefinedtrue, false, undefined3Three options
{ foo: boolean; bar: boolean }{true,true}, {true,false}, {false,true}, {false,false}4All combinations of two booleans
boolean | undefined | 1 | 2true, false, undefined, 1, 25Mix of booleans, numbers, and undefined
{ foo: boolean | undefined; bar: boolean }All combinations of foo (true, false, undefined) and bar (true, false)63 × 2 combinations

Once you start considering the size of your types, it becomes clear how easy it is to introduce unnecessary states - the exact source of all those redundant conditionals you've been writing.

How This Eliminates Defensive Coding

Imagine you're writing a function that returns the status of a process:

interface Status { isLoading: boolean; isError: boolean; isComplete: boolean; } function getStatus(...): Status { ... }

At first glance, this looks fine. But what's the cardinality of this type?

  • |Status| = 2 * 2 * 2 = 8

That's 8 possible states. Do we really have 8 meaningful states? No. We only care about three:

  • loading
  • error
  • complete

But our type allows impossible states like:

  • isLoading = true and isComplete = true
  • isError = true and isComplete = true

These don't make sense, but the type system won't stop you.

Instead, we can model this as a discriminated union:

type Status = "loading" | "error" | "complete"

Now the cardinality is exactly 3. No extra states. No ambiguity. No bugs caused by forgetting to check for invalid combinations.

Key principle: Minimize the number of representable states. Make invalid states impossible.

Background Knowledge: Unions of Objects

Union types don't just work with simple values, they can also work with objects. For example:

type Result = | { status: "loading"; data: undefined } | { status: "success"; data: string }

Here:

  • status acts as a discriminator.
  • The type of data depends on which branch of the union we're in.

Most modern typed languages can narrow the type automatically when you check the discriminator:

function process(result: Result) { if (result.status === "success") { console.log(result.data.toUpperCase()) } else { console.log(result.data) } }

Notice how the type system doesn't merge the two object types into a single type; it keeps them separate in the union and narrows correctly when you check status.

A Real World Example: TanStack Query

Libraries like TanStack Query take this principle seriously. Here's a simplified look at its result types:

export interface QueryObserverPendingResult<TData, TError> { data: undefined error: null isError: false isPending: true status: 'pending' } export interface QueryObserverLoadingErrorResult<TData, TError> { data: undefined error: TError isError: true isPending: false status: 'error' } export interface QueryObserverSuccessResult<TData, TError> { data: TData error: null isError: false isPending: false status: 'success' } export type QueryObserverResult<TData, TError> = | QueryObserverPendingResult<TData, TError> | QueryObserverLoadingErrorResult<TData, TError> | QueryObserverSuccessResult<TData, TError>

Notice how the type system prevents nonsense like isError = true and data being defined at the same time. Each variant has a specific, valid combination of values. You can't express impossible states.

Why This Matters in Practice

Without this design, its possible to have states like these that you should be checking for correctness (as they can happen).

if (result.isError && result.data) { } if (result.isLoading && result.data) { }

Both of these conditions represent impossible or contradictory states, but the type system won't stop you if your type design allows it.

With discriminated unions, your code becomes simpler and safer:

export function ExampleComponent() { const result = useQuery({ queryKey: ["fruits"], queryFn: fetchData, }) if (result.isPending) { return <div>Loading...</div> } if (result.isError) { return <div>Error: {result.error}</div> } return ( <div> <pre>{JSON.stringify(result.data, null, 2)}</pre> </div> ) }

Now the type system guarantees that when you're in the success branch, data is always there. No redundant null checks, no impossible branches, and no ambiguity.

Modeling Your Domain Properly

Example: Payments

interface Payment { amount: number; receiptId: string | undefined; error: string | undefined; isPending: boolean; isFailed: boolean; isSucceeded: boolean; }

This has a number of impossible states, but only three matter. Better:

type Payment = | { status: "pending"; amount: number } | { status: "failed"; amount: number; error: string } | { status: "succeeded"; amount: number; receiptId: string }

Example: Authentication

interface UserSession { isAuthenticated: boolean; user?: User; }

What happens if isAuthenticated = true but user is undefined? That's an invalid state. Instead:

type UserSession = | { status: "guest" } | { status: "authenticated"; user: User }

The Takeaway

When designing APIs in any typed language:

  • Think of types as sets
  • Think about how many states you are making
  • Eliminate invalid states by using discriminated unions, enums, or sum types
  • Model your domain truthfully so the type system enforces business logic

This approach works whether you're using TypeScript unions, Rust enums, Go interfaces, or Haskell algebraic data types.

But if you really want safer APIs, fewer bugs, and code that's easier to reason about, you need more than just good type design. You need a team that understands how to build robust systems from the ground up.

That's where Mechanical Rock comes in. We help teams architect and build production-ready applications that scale. Get in touch if you want to discuss how we can help your team ship better software.

Mechanical Rock is Australia's leading cloud software development, data, and AI consultancy, and we work with some of the biggest names in industry to deliver efficient solutions to complex problems. We help companies deliver better software, faster.

We acknowledge the traditional owners of the land on which we meet, the Whadjuk Noongar people of Western Australia, and pay our respects to Elders past and present.

© Mechanical Rock 2025

Read Entire Article