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.
| Concept | What it means | Interview hook |
|---|---|---|
| Inference | Compiler deduces types from values | Less annotation, same safety |
| Annotations | Explicit types you declare | Document contracts at boundaries |
| Structural typing | Shape matters, name does not | Duck typing at compile time |
| Compatibility | When one type is assignable to another | Excess property checks, bivariance |
| Widening | Literal types expand to general types | const vs let, as const |
| Narrowing | Types shrink after runtime checks | Foundation 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 numberWhen 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 assignableAnnotation 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
asunless 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 — assignableThis 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 matchExcess 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 checkInterview: 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 fieldsFunction 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 MouseEventReturn 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 constfor lookup tables and discriminant values. Useletwhen 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
constliterals stay narrow andifchecks 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 checkBest 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 unionsPractical 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