Skip to content

Instantly share code, notes, and snippets.

@leovido
Last active March 2, 2026 21:50
Show Gist options
  • Select an option

  • Save leovido/b0fd3b0c56ee04cffd547f2e44b30569 to your computer and use it in GitHub Desktop.

Select an option

Save leovido/b0fd3b0c56ee04cffd547f2e44b30569 to your computer and use it in GitHub Desktop.
Parsing network request (discriminated union)
// ❌ The classic anti-pattern: flags that can contradict each other
type FetchStateBad = {
isLoading: boolean;
data: User | null;
error: Error | null;
// What does isLoading=true, data=someUser, error=someError mean?
// This state is "impossible" but the type allows it
};
// ✅ Parse into a discriminated union where every state is valid by construction
type FetchState<T> =
| { status: "idle" }
| { status: "loading" }
| { status: "success"; data: T }
| { status: "error"; error: Error };
// State transitions are parsers: old state + event → new state
// Illegal transitions simply cannot be expressed
function transition<T>(
state: FetchState<T>,
event:
| { type: "FETCH" }
| { type: "SUCCESS"; data: T }
| { type: "ERROR"; error: Error }
): FetchState<T> {
switch (state.status) {
case "idle":
case "error":
if (event.type === "FETCH") return { status: "loading" };
return state;
case "loading":
if (event.type === "SUCCESS") return { status: "success", data: event.data };
if (event.type === "ERROR") return { status: "error", error: event.error };
return state;
case "success":
if (event.type === "FETCH") return { status: "loading" };
return state;
}
}
// Consumption is exhaustive — TypeScript forces you to handle every case
function render<T>(state: FetchState<T>) {
switch (state.status) {
case "idle": return <Idle />;
case "loading": return <Spinner />;
case "success": return <DataView data={state.data} />; // data is T, not T | null
case "error": return <ErrorView error={state.error} />; // error is Error, not Error | null
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment