<< Back to all Blogs
One Simple Trick to Better Types

One Simple Trick to Better Types

Brandon Barker

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") {
    // Here result is { status: "success"; data: string; }
    console.log(result.data.toUpperCase()) // string
  } else {
    // Here result is { status: "loading"; data: undefined; }
    console.log(result.data) // undefined
  }
}

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) {
  // what does this even mean?
}

if (result.isLoading && result.data) {
  // is the data fresh, stale, or invalid?
}

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) {
        // data is always undefined here
        return <div>Loading...</div>
    }

    if (result.isError) {
        // error is always defined, data is always undefined
        return <div>Error: {result.error}</div>
    }

    // we can be sure that data is defined here due the typesystem
    // data is always defined, error is always null
    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.