Connect every framework answer to browser behavior, accessibility, performance, or user-visible state; that is where senior frontend judgment becomes visible.
Question set
27 detailed answers
01Why do you need TypeScript? What does it add on top of JavaScript?
junior
Short answer: TypeScript is a superset of JavaScript with static typing. It catches a whole class of errors at compile time (before runtime), and provides autocompletion, refactoring, and self-documenting code.
In detail:
TypeScript adds a type system on top of JS. Any valid JS is valid TS, but TS provides:
- Early error detection — typos, wrong argument types, accessing
undefined. - IntelliSense / autocompletion — the IDE knows the structure of objects.
- Safe refactoring — renaming, finding usages by types.
- Documentation in code — function signatures describe the contract.
// JS: the error shows up only at runtime
function greet(user) {
return "Hello, " + user.name.toUpperCase();
}
greet({ nme: "Bob" }); // TypeError at runtime: Cannot read 'toUpperCase' of undefined
// TS: the error at compile time
function greetTs(user: { name: string }) {
return "Hello, " + user.name.toUpperCase();
}
greetTs({ nme: "Bob" }); // ❌ Error: Object literal may only specify known properties
⚠️ Gotcha: TS types exist only at compile time. They do not provide runtime guarantees — data from APIs, JSON.parse, or forms may not match the declared type. At the boundaries of the system you need runtime validation (e.g., zod).
02What does "types are erased at compilation" (type erasure) mean?
concept
Short answer: The tsc compiler checks types and then removes all type information, emitting plain JS. At runtime types don't exist — you can't check a type against an interface via instanceof.
In detail:
Type annotations, interfaces, type, generics — all of it compiles down to nothing.
interface User {
id: number;
name: string;
}
function process<T>(items: T[]): T[] {
return items;
}
const x: User = { id: 1, name: "Ann" };
Compiles to:
function process(items) {
return items;
}
const x = { id: 1, name: "Ann" };
// No trace of User, T, or annotations — everything is erased
Consequences:
// ❌ You can't check an interface at runtime — it doesn't exist
if (value instanceof User) {} // Error: 'User' only refers to a type
// ❌ You can't get T at runtime
function create<T>(): T {
return new T(); // Error: 'T' only refers to a type
}
// ✅ instanceof works only with classes (they exist at runtime)
class Animal {}
if (pet instanceof Animal) {} // OK
⚠️ Gotcha: Because of type erasure you can't "reflect" over a type at runtime. That's why, for validating incoming data, people use schemas (zod, io-ts) that work at runtime and also infer the TS type.
03What is structural typing (duck typing)? How does it differ from nominal typing?
middle
Short answer: In TypeScript types are compatible by structure (shape), not by name. If an object has the required fields, it fits, regardless of how it was declared. This is the opposite of nominal typing (Java, C#), where the name/inheritance matters.
In detail:
interface Point {
x: number;
y: number;
}
function printPoint(p: Point) {
console.log(p.x, p.y);
}
// Not declared as Point, but structurally compatible
const coord = { x: 1, y: 2, z: 3 };
printPoint(coord); // ✅ OK — it has x and y, the extra z is ignored
// Different interfaces with the same structure are interchangeable
interface Vector { x: number; y: number; }
const v: Vector = { x: 0, y: 0 };
printPoint(v); // ✅ OK
Nominal typing would require an explicit implements Point.
Emulating nominal typing in TS (branded types):
type UserId = number & { readonly __brand: "UserId" };
type PostId = number & { readonly __brand: "PostId" };
function getUser(id: UserId) {}
const uid = 1 as UserId;
const pid = 2 as PostId;
getUser(uid); // ✅
getUser(pid); // ❌ can't pass a PostId where a UserId is expected
⚠️ Gotcha: Extra properties are ignored when passing a variable, but when passing an object literal directly, the "excess property check" kicks in:
printPoint({ x: 1, y: 2, z: 3 }); // ❌ Error: 'z' does not exist in type 'Point'
const c = { x: 1, y: 2, z: 3 };
printPoint(c); // ✅ via a variable — OK
04Basic types: string/number/boolean/null/undefined/void/never/unknown/any
junior
Short answer: Primitives (string, number, boolean), empties (null, undefined), void (the function returns nothing), never (a value that never exists), unknown (a safe top type), any (turns off checking).
In detail:
let s: string = "hello";
let n: number = 42; // integers, fractions, infinity, NaN
let b: boolean = true;
let nul: null = null;
let und: undefined = undefined;
// void — the function returns no meaningful result
function log(msg: string): void {
console.log(msg);
}
// never — the function never completes normally
function fail(msg: string): never {
throw new Error(msg);
}
function loop(): never {
while (true) {}
}
// unknown — any value, but requires narrowing before use
let u: unknown = JSON.parse("...");
// u.toUpperCase(); // ❌ you must check the type first
// any — turns off checks (avoid!)
let a: any = 5;
a.foo.bar.baz; // compiles, crashes at runtime
never is also the result of impossible types: string & number = never.
⚠️ Gotcha: void in a callback function's type means "the return value is ignored," not "must return undefined." That's why Array.forEach(item => arr.push(item)) is valid even though push returns a number.
05any vs unknown vs never — in depth, when to use which
senior
Short answer: any means "turn off type checking" (dangerous). unknown is a "type-safe any": the value is unknown and can only be used after a check. never means "no value is possible": the empty type, the bottom of the hierarchy.
In detail:
// any — assignable to anything and accepts anything; checks are disabled
let a: any = "text";
a(); // OK for the compiler, crashes at runtime
const x: number = a; // OK — infects the rest of the code
// unknown — the top type: everything is assignable to unknown,
// but unknown is not assignable to anything without narrowing
let u: unknown = "text";
// const len = u.length; // ❌ Object is of type 'unknown'
if (typeof u === "string") {
const len = u.length; // ✅ narrowed — now string
}
// never — the bottom type: assignable to everything, but nothing is assignable to it
let nv: never;
// nv = 1; // ❌ Type 'number' is not assignable to 'never'
Assignability hierarchy: never ⊂ everything else ⊂ unknown. any stands apart — it's compatible in both directions.
When to use which:
unknown— for values from the outside world (API,JSON.parse,catch (e)).never— for exhaustiveness checks, impossible branches, functions that always throw.any— almost never; temporarily during migration from JS.
// never for exhaustiveness
type Shape = { kind: "circle" } | { kind: "square" };
function area(s: Shape) {
switch (s.kind) {
case "circle": return 1;
case "square": return 2;
default:
const _exhaustive: never = s; // ❌ if a new kind is added
return _exhaustive;
}
}
⚠️ Gotcha: In TS 4.4+ catch (e) has type unknown (with useUnknownInCatchVariables). You can't access e.message directly — you have to narrow: if (e instanceof Error) ....
06any vs unknown — why is unknown safer?
concept
Short answer: any disables all checks and "infects" the whole code — errors slip through unnoticed. unknown forces the compiler to require a type check before use, preserving type safety.
In detail:
function parseAny(json: string): any {
return JSON.parse(json);
}
const dataAny = parseAny("{}");
dataAny.user.name.toUpperCase(); // ✅ compiler stays silent, runtime crash
function parseUnknown(json: string): unknown {
return JSON.parse(json);
}
const dataUnknown = parseUnknown("{}");
// dataUnknown.user; // ❌ the compiler forces a check
if (
typeof dataUnknown === "object" &&
dataUnknown !== null &&
"user" in dataUnknown
) {
// safe access after narrowing
}
Key idea: any is "trust me" (the compiler backs off). unknown is "prove it to me" (the compiler demands proof of type). unknown is a barrier that data only passes through after an explicit check.
⚠️ Gotcha: any is contagious: const x = anyValue.foo makes x any too. A single any in a chain can "eat" the type safety of a large stretch of code without a single compile error.
07Type vs Interface — differences, when to use which
middle
Short answer: Both describe the shape of an object and are almost interchangeable. interface supports declaration merging and is idiomatic for objects/classes. type is more powerful: unions, intersections, tuples, primitives, mapped/conditional types.
In detail:
// interface — extension via extends
interface Animal { name: string; }
interface Dog extends Animal { breed: string; }
// type — extension via intersection
type AnimalT = { name: string };
type DogT = AnimalT & { breed: string };
// Only type can do:
type ID = string | number; // union
type Pair = [number, number]; // tuple
type Name = string; // primitive alias
type Keys = keyof DogT; // type operators
// Only interface can do declaration merging:
interface Window { customProp: string; }
interface Window { anotherProp: number; } // merged automatically
When to use which:
interface— public library APIs (extensibility), objects, classes (implements), when you need declaration merging.type— union/intersection, functions, tuples, utility/computed types.
// interface for classes
interface Repository<T> {
find(id: string): T | null;
}
class UserRepo implements Repository<User> {
find(id: string) { return null; }
}
⚠️ Gotcha: Declaration merging in interface can be surprising: two same-named interfaces in the same scope silently merge. With type, a repeated declaration is a "Duplicate identifier" error, which is sometimes preferable.
08How does type differ from interface in practice?
concept
Short answer: In practice, for describing objects the difference is minimal — choose by team convention. What really matters are three things: declaration merging (interface only), union/tuple/primitive aliases (type only), and error messages (with interface they're often shorter and more readable).
In detail:
// A practical rule many teams follow:
// interface — for objects and contracts
interface UserProps {
id: string;
name: string;
}
// type — when you need something interface can't do
type Status = "active" | "banned" | "pending";
type Handler = (e: Event) => void;
type Coords = readonly [number, number];
// Extending global types — interface only
declare global {
interface Window {
__APP_VERSION__: string;
}
}
Compiler performance: for very large intersections, interface extends usually caches better than long chains of & in a type — but this only matters on huge projects.
⚠️ Gotcha: type with & produces never for a conflicting field when fields clash (not an immediate error), whereas interface extends with conflicting types gives an explicit error. Example: type T = { a: string } & { a: number } → a: never.
09Union (|) and Intersection (&) types
middle
Short answer: Union (A | B) — a value of one of the types ("or"). Intersection (A & B) — a value satisfying all types at once ("and", combining the fields).
In detail:
// Union — one OF
type Result = string | number;
let r: Result = "ok";
r = 42; // also OK
// With a union, only common members are accessible before narrowing
function format(x: string | number) {
// x.toFixed(); // ❌ string doesn't have it
return x.toString(); // ✅ both have it
}
// Intersection — combining requirements
type WithId = { id: string };
type WithTimestamps = { createdAt: Date; updatedAt: Date };
type Entity = WithId & WithTimestamps;
const e: Entity = {
id: "1",
createdAt: new Date(),
updatedAt: new Date(),
}; // ALL fields are required
⚠️ Gotcha: Intersecting primitives gives never (string & number is impossible). Also, the intuition "union = fewer, intersection = more" is wrong for sets: a union of types = the union of their value sets (more values), an intersection of objects = more requirements (fewer matching values).
10Literal types and const assertions (as const)
middle
Short answer: Literal types — a specific value as a type ("red", 42, true). as const makes a value deeply readonly and narrows types down to literals.
In detail:
// Literal types
type Direction = "north" | "south" | "east" | "west";
let d: Direction = "north"; // only from the set
// Without as const: the type is widened
const obj1 = { role: "admin" }; // role: string
let m = "GET"; // string
// With as const: exact literals + readonly
const obj2 = { role: "admin" } as const; // role: "admin"
const config = {
method: "POST",
retries: 3,
} as const;
// type: { readonly method: "POST"; readonly retries: 3 }
// Useful for arrays → tuple of literals
const roles = ["admin", "user", "guest"] as const;
type Role = typeof roles[number]; // "admin" | "user" | "guest"
⚠️ Gotcha: Without as const, the array ["a", "b"] gets type string[], and typeof arr[number] will be string, not a union of literals. as const is critical for deriving a union from a constant array.
11Enums — why do many people avoid them?
middle
Short answer: Numeric enums generate a runtime object (breaking type erasure), have unsafe reverse mappings, and unintuitive behavior. People often prefer literal unions or as const objects.
In detail:
// A regular enum compiles to a runtime object (not erased!)
enum Color { Red, Green, Blue } // Red=0, Green=1, Blue=2
// A numeric enum accepts any number — unsafe
let c: Color = 5; // ❌ logically incorrect, but compiles (before strict versions)
// const enum — inlined, leaves no runtime code
const enum Size { S, M, L }
let s = Size.M; // compiles to: let s = 1;
// The preferred alternative — a literal union
type ColorU = "red" | "green" | "blue";
// Or an as const object (you get both a type and values to iterate over)
const Color2 = { Red: "red", Green: "green", Blue: "blue" } as const;
type Color2 = typeof Color2[keyof typeof Color2]; // "red" | "green" | "blue"
Downsides of enums: runtime weight, tree-shaking issues, const enum is incompatible with isolatedModules (Babel, esbuild), numeric enums are unsafe.
⚠️ Gotcha: const enum doesn't work when building with Babel/esbuild and isolatedModules, because it needs type information for inlining. This is a common reason for avoiding it in modern projects.
12Generics: why you need them, functions, classes, constraints, default params
middle
Short answer: Generics are type parameters that let you write reusable code which preserves the relationship between input and output types without losing information (unlike any).
In detail:
// Generic function: links input and output
function identity<T>(value: T): T {
return value;
}
const a = identity("hi"); // a: string (inferred)
const b = identity(42); // b: number
// Constraints: restriction via extends
function getLength<T extends { length: number }>(x: T): number {
return x.length;
}
getLength("abc"); // ✅
getLength([1, 2, 3]); // ✅
// getLength(42); // ❌ number has no length
// keyof + generics — type-safe field access
function getProp<T, K extends keyof T>(obj: T, key: K): T[K] {
return obj[key];
}
const user = { name: "Ann", age: 30 };
const name = getProp(user, "name"); // string
// getProp(user, "foo"); // ❌ no such key
// Default type params
interface Box<T = string> {
value: T;
}
const box: Box = { value: "default is string" };
// Generic class
class Stack<T> {
private items: T[] = [];
push(item: T) { this.items.push(item); }
pop(): T | undefined { return this.items.pop(); }
}
const s = new Stack<number>();
⚠️ Gotcha: Don't overuse generics. If a type parameter is used in only one place, it's probably unnecessary. And <T,> (with a comma) is needed in .tsx to distinguish it from a JSX tag.
13Utility types: Partial, Required, Readonly, Pick, Omit, Record, etc.
senior
Short answer: Built-in utility types transform existing types: make fields optional/required, select/exclude keys, build dictionaries, extract return/parameter types.
In detail:
interface User {
id: number;
name: string;
email?: string;
}
// Partial<T> — all fields optional (for updates)
type UserUpdate = Partial<User>; // { id?, name?, email? }
// Required<T> — all required
type FullUser = Required<User>; // email becomes required
// Readonly<T> — all read-only
type FrozenUser = Readonly<User>;
// Pick<T, K> — select a subset of keys
type UserPreview = Pick<User, "id" | "name">;
// Omit<T, K> — exclude keys
type UserNoId = Omit<User, "id">; // { name, email? }
// Record<K, V> — dictionary
type RolePermissions = Record<"admin" | "user", string[]>;
const perms: RolePermissions = { admin: ["all"], user: ["read"] };
// Exclude / Extract — filtering a union
type T1 = Exclude<"a" | "b" | "c", "a">; // "b" | "c"
type T2 = Extract<"a" | "b" | "c", "a" | "z">; // "a"
// NonNullable — remove null | undefined
type T3 = NonNullable<string | null | undefined>; // string
// ReturnType / Parameters — from a function type
function makeUser(name: string, age: number) {
return { name, age };
}
type MadeUser = ReturnType<typeof makeUser>; // { name: string; age: number }
type MakeArgs = Parameters<typeof makeUser>; // [string, number]
// Awaited — unwrap a Promise
type Data = Awaited<Promise<Promise<number>>>; // number
⚠️ Gotcha: Omit doesn't check that the excluded key exists in the type (Omit<User, "typo"> won't raise an error before TS 5.x strict modes). And Pick/Omit lose index signatures and overload methods in some cases.
14Type narrowing: typeof, instanceof, in, user-defined type guards
middle
Short answer: Narrowing is the process where the compiler refines a type inside a branch based on a check. The tools: typeof, instanceof, the in operator, truthiness/equality checks, and user-defined type guards with the x is T predicate.
In detail:
// typeof — for primitives
function pad(x: string | number) {
if (typeof x === "number") {
return x.toFixed(2); // x: number
}
return x.trim(); // x: string
}
// instanceof — for classes
function handle(e: Error | string) {
if (e instanceof Error) {
return e.message; // e: Error
}
return e; // e: string
}
// in — checking for a property
type Fish = { swim: () => void };
type Bird = { fly: () => void };
function move(animal: Fish | Bird) {
if ("swim" in animal) {
animal.swim(); // Fish
} else {
animal.fly(); // Bird
}
}
// User-defined type guard: the x is T predicate
function isString(x: unknown): x is string {
return typeof x === "string";
}
function process(val: unknown) {
if (isString(val)) {
val.toUpperCase(); // val: string
}
}
// Assertion function
function assertIsDefined<T>(x: T): asserts x is NonNullable<T> {
if (x == null) throw new Error("not defined");
}
⚠️ Gotcha: A type guard with x is T trusts your logic — the compiler doesn't verify its correctness. If the predicate lies (return true always), type safety is broken with no error. Also typeof null === "object" — a classic trap when narrowing objects.
15Discriminated (tagged) unions and exhaustiveness checks
senior
Short answer: A discriminated union is a union of objects with a common literal discriminant field (kind/type). The compiler narrows the type based on it. With never in the default branch you achieve an exhaustiveness check.
In detail:
type Shape =
| { kind: "circle"; radius: number }
| { kind: "rectangle"; width: number; height: number }
| { kind: "triangle"; base: number; height: number };
function area(shape: Shape): number {
switch (shape.kind) {
case "circle":
return Math.PI * shape.radius ** 2; // narrowed to circle
case "rectangle":
return shape.width * shape.height;
case "triangle":
return 0.5 * shape.base * shape.height;
default:
// Exhaustiveness: if a new kind is added and left unhandled,
// shape here won't be never → compile error
const _exhaustive: never = shape;
return _exhaustive;
}
}
This is one of TS's most powerful patterns: modeling states (loading/success/error), events, ASTs.
type RequestState =
| { status: "idle" }
| { status: "loading" }
| { status: "success"; data: string }
| { status: "error"; message: string };
function render(state: RequestState) {
if (state.status === "success") {
return state.data; // data is accessible only here
}
// state.data; // ❌ not present in other branches
}
⚠️ Gotcha: The discriminant must be a literal type ("circle", not string). If the field is declared as string, narrowing won't work. And don't forget the never check — without it a forgotten branch passes silently.
16keyof, the typeof operator, indexed access types (T[K])
senior
Short answer: keyof T — a union of the type's keys. typeof value (in a type position) — extracts a type from a value. T[K] — indexed access — the type of the value at a given key.
In detail:
interface User {
id: number;
name: string;
active: boolean;
}
// keyof — a union of string literals of the keys
type UserKeys = keyof User; // "id" | "name" | "active"
// typeof — a type from a value
const config = { host: "localhost", port: 8080 };
type Config = typeof config; // { host: string; port: number }
// Indexed access — the type of the value at a key
type IdType = User["id"]; // number
type NameOrId = User["id" | "name"]; // number | string
// Combination: the type of all values
type Values = User[keyof User]; // number | string | boolean
// Real case: the element type of an array
const arr = [{ x: 1 }, { x: 2 }];
type Item = typeof arr[number]; // { x: number }
// Combining in generics — a type-safe getter
function pluck<T, K extends keyof T>(items: T[], key: K): T[K][] {
return items.map((item) => item[key]);
}
const names = pluck([{ name: "A" }, { name: "B" }], "name"); // string[]
⚠️ Gotcha: keyof for a type with an index signature { [k: string]: number } gives string | number (number because numeric keys are coerced to strings). And typeof in a value position and typeof in a type position are different operators.
17Mapped types
senior
Short answer: Mapped types create a new type by iterating over the keys of an existing one: { [K in keyof T]: ... }. Partial, Readonly, Record, etc. are built on them.
In detail:
interface User {
id: number;
name: string;
}
// Basic mapped type
type ReadonlyUser = { readonly [K in keyof User]: User[K] };
// This is how Partial is implemented
type MyPartial<T> = { [K in keyof T]?: T[K] };
// Modifiers: add (+) / remove (-) readonly and ?
type Mutable<T> = { -readonly [K in keyof T]: T[K] };
type Concrete<T> = { [K in keyof T]-?: T[K] }; // remove optional
// Changing the value types
type Stringify<T> = { [K in keyof T]: string };
type S = Stringify<User>; // { id: string; name: string }
// Key remapping via `as` (TS 4.1+)
type Getters<T> = {
[K in keyof T as `get${Capitalize<string & K>}`]: () => T[K];
};
type UserGetters = Getters<User>;
// { getId: () => number; getName: () => string }
// Filtering keys via `as ... never`
type OnlyStrings<T> = {
[K in keyof T as T[K] extends string ? K : never]: T[K];
};
⚠️ Gotcha: A mapped type over a union is "homomorphic" only with [K in keyof T] — then the modifiers and optionality of the source type are preserved. With [K in SomeUnion] (not keyof) this property is lost. And key remapping via as is only available from TS 4.1 onward.
18Conditional types and infer
senior
Short answer: A conditional type T extends U ? X : Y picks a type based on a condition. infer declares a type variable inside the condition to "capture" a nested type.
In detail:
// Basic conditional type
type IsString<T> = T extends string ? "yes" : "no";
type A = IsString<string>; // "yes"
type B = IsString<number>; // "no"
// Distributive: over a union it applies to each member
type ToArray<T> = T extends any ? T[] : never;
type R = ToArray<string | number>; // string[] | number[]
// infer — extract a nested type
type ElementType<T> = T extends (infer U)[] ? U : never;
type E = ElementType<number[]>; // number
// This is how ReturnType works
type MyReturnType<T> = T extends (...args: any[]) => infer R ? R : never;
type Ret = MyReturnType<() => string>; // string
// This is how Awaited works (simplified)
type MyAwaited<T> = T extends Promise<infer V> ? MyAwaited<V> : T;
type W = MyAwaited<Promise<Promise<number>>>; // number
// Multiple infers
type FirstArg<T> = T extends (first: infer F, ...rest: any[]) => any ? F : never;
type F = FirstArg<(a: string, b: number) => void>; // string
⚠️ Gotcha: Distributive behavior kicks in only when the checked type is a "naked" parameter (T extends ...). To disable distributivity, wrap it in a tuple: [T] extends [U] ? .... This is a common trick, for example to check "is T exactly never".
19Optional (?) and readonly modifiers
junior
Short answer: ? makes a property/parameter optional (it may be absent, the type becomes T | undefined). readonly forbids reassigning a property after initialization (at the type level only).
In detail:
interface Config {
host: string;
port?: number; // optional → number | undefined
readonly apiKey: string; // can't be changed after creation
}
const c: Config = { host: "localhost", apiKey: "secret" };
// c.apiKey = "new"; // ❌ Cannot assign to 'apiKey', it is read-only
c.port = 8080; // ✅
// Optional function parameters
function greet(name: string, greeting?: string) {
return `${greeting ?? "Hi"}, ${name}`;
}
// readonly for arrays and tuples
const nums: readonly number[] = [1, 2, 3];
// nums.push(4); // ❌ push is absent on a readonly array
// ReadonlyArray<T> — the equivalent
const list: ReadonlyArray<string> = ["a"];
⚠️ Gotcha: readonly is compile-time only. At runtime the object can be modified (via any, or from JS). Also readonly is shallow: readonly { nested: { x: number } } doesn't protect nested.x. For deep immutability you need as const or DeepReadonly utilities.
20Tuple types
middle
Short answer: A tuple is a fixed-length array with a known type for each element by position. It supports optional, rest, and named elements.
In detail:
// Basic tuple
let point: [number, number] = [10, 20];
let entry: [string, number] = ["age", 30];
// Named elements (for readability only)
type Range = [start: number, end: number];
// Optional and rest
type Args = [string, number?]; // the second is optional
type Variadic = [string, ...number[]]; // a string + any number of numbers
// readonly tuple
const rgb: readonly [number, number, number] = [255, 0, 0];
// Destructuring preserves the types
const [name, age]: [string, number] = ["Ann", 30];
// Real case: returning a pair (like useState)
function useToggle(): [boolean, () => void] {
let state = false;
return [state, () => { state = !state; }];
}
const [on, toggle] = useToggle();
⚠️ Gotcha: Without as const, an array literal is inferred as T[], not a tuple. const pair = [1, "a"] → (number | string)[], not [number, string]. For a tuple you need to annotate the type explicitly or use as const.
21Function types and overloads
middle
Short answer: A function type describes parameters and return: (a: number) => string. Overloads let you declare multiple signatures for a single function with behavior that differs by argument types.
In detail:
// Function type
type BinaryOp = (a: number, b: number) => number;
const add: BinaryOp = (a, b) => a + b;
// Type with an optional/rest parameter
type Logger = (msg: string, ...meta: unknown[]) => void;
// Overloads: several signatures + one implementation
function parse(value: string): string[];
function parse(value: number): number;
function parse(value: string | number): string[] | number {
if (typeof value === "string") return value.split(",");
return value * 2;
}
const a = parse("a,b"); // string[]
const b = parse(10); // number
// Call signature + properties (a callable object)
interface Counter {
(): number; // callable
count: number; // and with a property
reset(): void;
}
⚠️ Gotcha: The implementation signature of an overload is not visible from outside — it's only for the function body. You can call it only through the declared overloads. Often overloads can be replaced with a generic or a union type, which is easier to maintain.
22Type assertions (as) and non-null assertion (!) — when they're dangerous
middle
Short answer: as T tells the compiler "trust me, this is type T" without a check. ! (non-null assertion) removes null | undefined. Both disable checks and are dangerous — they can hide a runtime error.
In detail:
// Type assertion
const input = document.getElementById("x") as HTMLInputElement;
input.value; // the compiler believes you, but at runtime it could be null
// as is dangerous — it lets you "lie"
const x = "hello" as unknown as number; // a double cast bypasses the protection
// Non-null assertion
function getLength(s?: string) {
return s!.length; // asserting s isn't undefined — crashes if it is
}
// Safer alternatives:
const el = document.getElementById("x");
if (el instanceof HTMLInputElement) {
el.value; // ✅ narrowing, not a cast
}
const len = s?.length ?? 0; // ✅ optional chaining instead of !
as is justified when:
- You know more than the compiler (DOM, external data after validation).
as const— a safe use.
⚠️ Gotcha: as and ! don't do any runtime conversion — they only soothe the compiler. If the data doesn't match, the error surfaces later and in an unexpected place. A double cast as unknown as T is a red flag: it almost always means a hidden typing problem.
23Declaration files (.d.ts), @types, DefinitelyTyped
middle
Short answer: .d.ts — files with type declarations only (no implementation), describing the shape of JS code. @types/* — packages of types for libraries, published to the DefinitelyTyped repository.
In detail:
// math.d.ts — describing types for math.js, which has no types
declare module "legacy-math" {
export function add(a: number, b: number): number;
export const PI: number;
}
// Global declarations
declare global {
interface Window {
analytics: { track(event: string): void };
}
}
// Declaring a variable/function (ambient)
declare const __VERSION__: string;
declare function gtag(...args: unknown[]): void;
How it works:
- Many npm packages don't ship types. The community publishes types to DefinitelyTyped → they become available as
@types/name(npm i -D @types/node @types/react). - TS automatically picks up
@types/*fromnode_modules/@types. - Modern libraries often ship
.d.tsright inside the package (thetypes/typingsfield in package.json).
npm install --save-dev @types/lodash
⚠️ Gotcha: The version of @types/lib should roughly match the version of the library itself — a mismatch gives wrong or missing types. And a .d.ts describes the contract but doesn't guarantee it: if the actual JS differs from the declaration, the compiler won't notice.
24tsconfig: strict, noImplicitAny, strictNullChecks
middle
Short answer: tsconfig.json configures the compiler. strict: true enables a set of strict checks (including noImplicitAny and strictNullChecks) — the recommended baseline for new projects.
In detail:
{
"compilerOptions": {
"strict": true, // enables all strict flags
"noImplicitAny": true, // forbid implicit any
"strictNullChecks": true, // null/undefined explicit in types
"target": "ES2020",
"module": "ESNext",
"moduleResolution": "bundler",
"esModuleInterop": true,
"skipLibCheck": true, // don't check dependencies' .d.ts (speed)
"noUncheckedIndexedAccess": true // arr[i] → T | undefined
}
}
What the key flags give you:
// noImplicitAny: a parameter without a type → error
function f(x) {} // ❌ 'x' implicitly has type 'any'
// strictNullChecks: null/undefined aren't assignable just anywhere
let s: string = null; // ❌ with strictNullChecks
let s2: string | null = null; // ✅ must be explicit
function find(): string | undefined { return undefined; }
const r = find();
// r.length; // ❌ Object is possibly 'undefined'
strict also enables: strictFunctionTypes, strictBindCallApply, strictPropertyInitialization, noImplicitThis, useUnknownInCatchVariables, alwaysStrict.
⚠️ Gotcha: Without strictNullChecks, the type system "lies" — null/undefined are assignable to any type, and Object is possibly undefined isn't caught. This is the most valuable flag; you should enable strict from the very start, otherwise migrating legacy code is painful.
25Typing in React: props, events, useState, useRef
middle
Short answer: Props are typed with type/interface. Hooks are generic: useState<T>, useRef<T>. Events use React types (React.ChangeEvent, React.MouseEvent). React.FC is more often avoided today in favor of typing props explicitly.
In detail:
// Props — explicit typing is preferred, without FC
interface ButtonProps {
label: string;
variant?: "primary" | "secondary";
onClick: (e: React.MouseEvent<HTMLButtonElement>) => void;
children?: React.ReactNode;
}
function Button({ label, variant = "primary", onClick }: ButtonProps) {
return <button className={variant} onClick={onClick}>{label}</button>;
}
// useState with an explicit type, when inference isn't enough
const [count, setCount] = useState(0); // infers number
const [user, setUser] = useState<User | null>(null); // must be explicit
// Events
function Input() {
const onChange = (e: React.ChangeEvent<HTMLInputElement>) => {
console.log(e.target.value);
};
return <input onChange={onChange} />;
}
// useRef — for the DOM and mutable values
const inputRef = useRef<HTMLInputElement>(null); // ref to a DOM node
const timerRef = useRef<number | undefined>(undefined); // mutable value
// Generic component
function List<T>({ items, render }: { items: T[]; render: (item: T) => React.ReactNode }) {
return <>{items.map(render)}</>;
}
⚠️ Gotcha: React.FC used to implicitly add children (in the types before React 18) and works poorly with generic components, so many teams have abandoned it. With useRef<T>(null) for a DOM ref, the .current property will be T | null — you need a check before access.
26Generics in real-world tasks: a type-safe API client
senior
Short answer: Generics let you build an API client where the response type is inferred from the endpoint/input data rather than set to any. Combining keyof, extends, and conditional types gives full type safety.
In detail:
// A map of endpoints → response types
interface ApiRoutes {
"/users": { id: number; name: string }[];
"/users/:id": { id: number; name: string; email: string };
"/posts": { id: number; title: string }[];
}
// Type-safe fetch: the response type is inferred from the path
async function apiGet<Path extends keyof ApiRoutes>(
path: Path
): Promise<ApiRoutes[Path]> {
const res = await fetch(path);
return res.json() as Promise<ApiRoutes[Path]>;
}
const users = await apiGet("/users"); // { id; name }[]
const user = await apiGet("/users/:id"); // { id; name; email }
// await apiGet("/unknown"); // ❌ no such path
// A generic result wrapper with a discriminated union
type ApiResult<T> =
| { ok: true; data: T }
| { ok: false; error: string };
async function safeGet<P extends keyof ApiRoutes>(
path: P
): Promise<ApiResult<ApiRoutes[P]>> {
try {
const data = await apiGet(path);
return { ok: true, data };
} catch (e) {
return { ok: false, error: e instanceof Error ? e.message : "unknown" };
}
}
const result = await safeGet("/users");
if (result.ok) {
result.data.length; // ✅ the type is known
}
⚠️ Gotcha: res.json() returns any (really Promise<any>), so your as Promise<ApiRoutes[Path]> is a promise, not a check. The runtime data may not match the type. For real safety at the boundary, use schema validation (zod) and derive the type from it.
27Why TypeScript if you have tests?
concept
Short answer: Tests and types solve different problems and complement each other. Types check structural correctness continuously and exhaustively across all the code; tests check behavior in specific scenarios. Types are a cheap first line of defense.
In detail:
Comparison:
- Coverage. Types check every call and property access automatically. Tests check only what you wrote. Types give you "100% coverage" against typos and wrong arguments for free.
- Cost. Types are written once in the signature; they protect all calls. Tests have to be written and maintained for each case.
- Feedback speed. Types — instantly in the IDE. Tests — after running.
- Class of error. Types catch "the wrong shape of data" (passed the wrong thing, accessed a nonexistent field). Tests catch "wrong logic" (the formula computed incorrectly).
// A test checks that calculateTotal([1,2]) === 3
// A type guarantees that NO ONE calls calculateTotal("abc")
function calculateTotal(prices: number[]): number {
return prices.reduce((a, b) => a + b, 0);
}
Conclusion: types don't replace tests (they don't check that the business logic is correct), and tests don't replace types (they don't cover all possible incorrect usages). Best practice is to have both.
⚠️ Gotcha: Types give a false sense of security at the system's boundaries. The type User doesn't guarantee the API returned a User — types are erased and don't validate runtime data. This is exactly where you need both tests and runtime validation.
Source notes
References and review policy
RecallDeck’s interview answers are editorial material, reviewed against maintained official documentation where a primary reference is available. Tool selections use direct provider links and contain no affiliate placements. Features can change after the review date.
From reading to recall
Practice the full interview loop.
RecallDeck schedules the concepts you miss and keeps coding, design, and behavioral fundamentals available when the interviewer changes direction.