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
to2_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
Type | Possible Values | Cardinality | Notes |
---|---|---|---|
null | null | 1 | Only one value, super simple |
"a" | "b" | "a" , "b" | 2 | Two choices |
boolean | undefined | true , false , undefined | 3 | Three options |
{ foo: boolean; bar: boolean } | {true,true} , {true,false} , {false,true} , {false,false} | 4 | All combinations of two booleans |
boolean | undefined | 1 | 2 | true , false , undefined , 1 , 2 | 5 | Mix of booleans, numbers, and undefined |
{ foo: boolean | undefined; bar: boolean } | All combinations of foo (true , false , undefined ) and bar (true , false ) | 6 | 3 × 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
andisComplete = true
isError = true
andisComplete = 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.