Q378: TypeScript API Modeling Drill Discriminated Unions and Runtime Guards
What Interviewers Want To Evaluate
This drill tests whether you can model API data safely in TypeScript.
Senior frontend engineers know that TypeScript protects code after data has been trusted.
External API data still needs runtime validation, narrowing, and failure handling.
The interviewer wants to see whether you can design types that make invalid UI states harder to represent.
Short Interview Answer
I use TypeScript to model the states the UI can actually be in, usually with discriminated unions for loading, success, empty, unauthorized, and error states. At API boundaries, I validate runtime data before treating it as typed. The goal is not to type everything with large interfaces; the goal is to make impossible states unrepresentable and unsafe external data explicit.
Problem Setup
Imagine an endpoint returns a user profile.
Possible outcomes:
loading
success with profile
not found
unauthorized
server error
network error
invalid response shape
A weak model:
type ProfileState = {
loading: boolean;
data?: Profile;
error?: string;
};
This allows impossible combinations:
loading true with data
error and data together
not found represented as generic error
invalid data ignored
Better State Model
Use a discriminated union:
type ProfileState =
| { status: "idle" }
| { status: "loading" }
| { status: "success"; profile: Profile }
| { status: "empty"; reason: "not-found" }
| { status: "blocked"; reason: "unauthorized" }
| { status: "error"; message: string; retryable: boolean };
Now the UI must handle each state intentionally.
The status field becomes the discriminant.
Rendering With Exhaustiveness
Example:
function ProfilePanel({ state }: { state: ProfileState }) {
switch (state.status) {
case "idle":
return null;
case "loading":
return <ProfileSkeleton />;
case "success":
return <ProfileDetails profile={state.profile} />;
case "empty":
return <NotFoundMessage />;
case "blocked":
return <LoginPrompt />;
case "error":
return <RetryPanel message={state.message} retryable={state.retryable} />;
default:
return assertNever(state);
}
}
The assertNever helper catches missing cases during compilation.
function assertNever(value: never): never {
throw new Error(`Unhandled state: ${JSON.stringify(value)}`);
}
Runtime Boundary
TypeScript cannot prove the server sent a valid profile.
This is unsafe:
const profile = (await response.json()) as Profile;
That only tells the compiler to trust you.
It does not validate the data.
Runtime Guard
A simple guard:
type Profile = {
id: string;
name: string;
role: "admin" | "member";
};
function isProfile(value: unknown): value is Profile {
if (!value || typeof value !== "object") return false;
const candidate = value as Record<string, unknown>;
return (
typeof candidate.id === "string" &&
typeof candidate.name === "string" &&
(candidate.role === "admin" || candidate.role === "member")
);
}
For larger projects, a schema library can be better.
The principle stays the same: validate before trust.
Fetch Modeling
Example function:
async function loadProfile(id: string): Promise<ProfileState> {
try {
const response = await fetch(`/api/profile/${id}`);
if (response.status === 401) {
return { status: "blocked", reason: "unauthorized" };
}
if (response.status === 404) {
return { status: "empty", reason: "not-found" };
}
if (!response.ok) {
return { status: "error", message: "Profile failed to load.", retryable: true };
}
const payload: unknown = await response.json();
if (!isProfile(payload)) {
return { status: "error", message: "Profile response was invalid.", retryable: false };
}
return { status: "success", profile: payload };
} catch {
return { status: "error", message: "Network connection failed.", retryable: true };
}
}
This function makes failure modes visible.
It does not pretend every failure is the same.
Senior Trade-Off
You do not need runtime schemas everywhere.
Use stronger validation at:
external API boundaries
localStorage hydration
URL parameter parsing
feature flag config
analytics event payloads
payment or auth flows
third-party integrations
Use lighter typing for trusted internal values.
Validation has cost.
Invalid trust has bigger cost.
API Contract Review
Ask:
Which fields are required?
Which fields are nullable?
Can enum values grow?
How are errors represented?
Does the backend version contracts?
How does the frontend handle unknown fields?
How does the frontend handle missing fields?
Good TypeScript design starts with contract clarity.
UI State Benefit
Discriminated unions help prevent:
showing stale data as fresh
rendering success UI without data
hiding unauthorized states inside generic errors
forgetting empty states
forgetting invalid response handling
They also make code review easier because state transitions are explicit.
Common Mistakes
- Using
asto silence unsafe API data. - Modeling async state with unrelated booleans.
- Treating 404, 401, network failure, and invalid data as the same error.
- Not handling new enum values.
- Creating huge types that do not match UI needs.
- Forgetting runtime validation at localStorage and URL boundaries.
- Skipping exhaustiveness checks.
Final Mental Model
Type-safe API modeling is:
clear contract
runtime validation
explicit states
exhaustive rendering
honest failure modes
TypeScript is strongest when it describes reality instead of hiding uncertainty.