TypeScript for Frontend Interviews
TypeScript adds a static type system to JavaScript. Interviews test whether you can use types to prevent real bugs, not whether you can recite syntax: narrowing, generics, utility types, discriminated unions, and the difference between compile-time and run-time. The ts blocks in this chapter are type-checked with the TypeScript compiler in strict mode; blocks marked as errors show code that is meant to fail.
1. What types are for
- Catch mistakes early: wrong property names,
undefinedaccess, wrong argument shapes. - Document intent: a function signature tells the reader what goes in and out.
- Power tooling: autocomplete, safe refactors, find-all-references.
Types are erased at runtime. They do not validate data arriving from an API, a form or JSON.parse. For untrusted data you need run-time validation (Zod, Valibot, hand-written guards), then derive the type from the schema.
Turn on strict: true (it enables strictNullChecks, noImplicitAny and more). Without strict null checks, null and undefined silently inhabit every type, and most of the value of TypeScript disappears.
2. Basic types and inference
const count = 3; // inferred: number
let title = "Hello"; // inferred: string
const tags: string[] = ["a", "b"];
const pair: [string, number] = ["age", 30]; // a tuple has fixed length and positions
const user: { id: number; name: string; email?: string } = { id: 1, name: "Asha" };
function add(a: number, b: number): number { return a + b; }
const double = (n: number) => n * 2; // the return type is inferred
export { count, title, tags, pair, user, add, double };
Let TypeScript infer where it can (local variables, return types of small functions); annotate function parameters, exported APIs and anywhere inference would be too wide.
3. type versus interface
Both describe object shapes.
interface | type | |
|---|---|---|
| Object shapes | yes | yes |
| Extending | extends, and declaration merging | intersection & |
| Unions, tuples, primitives, mapped and conditional types | no | yes |
| Declaration merging (reopening) | yes | no |
A practical rule: use interface for public object contracts that others may extend, type for unions and anything computed. Be consistent within a codebase.
4. Unions, narrowing and discriminated unions
A union says a value is one of several types. Before using it you must narrow it: TypeScript follows your checks.
function format(value: string | number | null): string {
if (value === null) return "none"; // narrowed to string | number
if (typeof value === "number") return value.toFixed(2); // narrowed to number
return value.toUpperCase(); // narrowed to string
}
function describe(x: Date | string[]): string {
return x instanceof Date ? x.toISOString() : x.join(","); // instanceof narrows
}
export { format, describe };
Other narrowing tools: in ("name" in obj), truthiness checks, equality checks, Array.isArray, and user-defined type guards (function isUser(x: unknown): x is User).
Discriminated unions
Give each variant a literal tag field. Switching on it narrows precisely, and the compiler can prove you handled every case.
type RequestState =
| { status: "idle" }
| { status: "loading" }
| { status: "success"; data: string[] }
| { status: "error"; message: string };
function render(state: RequestState): string {
switch (state.status) {
case "idle": return "Start";
case "loading": return "Loading...";
case "success": return `${state.data.length} results`; // data exists only here
case "error": return `Failed: ${state.message}`;
default: {
const unreachable: never = state; // compile error if a new variant is added and not handled
return unreachable;
}
}
}
export { render };
This models UI state far better than { loading: boolean; data?: string[]; error?: string }, which allows impossible combinations (loading and error at the same time). Make illegal states unrepresentable.
5. unknown, any and never
anyturns type checking off. It spreads silently. Avoid it.unknownis the safe top type: you may hold any value but must narrow before using it. Use it for untrusted input.neveris the empty type: a function that never returns, or an impossible branch (used in exhaustiveness checks).
function parseId(input: unknown): number {
if (typeof input === "number" && Number.isInteger(input)) return input;
if (typeof input === "string" && /^\d+$/.test(input)) return Number(input);
throw new Error("invalid id");
}
function isStringArray(x: unknown): x is string[] {
return Array.isArray(x) && x.every(i => typeof i === "string");
}
export { parseId, isStringArray };
6. Generics
Generics let a function or type work over many types while keeping the relationship between input and output.
function first<T>(items: readonly T[]): T | undefined {
return items[0];
}
const n = first([1, 2, 3]); // number | undefined
const s = first(["a", "b"]); // string | undefined
interface ApiResponse<T> { data: T; error: string | null }
async function getJson<T>(url: string): Promise<ApiResponse<T>> {
const res = await fetch(url);
return { data: (await res.json()) as T, error: null }; // the cast is a promise to the compiler, not a check
}
// constrain with extends
function pluck<T, K extends keyof T>(items: T[], key: K): T[K][] {
return items.map(i => i[key]);
}
const names = pluck([{ id: 1, name: "a" }, { id: 2, name: "b" }], "name"); // string[]
export { n, s, getJson, names };
as T (a type assertion) tells the compiler to trust you; it does not verify anything at run time. Prefer validated parsing at boundaries.
7. Utility types
| Utility | Meaning |
|---|---|
Partial<T> | all properties optional |
Required<T> | all required |
Readonly<T> | all read-only |
Pick<T, K> | only the listed keys |
Omit<T, K> | everything except the listed keys |
Record<K, V> | an object type with keys K and values V |
ReturnType<F> | the return type of a function type |
Parameters<F> | the parameter tuple of a function type |
Awaited<T> | the resolved type of a promise |
NonNullable<T> | removes null and undefined |
Exclude<U, X> / Extract<U, X> | filter a union |
interface User { id: number; name: string; email: string; role: "admin" | "member" }
type UserPreview = Pick<User, "id" | "name">;
type NewUser = Omit<User, "id">;
type UserPatch = Partial<Omit<User, "id">>;
type RoleCount = Record<User["role"], number>;
const counts: RoleCount = { admin: 1, member: 20 };
const patch: UserPatch = { name: "Ravi" };
const preview: UserPreview = { id: 1, name: "Asha" };
const draft: NewUser = { name: "x", email: "[email protected]", role: "member" };
async function loadNames(): Promise<{ data: string[]; error: string | null }> {
return { data: [], error: null };
}
type Loaded = Awaited<ReturnType<typeof loadNames>>; // { data: string[]; error: string | null }
export { counts, patch, preview, draft };
export type { Loaded };
8. Mapped and conditional types (the idea)
A mapped type builds a type by transforming each key; a conditional type chooses a type with extends.
type Nullable<T> = { [K in keyof T]: T[K] | null };
type ElementOf<T> = T extends readonly (infer E)[] ? E : never;
type EventName<T extends string> = `on${Capitalize<T>}`;
const maybe: Nullable<{ a: number; b: string }> = { a: null, b: "x" };
const elem: ElementOf<string[]> = "hello";
const ev: EventName<"click"> = "onClick";
export { maybe, elem, ev };
These are the machinery behind the utility types. In interviews you rarely write exotic ones, but you should read them and explain them.
9. TypeScript with React
// typing props, events and state (shown as plain TypeScript to keep the example runnable)
interface ButtonProps {
label: string;
onClick: (event: { shiftKey: boolean }) => void;
variant?: "primary" | "secondary";
disabled?: boolean;
}
function buttonClass({ variant = "primary", disabled = false }: Pick<ButtonProps, "variant" | "disabled">): string {
return ["btn", `btn-${variant}`, disabled ? "is-disabled" : ""].filter(Boolean).join(" ");
}
const cls: string = buttonClass({ variant: "secondary" });
export { cls };
In JSX files the same ideas apply: props: ButtonProps, React.ChangeEvent<HTMLInputElement> for input events, useState<User | null>(null) when the initial value does not tell the whole type, useRef<HTMLInputElement>(null), and discriminated unions for component state. Prefer children: React.ReactNode. For polymorphic components and generics over props, keep types simple; complexity there is a smell.
10. Things that fail on purpose
These blocks are marked as errors and are not compiled by the checker; they show what TypeScript catches.
const user = { id: 1, name: "Asha" };
user.email; // error: Property 'email' does not exist on type '{ id: number; name: string; }'
function len(s: string | null) { return s.length; } // error: 's' is possibly 'null'
type State = { status: "ok" } | { status: "fail"; reason: string };
function f(s: State) { return s.reason; } // error: 'reason' does not exist on type '{ status: "ok" }'
11. Run-time versus compile-time
TypeScript's guarantees stop at the boundary of your code. An API can return anything; JSON.parse returns any; a cast can lie. Validate at the boundary and trust the types inside.
// the shape check that the type system cannot do for you, written as a plain run-time guard
function isUser(x) {
return typeof x === "object" && x !== null && typeof x.id === "number" && typeof x.name === "string";
}
assert.ok(isUser(JSON.parse('{"id": 1, "name": "Asha"}')));
assert.ok(!isUser(JSON.parse('{"id": "1", "name": "Asha"}'))); // looks plausible but has the wrong type
assert.ok(!isUser(JSON.parse("null")));
12. Configuration worth knowing
strict(and specificallystrictNullChecks,noImplicitAny),noUncheckedIndexedAccess(array and index lookups includeundefined),exactOptionalPropertyTypes.target,module,moduleResolution,jsxmatch your bundler.isolatedModulesis required by most transpilers (Babel, esbuild, SWC), which strip types without type-checking; runtsc --noEmitin CI for the actual checks.satisfieschecks that a value matches a type without widening it:const routes = { home: "/" } satisfies Record<string, string>.- Enums versus unions: prefer string-literal unions (
"asc" | "desc") oras constobjects; they erase cleanly and avoid enum quirks.
13. Common mistakes
- Using
anyto silence errors; useunknownand narrow. - Casting with
asinstead of validating. - Leaving
strictoff. - Boolean flag soup instead of discriminated unions.
- Over-engineered generic types that no one can read.
- Assuming types validate API data.
- Non-null assertions (
!) that move a crash from compile time to run time. - Duplicating types that could be derived (
typeof,keyof,ReturnType, schema inference).
14. Practice questions
- What does TypeScript add over JavaScript, and what can it not do?
typeversusinterface: differences and when you choose each.- What is narrowing? Show three ways to narrow a union.
- Model a request's loading, success and error states with a discriminated union and explain the benefit.
unknownversusanyversusnever.- Write a generic
pluck<T, K extends keyof T>and explain the constraint. - Explain
Partial,Pick,OmitandRecord; buildOmitfrom other utilities. - How do you type data from an API safely?