TypeScript System Design Decisions
System design decisions and tradeoffs for TypeScript frontend work.
📘 TypeScript System Design Decisions (Frontend – Senior Level)
Table of Contents
Part 1: System Design Principles
- What "System Design" Means in Frontend + TypeScript
- Core Principle: Types at Boundaries
- Preventing Impossible States
- API Layer Design in TypeScript
- Designing Reusable Hooks
- Designing Component APIs
- Type Organization Strategy
- When to Use interface vs type
- Strictness Strategy
- Avoiding Over-Engineering
Part 2: Real Frontend Interview Coding Problems
- Problem 1: Type-Safe API Fetcher
- Problem 2: Event System with Type Safety
- Problem 3: Discriminated Reducer
- Problem 4: Generic List Component
- Problem 5: Safe Object Access
- Problem 6: Form Validation Result
- What Interviewers Evaluate
- Final Senior-Level Summary
1. What "System Design" Means in Frontend + TypeScript
In frontend interviews, TypeScript system design does not mean backend architecture.
It means:
- How you model data and state
- How you prevent invalid states
- How you scale types with app growth
- How you design APIs between components, hooks, services
- How you balance safety vs complexity
Interviewers evaluate:
- Thought process
- Trade-offs
- Maintainability
2. Core Principle: Types at Boundaries
Boundaries are:
- API responses
- Component props
- Hook return values
- Redux / store slices
- Form schemas
Rule
Be strict at boundaries, flexible internally.
Example: API Boundary
type ApiUser = {
id: string;
name: string;
created_at: string;
};Convert once:
type User = {
id: string;
name: string;
createdAt: Date;
};
function mapUser(api: ApiUser): User {
return {
id: api.id,
name: api.name,
createdAt: new Date(api.created_at),
};
}Why this matters:
- Prevents date bugs everywhere
- Centralized conversion
- Cleaner UI code
Flowchart: Convert Once at the Boundary
Be strict at the boundary and convert there, so the rest of the app never sees the raw API shape.
3. Preventing Impossible States (Most Important)
❌ Bad State Design
type State = {
loading: boolean;
data?: User[];
error?: string;
};Problem:
loading = falseanddata = undefineddataanderrorcan exist together
Flowchart: Impossible States
Independent fields allow combinations that make no sense, while a status union only has the states you meant.
✅ Correct State Design (Discriminated Union)
type State =
| { status: "idle" }
| { status: "loading" }
| { status: "success"; data: User[] }
| { status: "error"; error: string };Benefits:
- Impossible states eliminated
- Compiler enforces correctness
- Cleaner UI logic
4. API Layer Design in TypeScript
API Response Pattern
type ApiSuccess<T> = {
success: true;
data: T;
};
type ApiFailure = {
success: false;
error: string;
};
type ApiResponse<T> = ApiSuccess<T> | ApiFailure;Usage:
function handleResponse(res: ApiResponse<User>) {
if (res.success) {
res.data.name;
} else {
res.error;
}
}Why interviewers like this:
- Forces error handling
- No
try/catchabuse - Clear control flow
Flowchart: Handling an ApiResponse
The success flag narrows the union, so you cannot read data on a failure or error on a success.
5. Designing Reusable Hooks (Typed Correctly)
Bad Hook Design
function useUser() {
return { data: null, loading: false };
}Good Hook Design
type UseUserResult =
| { status: "loading" }
| { status: "success"; user: User }
| { status: "error"; error: string };
function useUser(): UseUserResult {
// implementation
}Why:
- Consumer must handle all cases
- No undefined checks
- Scales well
Flowchart: Consuming a Typed Hook
The consumer is forced to handle every case, and never needs an undefined check.
6. Designing Component APIs (Senior Signal)
Bad Component API
<Button disabled={true} loading={true} />Ambiguous:
- Can be disabled AND loading
Flowchart: A Component API With States
One state prop replaces two booleans, so the ambiguous combination cannot be written.
Good Component API
type ButtonProps =
| { state: "idle"; onClick: () => void }
| { state: "loading" }
| { state: "disabled" };
function Button(props: ButtonProps) {}Result:
- Invalid combinations impossible
- Better UX guarantees
7. Type Organization Strategy (Large Codebases)
Recommended Structure
/types
api.ts
domain.ts
ui.tsapi.ts→ server contractsdomain.ts→ business modelsui.ts→ component-level types
Why:
- Clear ownership
- Easier refactors
- Faster onboarding
8. When to Use interface vs type (System-Level Decision)
Use interface when:
- Public APIs
- Object shapes
- Library exports
Use type when:
- Unions
- Intersections
- Conditional types
- Utility types
Interview one-liner:
Interfaces describe shapes, types describe logic.
Flowchart: interface or type?
Interfaces describe shapes, types describe logic.
9. Strictness Strategy
Always Enable
{
"strict": true,
"strictNullChecks": true,
"noImplicitAny": true
}Exception Handling
- Allow
anyonly at external boundaries - Wrap unsafe values with validators
Flowchart: Where any Is Allowed
any is acceptable only at the outer edge, and it is wrapped by a validator before it reaches typed code.
10. Avoiding Over-Engineering (Very Important)
Senior engineers know when not to use complex types.
Bad:
type DeepMapped<T> = {
[K in keyof T]: T[K] extends object ? DeepMapped<T[K]> : T[K];
};Good:
interface User {
id: string;
name: string;
}Rule:
Types should help humans first, compiler second.
Flowchart: Is This Type Worth It?
Types should help humans first, and the compiler second.
📘 Real Frontend Interview Coding Problems (TypeScript)
Problem 1: Type-Safe API Fetcher
Question
Create a typed fetcher that:
- Returns data on success
- Returns error on failure
- Is generic
Solution
type FetchSuccess<T> = {
ok: true;
data: T;
};
type FetchError = {
ok: false;
error: string;
};
type FetchResult<T> = FetchSuccess<T> | FetchError;
async function fetcher<T>(url: string): Promise<FetchResult<T>> {
try {
const res = await fetch(url);
if (!res.ok) {
return { ok: false, error: "Request failed" };
}
const data = await res.json();
return { ok: true, data };
} catch {
return { ok: false, error: "Network error" };
}
}Problem 2: Event System with Type Safety
Question
Implement a typed event emitter.
Solution
type Events = {
login: { userId: string };
logout: undefined;
};
function emit<K extends keyof Events>(event: K, payload: Events[K]) {}Usage:
emit("login", { userId: "123" });
emit("logout", undefined);Problem 3: Discriminated Reducer
Question
Write a reducer with exhaustive checks.
Solution
type Action =
| { type: "ADD"; value: number }
| { type: "REMOVE"; value: number };
function reducer(state: number, action: Action): number {
switch (action.type) {
case "ADD":
return state + action.value;
case "REMOVE":
return state - action.value;
default:
const _exhaustive: never = action;
return state;
}
}Problem 4: Generic List Component
Question
Create a reusable typed list component.
Solution
type ListProps<T> = {
items: T[];
renderItem: (item: T) => string;
};
function List<T>({ items, renderItem }: ListProps<T>) {
return items.map(renderItem).join(",");
}Problem 5: Safe Object Access
Question
Create a function to safely access object keys.
Solution
function getValue<T, K extends keyof T>(obj: T, key: K): T[K] {
return obj[key];
}Usage:
getValue({ name: "Sayan" }, "name"); // stringProblem 6: Form Validation Result
Question
Model a form validation response.
Solution
type ValidationResult =
| { valid: true }
| { valid: false; errors: Record<string, string> };Usage:
function submit(result: ValidationResult) {
if (!result.valid) {
result.errors.email;
}
}11. What Interviewers Evaluate in These Problems
They look for:
- Correct type modeling
- No
any - Exhaustive handling
- Clean APIs
- Readability
Not:
- Clever tricks
- Over-engineered generics
- Complex conditional types without need
12. Final Senior-Level Summary
Strong frontend engineers use TypeScript to design correct systems, not just to fix errors.