Topics
v5.0.0
On this page · sections

Type System Fundamentals

TypeScript’s type system is structural — compatibility is based on shape, not names. Understanding inference, annotations, widening, and narrowing is the foundation for every other TypeScript interview topic.

ConceptWhat it meansInterview hook
InferenceCompiler deduces types from valuesLess annotation, same safety
AnnotationsExplicit types you declareDocument contracts at boundaries
Structural typingShape matters, name does notDuck typing at compile time
CompatibilityWhen one type is assignable to anotherExcess property checks, bivariance
WideningLiteral types expand to general typesconst vs let, as const
NarrowingTypes shrink after runtime checksFoundation for unions and guards

Type inference

Definition

TypeScript infers types from initializers, return statements, and context so you do not annotate every variable.

const count = 42; // number const label = "active"; // string const items = [1, 2, 3]; // number[] function double(n: number) { return n * 2; // return type inferred as number }

Generic call sites also infer type parameters:

function first<T>(arr: T[]): T | undefined { return arr[0]; } const n = first([1, 2, 3]); // number | undefined — T inferred as number

When inference stops

Without strict flags, parameters default to any. With noImplicitAny, untyped parameters are errors:

// ❌ Parameter 'value' implicitly has an 'any' type function log(value) { console.log(value); } // ✅ function log(value: unknown) { console.log(value); }

Interview: Inference is local and flow-sensitive. Annotate public API boundaries; let the compiler infer locals and return types when obvious.

Type annotations

Definition

An annotation is an explicit type you attach to a variable, parameter, property, or return value.

type User = { id: string; name: string }; function getUser(id: string): Promise<User> { return fetch(`/api/users/${id}`).then((r) => r.json()); } const cache: Record<string, User> = {};

Annotations document intent and catch mistakes at the call site:

function setTheme(theme: "light" | "dark") { document.documentElement.dataset.theme = theme; } setTheme("blue"); // ❌ Argument of type '"blue"' is not assignable

Annotation vs assertion

Annotations declare what a value should be. Assertions (as) tell the compiler to trust you:

// Annotation — checked by compiler const user: User = { id: "1", name: "Ada" }; // Assertion — bypasses checking (use sparingly) const raw = JSON.parse('{"id":"1"}') as User;

Interview: Prefer annotations at module boundaries. Avoid as unless you have validated the shape at runtime.

Structural typing

Definition

TypeScript uses structural (duck) typing: two types are compatible if their members are compatible, regardless of name.

type Point = { x: number; y: number }; type Vector = { x: number; y: number }; const p: Point = { x: 1, y: 2 }; const v: Vector = p; // ✅ same shape — assignable

This differs from nominal typing (Java, C#) where the declared name must match.

Real-world example

type ButtonProps = { label: string; onClick: () => void }; function IconButton(props: { label: string; onClick: () => void }) { return <button onClick={props.onClick}>{props.label}</button>; } const btn: ButtonProps = { label: "Save", onClick: () => {} }; <IconButton {...btn} />; // ✅ structural match

Excess property checking

Object literals get stricter checks than variables:

type Config = { host: string; port: number }; const host = "localhost"; const cfg: Config = { host, port: 3000, debug: true }; // ❌ 'debug' does not exist on type 'Config' const extra = { host, port: 3000, debug: true }; const cfg2: Config = extra; // ✅ variable assignment — no excess check

Interview: Structural typing enables flexible APIs but surprises developers from nominal languages. Excess property checks only apply to fresh object literals.

Type compatibility

Definition

Type A is assignable to type B when A has at least the members B requires, with compatible types.

type Animal = { name: string }; type Dog = { name: string; breed: string }; const dog: Dog = { name: "Rex", breed: "lab" }; const animal: Animal = dog; // ✅ Dog has all Animal fields

Function compatibility

Parameter types are checked contravariantly (with strictFunctionTypes); return types are covariant:

type Handler = (event: MouseEvent) => void; const onClick: Handler = (e: Event) => { console.log(e.type); }; // ✅ Event is wider than MouseEvent — safe for callers passing MouseEvent

Return type must be assignable in the narrowing direction — a function returning string | number cannot satisfy () => string.

Interview: Assignability is about “can I use this value where that type is expected?” — not about inheritance trees.

Type widening

Definition

Widening expands a narrow literal type to a broader one. It happens with let, mutable arrays, and untyped assignments.

const a = "hello"; // type: "hello" (literal) let b = "hello"; // type: string (widened) const colors = ["red", "blue"]; // ("red" | "blue")[] let palette = ["red", "blue"]; // string[] (widened)

as const — prevent widening

const routes = { home: "/", profile: "/profile", } as const; // { readonly home: "/"; readonly profile: "/profile" } type Route = (typeof routes)[keyof typeof routes]; // "/" | "/profile"

Tuple preservation:

const pair = [1, "two"] as const; // readonly [1, "two"] const loose = [1, "two"]; // (string | number)[]

Real-world example

const STATUS = { idle: "idle", loading: "loading", success: "success", } as const; type Status = (typeof STATUS)[keyof typeof STATUS]; // "idle" | "loading" | "success"

Interview: Use const + as const for lookup tables and discriminant values. Use let when the value will be reassigned to a wider type.

Type narrowing (basics)

Definition

Narrowing shrinks a type after a runtime check. TypeScript tracks control flow to refine types in each branch.

function format(value: string | number) { if (typeof value === "string") { return value.toUpperCase(); // value is string } return value.toFixed(2); // value is number }

Truthiness narrowing:

function greet(name: string | null | undefined) { if (!name) return "Guest"; return `Hello, ${name}`; // name is string }

never after exhaustive checks

When every union member is handled, the remaining type is never:

type Shape = { kind: "circle"; r: number } | { kind: "square"; s: number }; function area(shape: Shape) { switch (shape.kind) { case "circle": return Math.PI * shape.r ** 2; case "square": return shape.s ** 2; default: { const _exhaustive: never = shape; return _exhaustive; } } }

Narrowing is covered in depth in the dedicated narrowing guide; here the key idea is that TypeScript follows your if/switch logic.

Interview: Narrowing is flow-sensitive — assign to a local const if you need the narrowed type across async gaps.

Senior Interview Summary

What to say

TypeScript is structural: shapes determine compatibility, not declared names. Inference reduces noise; annotations document contracts. Widening and narrowing explain why const literals stay narrow and if checks unlock union members.

Key distinctions

Inference → compiler figures out the type Annotation → you declare the type Structural → { x: number } matches { x: number } regardless of alias name Widening → "hello" becomes string (let, mutable context) Narrowing → string | number becomes string after typeof check

Best mental model

const x = "a" → literal type "a" let x = "a" → string if (check) → type shrinks in the true branch as const → freeze literals for discriminated unions

Practical rule

API boundaries → annotate parameters and return types Local variables → infer when initializer is clear Lookup tables → as const + typeof indexing Union handling → narrow before accessing type-specific fields

Last updated on