TypeScript NotesRohit’s interview study guide
Chapter 03

Interfaces & Type Aliases

Describing object shapes, extending and merging them, function and index signatures, and when to pick interface or type.

11 topics
Parts marked Advanced are extra depth. Skip them on a first read or a quick revision.

#type vs interface

Both interface and type give a name to a type so you can reuse it. They overlap a lot — for a plain object shape they are interchangeable — which is exactly why interviewers like asking about the differences.

An interface declares a named object type: properties, methods, call signatures, index signatures. That's all it can describe. A type alias is a new name for any type expression — an object shape, but also a primitive, a union, a tuple, a function type, or the result of a mapped or conditional type. The alias doesn't create anything new; it's a label stuck on whatever is to the right of the =.

A useful mental model: an interface is a named contract that stays open (you can add to it later, see Declaration merging); a type alias is a closed label for a type expression. And because TypeScript is structural, neither one creates a "brand" — two types with the same members are compatible no matter what they're called or how they were declared.

TypeScript
interface PersonI {
  name: string;
  age?: number;
}

type PersonT = {
  name: string;
  age?: number;
};

type ID = string | number;

const a: PersonI = { name: "Ana" };
const b: PersonT = a; // fine: same members, different declaration kind
const id: ID = 42;
What’s happening
  1. interface PersonI and type PersonT describe the same shape: a required name: string and an optional age?: number. The interface uses interface Name { … } (no =); the alias uses type Name = { … }; — the { … } is an ordinary object type literal that the alias names.
  2. const a: PersonI = { name: "Ana" } — the checker compares the literal's type { name: string } with PersonI: name is present and a string, age is optional so leaving it out is fine.
  3. const b: PersonT = a — the checker doesn't care that a was declared with an interface. It checks structure: does PersonI have everything PersonT requires, with compatible types? Yes, so the assignment passes. This is structural typing: names don't matter, members do.
  4. type ID = string | number is something an interface can't express — it isn't an object shape, it's a union of two primitives. const id: ID = 42 passes because 42 is assignable to number, one member of the union.
  5. At runtime all four type declarations are erased. The emitted JavaScript is just const a = { name: "Ana" }; const b = a; const id = 42;.
AdvancedWhat only a type alias can name

Here is what only a type alias can name — every line is a type expression that isn't an object shape, or is computed from another type:

TypeScript
type PersonT = { name: string; age?: number };

type ID = string | number; // union
type Point = [x: number, y: number]; // tuple
type Callback = (err: Error | null) => void; // function type
type Status = "idle" | "loading" | "done"; // union of literal types
type Nullable<T> = T | null; // generic alias of anything
type PersonKeys = keyof PersonT; // computed from another type: "name" | "age"
type ReadonlyPerson = { readonly [K in keyof PersonT]: PersonT[K] }; // mapped type
What’s happening
  1. ID and Status are unions — "one of these". An interface always describes one object shape, so it has no way to say "a string or a number".
  2. Point is a tuple type with labelled positions (see Tuples). An interface could describe something array-like, but not a fixed-length tuple this directly.
  3. Callback is a function type expression. An interface can describe a function with a call signature (see Describing functions with types and interfaces), but the alias form is shorter.
  4. Nullable<T> and PersonKeys show that an alias can be built from any type operator: T | null for whatever T is passed in, and keyof PersonT, which the checker evaluates to "name" | "age".
  5. ReadonlyPerson is a mapped type (see Mapped types). Mapped and conditional types can only appear on the right of a type alias — an interface body can't contain them.
Advancedinterface vs type comparison table

The differences that matter in practice:

interfacetype alias
Object shapesYesYes
Unions, tuples, primitives, mapped and conditional typesNoYes
How you build on another typeextendsintersection &
Conflicting property when building on another typeError at the declarationProperty silently becomes never
Same name declared twiceMerges into one interfaceDuplicate identifier error
A class can implements itYesYes, if it's an object type (not a union)
Assignable to Record<string, unknown>No (no implicit index signature)Yes

The last row surprises people; it's explained under Index signatures. The extends-vs-& rows have their own topic: extends vs intersection.

#Extending interfaces and intersecting types

Both forms let you build a bigger type out of smaller ones, so you don't repeat properties. Interfaces do it with extends: "a Dog is everything an Animal is, plus these members." Type aliases do it with an intersection A & B: "a value that is an A and also a B", which for object types means it has the members of both.

The mental model for an intersection is a value that has to pass both checks at once. To be an A & B, an object must satisfy A's requirements and B's requirements, so it ends up needing all of their properties. (For unions vs intersections in general, and why string & number is never, see Unions vs intersections.)

TypeScript
interface Animal {
  name: string;
}

interface Dog extends Animal {
  breed: string;
}

const rex: Dog = { name: "Rex", breed: "Labrador" };
const stray: Dog = { name: "Rex" }; // ❌ TS2741: Property 'breed' is missing in type '{ name: string; }' but required in type 'Dog'.
What’s happening
  1. interface Dog extends Animal tells the checker to start Dog with every member of Animal (name: string) and add its own (breed: string). The checker builds one flat object type: { name: string; breed: string }.
  2. rex provides both properties with the right types, so it's assignable to Dog.
  3. stray is missing breed. The checker walks Dog's members, finds breed is required and absent, and reports TS2741 — note the message names Dog, the interface that declared the requirement.
  4. Nothing of this exists at runtime: extends on an interface is purely a type relationship. It's not class inheritance — no prototype chain is created.
AdvancedExtending type aliases with &

The same thing with type aliases, using the examples from my notes:

TypeScript
type Animal = { name: string };
type Dog = Animal & { breed: string };

const rex: Dog = { name: "Rex", breed: "Labrador" };

type A = { name: string };
type B = { age: number };
type C = A & B;

const person: C = { name: "John", age: 30 };
const partial: C = { name: "John" }; // ❌ TS2322: Type '{ name: string; }' is not assignable to type 'C'.
What’s happening
  1. Animal & { breed: string } is an intersection of two object types. To check a value against it, the checker requires the value to be assignable to Animal and to { breed: string }.
  2. rex has name (satisfies Animal) and breed (satisfies the second part), so it passes.
  3. type C = A & B works the same way: an object needs name: string to be an A and age: number to be a B. person has both.
  4. partial has name but not age. It's a valid A but not a valid B, so it fails the intersection. The full message continues "Property 'age' is missing in type '{ name: string; }' but required in type 'B'." — the checker tells you which constituent wasn't satisfied.
  5. Unlike interface, nothing new is "declared" here: C is just a name for the type expression A & B. Hover over C and the editor shows A & B, not a merged list of members.
AdvancedMultiple bases and narrowed inherited properties

An interface can extend several types at once, and it may narrow an inherited property as long as the new type is assignable to the old one:

TypeScript
interface HasId {
  id: string | number;
}

interface Timestamped {
  createdAt: Date;
}

interface Order extends HasId, Timestamped {
  id: string; // narrowed from string | number — allowed
  total: number;
}

const order: Order = { id: "ord_1", createdAt: new Date(), total: 99 };
const legacy: Order = { id: 7, createdAt: new Date(), total: 5 }; // ❌ TS2322: Type 'number' is not assignable to type 'string'.
What’s happening
  1. extends HasId, Timestamped copies the members of both parents into Order: id: string | number and createdAt: Date.
  2. Order then redeclares id as string. The checker verifies the redeclaration is compatible: is string assignable to string | number? Yes — so Order is still a valid HasId, and the narrower type wins.
  3. order uses a string id and passes.
  4. legacy uses a number id. That would have been fine for HasId, but Order narrowed id to string, so the checker reports the error on the id property.
  5. If Order had redeclared id as something incompatible, like boolean, the error would be on the interface declaration itself — see extends vs intersection for that case and how & behaves differently.

#Declaration merging

Interfaces are open. If you declare an interface with the same name twice in the same scope, TypeScript doesn't complain — it merges the two declarations into one interface with the members of both. Type aliases are closed: declaring the same alias name twice is an error.

Why would a language want this? Because JavaScript objects get extended after the fact, and the types have to follow. The built-in lib files rely on it: Array<T> is declared in lib.es5.d.ts, and later lib files (lib.es2016.array.include.d.ts, lib.es2022.array.d.ts…) re-open interface Array<T> to add includes, at and so on, depending on your target and lib settings. The same mechanism lets you add a property to Window or to a library's types (see Global declarations and extending window and Module augmentation).

This is the example from my notes:

TypeScript
interface Person {
  name: string;
}

interface Person {
  age: number;
}

const person: Person = { name: "John", age: 30 }; // works due to merging
const nameOnly: Person = { name: "John" }; // ❌ TS2741: Property 'age' is missing in type '{ name: string; }' but required in type 'Person'.
What’s happening
  1. The checker sees two interface Person declarations in the same module. Instead of reporting a duplicate, it merges them into a single Person with both members: { name: string; age: number }.
  2. person provides both properties, so it type-checks.
  3. nameOnly proves the merge really happened: the second declaration made age required, so leaving it out is an error even though the first declaration never mentioned age.
  4. Order doesn't matter for properties, and the declarations don't have to be next to each other — they just have to be in the same scope (the same module, or the global scope, or the same declare global / declare module block).
AdvancedMerging rules and conflicting properties

Merging has rules. A property that appears in both declarations must have the same type, and type aliases can't merge at all. This is the real compiler output:

TypeScript
interface Person {
  name: string;
  age: number;
}

interface Person {
  age: string; // ❌ TS2717: Subsequent property declarations must have the same type.  Property 'age' must be of type 'number', but here has type 'string'.
}

type Point = { x: number }; // ❌ TS2300: Duplicate identifier 'Point'.
type Point = { y: number }; // ❌ TS2300: Duplicate identifier 'Point'.
What’s happening
  1. The second Person redeclares age. When merging, the checker compares the two declarations of the same property; string is not identical to number, so it reports TS2717 on the later declaration. Merging can only add members, never change one.
  2. Note it's "must have the same type", not "a compatible type" — even narrowing (age: 30) would be rejected here. Narrowing is something extends allows; merging doesn't.
  3. The two type Point lines are both flagged with TS2300. A type alias is a binding of a name to one type expression, and a name can only be bound once per scope — there is no "merge" for aliases.
  4. This is the practical difference: if you want other code to be able to add to your type (a plugin system, a library's config object), it has to be an interface.
AdvancedMerged methods become overloads

Methods with the same name don't conflict — they become overloads of one method:

TypeScript
interface Logger {
  log(message: string): string;
}

interface Logger {
  log(code: number): number;
}

declare const logger: Logger;

const s = logger.log("started"); // const s: string
const n = logger.log(404); // const n: number
What’s happening
  1. Both declarations have a method called log but with different parameter types. For methods, merging collects the signatures into an overload list instead of reporting a conflict.
  2. The rule for order is that signatures from later declarations come first in the list (with one exception: signatures with a single string-literal parameter type are moved to the top). Here it doesn't matter because the parameter types don't overlap.
  3. logger.log("started") matches the (message: string): string signature, so s is string; logger.log(404) matches (code: number): number, so n is number.
  4. This is how DOM typings give document.createElement("a") the return type HTMLAnchorElement: specific string-literal overloads sit alongside a general (tagName: string) one.
AdvancedAdding a global with declare global

The most common real use is adding a global. Inside a module, wrap it in declare global so the merge targets the real global Window interface:

TypeScript
declare global {
  interface Window {
    __APP_VERSION__: string;
  }
}

window.__APP_VERSION__ = "1.4.2";
console.log(window.__APP_VERSION__.toUpperCase());
What’s happening
  1. declare global { … } opens the global scope from inside a module. Without it, interface Window in a module would declare a new, local Window that shadows the global one instead of merging with it.
  2. The DOM lib already declares interface Window with hundreds of members. Our declaration has the same name in the same (global) scope, so the two merge: Window now also has __APP_VERSION__: string.
  3. window is declared in the lib as declare var window: Window & typeof globalThis, so it picks up the new member immediately and window.__APP_VERSION__ is typed string.
  4. The type only describes the property — something at runtime (a script tag, a bundler define) still has to actually set it. More on this in Global declarations and extending window.
AdvancedPitfall: silent merges in scripts and classes

#Interfaces can extend types

extends isn't limited to other interfaces. An interface can extend any object type whose members are statically known: a type alias for an object, an intersection of object types, the result of a utility type like Omit<User, "password">, even a class (its instance type). The checker just needs to be able to list the parent's members so it can copy them in.

The one thing it can't extend is something that isn't a single, known shape — a union (which object is it?), a primitive, or a bare type parameter.

TypeScript
type Named = { name: string };
type Timestamps = { createdAt: Date; updatedAt: Date };

interface User extends Named, Timestamps {
  email: string;
  password: string;
}

interface PublicUser extends Omit<User, "password"> {
  avatarUrl?: string;
}

const pub: PublicUser = {
  name: "Ana",
  email: "ana@example.com",
  createdAt: new Date(),
  updatedAt: new Date(),
};

const withPassword = { ...pub, password: "hunter2" };
const leaked: PublicUser = withPassword; // fine: not a fresh literal, so no excess property check
What’s happening
  1. Named and Timestamps are type aliases for object types. interface User extends Named, Timestamps copies name, createdAt and updatedAt into User and adds email and password.
  2. Omit<User, "password"> is a mapped type (see Omit<T, K>) that the checker evaluates to { name: string; createdAt: Date; updatedAt: Date; email: string }. Its keys are known, so an interface can extend it. PublicUser adds an optional avatarUrl.
  3. pub provides every required member and leaves out the optional one — it passes. Writing password: "x" directly in that literal would be an excess-property error (see Excess property checking), because PublicUser has no password.
  4. leaked shows the limit of the idea. withPassword is inferred as PublicUser's members plus password: string. Assigning that variable (not a fresh literal) skips the excess property check, and an object that has a password still has everything a PublicUser needs, so it's accepted. Omit describes what you may rely on, not what the object is guaranteed not to contain.
AdvancedWhat an interface can't extend

What it can't extend:

TypeScript
type ID = string | number;
type Shape = { kind: "circle"; radius: number } | { kind: "square"; side: number };

interface BadId extends ID {} // ❌ TS2312: An interface can only extend an object type or intersection of object types with statically known members.
interface BadShape extends Shape {} // ❌ TS2312: An interface can only extend an object type or intersection of object types with statically known members.

type Tagged = Shape & { id: string }; // fine: & works on anything
What’s happening
  1. ID is a union of primitives — there are no members to copy, so TS2312.
  2. Shape is a union of two object types. Even though each side is an object, the union as a whole has no single member list (a circle has radius, a square has side), so extends rejects it with the same error.
  3. An intersection has no such restriction. Shape & { id: string } distributes over the union: the checker turns it into ({ kind: "circle"; radius: number } & { id: string }) | ({ kind: "square"; side: number } & { id: string }) — every shape gains an id.
  4. That's the practical rule: when the base is a union (or a generic T), you have to use a type alias with &.

#extends vs intersection

They look like two spellings of the same thing, and most of the time they produce the same shape. They are not equivalent in two ways that interviewers love: what happens when the two sides conflict, and how much work they cost the type checker.

With extends, the checker builds a new interface and verifies it is a valid subtype of its parent, so a conflicting property is an error right at the declaration. With &, the checker doesn't verify anything when you write the type — it just intersects the property types, and a conflict quietly produces never. You only find out later, when you try to create a value and can't.

extends — fails loudly at the declaration

TypeScript
interface A {
  id: string;
}

interface B extends A { // ❌ TS2430: Interface 'B' incorrectly extends interface 'A'.
  id: number;
}
What’s happening
  1. To build B, the checker copies A's members and then applies B's own. B redeclares id.
  2. It checks the redeclaration: is number assignable to string? No. So B would not be a valid A, and the declaration itself is rejected.
  3. The full message spells it out: "Types of property 'id' are incompatible. Type 'number' is not assignable to type 'string'."

& — compiles, then nothing fits

TypeScript
type A = { id: string };
type B = A & { id: number };

type BId = B["id"]; // type BId = never

const b: B = { id: "x" }; // ❌ TS2322: Type 'string' is not assignable to type 'never'.
What’s happening
  1. type B = A & { id: number } is accepted with no error — an intersection is never "invalid" to write.
  2. When you look at B["id"], the checker intersects the two property types: string & number. No value is both, so that's never.
  3. The error only appears at the point of use: "x" isn't assignable to never, and neither is any other value. B is a type nobody can construct.
AdvancedConflicting discriminants become never

It gets more dramatic with discriminant properties (literal-typed kind fields, as in Discriminated unions). When two literal types conflict, the checker reduces the entire intersection to never, not just the property:

TypeScript
type Circle = { kind: "circle"; radius: number };
type Square = { kind: "square"; side: number };

type Both = Circle & Square; // type Both = never

const both: Both = { kind: "circle", radius: 1, side: 2 }; // ❌ TS2322: Type 'string' is not assignable to type 'never'.
What’s happening
  1. Circle & Square intersects every property. radius and side don't clash, but kind is "circle" & "square" — two different literal types, which have no common value.
  2. Since TypeScript 3.9, when an intersection contains a property whose type reduces to never and that property is a discriminant (a literal type), the checker reduces the whole object type to never. Hovering Both shows never.
  3. So assigning even a "complete" object fails — the checker reports each property against never (the line above produces one error per property).
  4. Lesson: if you combine types with & and they disagree, you won't get a helpful error where you made the mistake; you'll get a confusing never somewhere downstream.
AdvancedCompatible overlaps in intersections

Not every overlap is a conflict. If one property type is assignable to the other, the intersection simply keeps the narrower one — exactly what extends would do:

TypeScript
type Base = { status: string; log(msg: string): void };
type Narrowed = Base & { status: "active"; log(code: number): void };

type Status = Narrowed["status"]; // type Status = "active"

declare const n: Narrowed;
n.log("ready"); // fine
n.log(200); // fine: methods from both sides become overloads
What’s happening
  1. status is string & "active". Every "active" is also a string, so the intersection simplifies to "active" — the narrower type.
  2. log appears on both sides with different parameter types. For function-typed members, an intersection means "callable both ways", so Narrowed["log"] behaves like an overloaded function with both signatures.
  3. With interface Narrowed extends Base { log(code: number): void } the checker would instead report TS2430, because (code: number) => void isn't assignable to (msg: string) => void. extends checks substitutability; & combines.
AdvancedPerformance and when to use each

The second difference is performance. The TypeScript team's own performance guidance recommends interfaces over intersections for object types: an interface is a single flat object type whose relationships to other types the checker can cache, while an intersection has to be re-examined constituent by constituent each time it's compared to something. In a small file you'll never notice; in a big codebase with deep A & B & C & … chains it shows up in build times (see Keeping the type checker fast).

When to use which:

  • extends for object hierarchies you own: AdminUser extends User. You get errors at the right place and the cheaper checks.
  • & when the base isn't something an interface can extend — a union, a generic type parameter (T & { id: string }), or the result of a conditional type — or when you're composing on the fly in a function signature.
  • If you want "B's properties replace A's" (an override, not a conflict), neither does that. Use Omit<A, keyof B> & B — see Merge two object types.
What is Merged["id"], and does the last line compile?
TypeScript
type WithStringId = { id: string; name: string };
type WithNumericId = { id: number; age: number };
type Merged = WithStringId & WithNumericId;

const m: Merged = { id: 1, name: "Ana", age: 30 };
Show answer

Merged["id"] is never, and the last line fails with "Type 'number' is not assignable to type 'never'" on id.

What’s happening
  1. type Merged = WithStringId & WithNumericId compiles — intersections are never rejected at the point where you write them.
  2. name and age don't overlap, so Merged has name: string and age: number.
  3. id appears on both sides: the checker intersects string & number. No value is both a string and a number, so Merged["id"] is never.
  4. string and number aren't literal types, so this is not the discriminant case — the object type itself survives, only the id property is never.
  5. Assigning { id: 1, … } fails on id, because 1 isn't assignable to never. No value of Merged can exist. Written as interface Merged extends WithStringId { id: number; age: number }, the same mistake would be reported on the interface declaration instead.

#When to use interface and when to use type

From my notes: use an interface for defining object shapes, especially in class-based code — APIs, configs, component props — because interfaces work well with classes that extend and implement them. Use a type for unions, intersections and tuples, and for aliases of primitives, arrays and functions.

That's a good rule of thumb, and it matches the TypeScript handbook's advice ("use interface until you need features from type"). These are the examples from my notes:

TypeScript
interface User {
  id: number;
  name: string;
  email: string;
}

class Person implements User {
  constructor(
    public id: number,
    public name: string,
    public email: string,
  ) {}
}

type ID = number | string;
type PersonT = { name: string; age: number };
type Employee = PersonT & { role: string };

type StringAlias = string;
type NumberArray = number[];
type AddFunction = (a: number, b: number) => number;

const add: AddFunction = (a, b) => a + b;
What’s happening
  1. User is a plain object shape, the textbook case for an interface.
  2. class Person implements User asks the checker to verify that a Person instance has id, name and email with matching types. The public modifiers on the constructor parameters are parameter properties — they declare and assign the fields in one go (see Parameter properties and readonly) — so the check passes.
  3. ID, Employee, StringAlias, NumberArray and AddFunction are all things that are either impossible with an interface (the union) or more natural as an alias (the intersection, the array, the function type).
  4. const add: AddFunction = (a, b) => a + b — the alias provides a contextual type for the arrow function, so a and b are inferred as number without annotations (see Contextual typing).
AdvancedCorrecting my notes: classes implementing types
AdvancedHow I choose interface or type

How I actually decide:

  • Interface for object shapes that are part of a public surface — library types, plugin contracts, anything other code may want to augment via Declaration merging — and for class hierarchies, where extends gives you early conflict errors.
  • Type alias for unions (especially Discriminated unions), tuples, function types, and anything computed with keyof, mapped or conditional types.
  • Type alias also when you need the value to be assignable to Record<string, unknown> (e.g. passing it to a generic "serialise any plain object" helper) — interfaces lack the implicit index signature (see Index signatures).
  • Plenty of teams pick one style for object shapes and lint for it (@typescript-eslint/consistent-type-definitions). Consistency matters more than which one you pick.
Which of these can only be written as a type alias?
TypeScript
// 1
{ id: string; save(): void }
// 2
"GET" | "POST"
// 3
[latitude: number, longitude: number]
// 4
(value: string) => boolean
// 5
{ [K in "a" | "b"]: number }
Show answer

2, 3 and 5 need a type alias. 1 and 4 can be interfaces.

What’s happening
  1. An object shape with a property and a method is exactly what an interface describes: interface Entity { id: string; save(): void }.
  2. A union of string literals is not an object shape, so only type Method = "GET" | "POST" works.
  3. A tuple type needs an alias. (You could write an array-like interface with numeric keys and a length, but it wouldn't be a real tuple type.)
  4. A function type can be an interface with a call signature: interface Predicate { (value: string): boolean }. The alias type Predicate = (value: string) => boolean is just shorter.
  5. A mapped type can only appear on the right side of a type alias; inside an interface body the compiler rejects it with "TS7061: A mapped type may not declare properties or methods".

#Implementing interfaces in classes

class X implements Y asks the compiler one question: is an instance of X assignable to Y? If not, you get an error at the class declaration. That's all it does. It doesn't copy any members into the class, doesn't give types to your method parameters, and leaves no trace in the emitted JavaScript.

Because TypeScript is structural, you don't even need implements for a class to be usable where a Y is expected — any class whose instances have the right shape already is one. The value of implements is moving the error to where it's most useful: the class itself, at the moment you write it, rather than wherever the instance is first passed around.

This is the example from my notes, plus what happens when something is missing:

TypeScript
interface Animal {
  makeSound(): void;
}

class Dog implements Animal {
  makeSound() {
    console.log("Woof!");
  }
}

interface Speaker {
  speak(): void;
  volume: number;
}

class Quiet implements Speaker { // ❌ TS2420: Class 'Quiet' incorrectly implements interface 'Speaker'.
  speak() {}
}
What’s happening
  1. For Dog implements Animal, the checker computes the instance type of Dog — { makeSound(): void } — and checks it's assignable to Animal. It is, so nothing is reported.
  2. For Quiet implements Speaker, the instance type is { speak(): void }. It's missing volume, so the class declaration gets TS2420. The full message continues "Property 'volume' is missing in type 'Quiet' but required in type 'Speaker'."
  3. Note that implements didn't add volume to Quiet. The fix is to declare it yourself (volume = 5;).
  4. The emitted JavaScript for both classes contains no mention of Animal or Speaker: class Dog { makeSound() { console.log("Woof!"); } }.
Advancedimplements doesn't type method parameters

The surprising part: implements doesn't give your method parameters their types.

TypeScript
interface Handler {
  handle(event: string): number;
}

class ClickHandler implements Handler {
  handle(event) { // ❌ TS7006: Parameter 'event' implicitly has an 'any' type.
    return event.length;
  }
}

class TypedHandler implements Handler {
  handle(event: string) {
    return event.length;
  }
}
What’s happening
  1. You might expect handle(event) to pick up event: string from Handler, the way an arrow function picks up parameter types from a variable's annotation. It doesn't: class members are checked against the interface, but their types come from the class body only.
  2. So event has no type annotation and no contextual type; under strict (noImplicitAny) that's TS7006.
  3. In TypedHandler, event: string is written out. The checker infers the return type number from event.length, then checks (event: string) => number against Handler["handle"] — compatible, so it passes.
  4. Having implements type the members has been a long-standing feature request, but it isn't how the language works: a class's types come from its own body, and implements is purely a check afterwards.
AdvancedMore facts about implements

A few more facts worth knowing:

  • A class can implement several interfaces: class Duck implements Swimmer, Flyer.
  • implements only checks the instance side. Static members and the constructor signature aren't checked against the interface; to constrain those, type the class itself as a value (e.g. const Ctor: PluginConstructor = MyPlugin).
  • You can implements a class name, which treats that class's instance type as an interface (useful for test doubles).
  • extends and implements can be combined: class Admin extends User implements Auditable — the first gives you real inherited code; the second only adds a check.

#Describing functions with types and interfaces

Functions are values in JavaScript, so you need ways to write their types: for a variable holding a callback, for a parameter that receives one, for an object property that is a method. TypeScript gives you three syntaxes, and they mostly describe the same thing.

  1. A function type expression: (a: number, b: number) => number. The parameter names are documentation only.
  2. A call signature inside an object type or interface: { (a: number, b: number): number } — note the : instead of =>. This is the form to use when the function also has properties.
  3. For functions that live on objects, method syntax greet(name: string): void or property syntax greet: (name: string) => void. These look interchangeable, but under strictFunctionTypes they're checked differently.
TypeScript
type AddFn = (a: number, b: number) => number;

interface AddFnI {
  (a: number, b: number): number;
}

const add: AddFn = (x, y) => x + y;
const add2: AddFnI = add;

interface Counter {
  (): number;
  count: number;
  reset(): void;
}

function makeCounter(): Counter {
  const counter = () => ++counter.count;
  counter.count = 0;
  counter.reset = () => {
    counter.count = 0;
  };
  return counter;
}

const next = makeCounter();
next(); // 1
next(); // 2
next.reset();
console.log(next.count); // 0
What’s happening
  1. AddFn and AddFnI describe the same function type, one as an alias, one as an interface with a call signature. add2: AddFnI = add passes because function types are compared structurally: same parameters, same return.
  2. In add, the parameters x and y have no annotations. The variable's type provides a contextual type, so the checker gives them number from AddFn (the names don't need to match — only positions count).
  3. Counter is a hybrid: it has a call signature (): number (you can call it) and properties count and reset. Only the call-signature form can express this; () => number alone can't have properties.
  4. Inside makeCounter, counter starts as a plain arrow function. TypeScript (3.1+) recognises property assignments to a const function in the same scope (counter.count = 0, counter.reset = …) and adds them to the function's type, so returning counter satisfies Counter without an assertion.
  5. At runtime, next() runs ++counter.count: 0 → 1, then 1 → 2. next.reset() sets it back to 0. Functions are objects, so storing properties on them is ordinary JavaScript — the call signature just lets the types describe it.
AdvancedMethod vs property function syntax

The two syntaxes for a function as an object property look the same, but they aren't checked the same way:

TypeScript
interface MethodStyle {
  handle(value: string | number): void;
}

interface PropertyStyle {
  handle: (value: string | number) => void;
}

const onlyStrings = (value: string) => console.log(value.toUpperCase());

const m: MethodStyle = { handle: onlyStrings }; // compiles: method parameters are bivariant
const p: PropertyStyle = { handle: onlyStrings }; // ❌ TS2322: Type '(value: string) => void' is not assignable to type '(value: string | number) => void'.

m.handle(42); // compiles; at runtime: TypeError: value.toUpperCase is not a function
What’s happening
  1. onlyStrings can only handle strings. Both interfaces promise callers they may pass a string | number.
  2. For PropertyStyle, strictFunctionTypes (part of strict) checks parameters contravariantly: the function being assigned must accept at least everything the type promises. string doesn't cover string | number, so the assignment is rejected. The message continues "Types of parameters 'value' and 'value' are incompatible. Type 'string | number' is not assignable to type 'string'."
  3. For MethodStyle, the same check is bivariant: it passes if the parameter types are related in either direction. string is assignable to string | number, so it's accepted — even though it's unsound.
  4. The unsoundness is real: m.handle(42) type-checks, onlyStrings receives a number, and value.toUpperCase is undefined — calling it throws.
  5. Why does method bivariance exist? Without it, Dog[] wouldn't be assignable to Animal[], because Array<T>'s methods like push(item: T) would make arrays invariant. The TypeScript team kept methods bivariant so that common, mostly-safe patterns keep working.
AdvancedPrefer property syntax for functions

So, from my notes, "function as object properties: 2 syntax" — and the practical advice is: prefer property syntax for callbacks (onChange: (value: string) => void) so you get the strict check. Use method syntax for genuine methods on classes and interfaces that model objects. The lint rule @typescript-eslint/method-signature-style can enforce the property style.

Function overloads are covered in Function overloads, and typing this inside functions in Typing this.

#Index signatures

Sometimes you know the type of the values but not the names of the keys: a cache keyed by URL, word counts, a map of feature flags loaded from a server. An index signature describes that: { [key: string]: number } means "any string key, and every value is a number". The name key is just documentation.

Think of it as a promise about every possible key at once. That's powerful, and it's also why index signatures need care: the checker believes the promise even for keys that don't exist.

TypeScript
const scores: { [name: string]: number } = {
  alice: 90,
  bob: 72,
};

scores.carol = 85; // fine: any string key is allowed
scores["dave"] = 60;
scores.erin = "A"; // ❌ TS2322: Type 'string' is not assignable to type 'number'.

const missing = scores.zoe; // const missing: number
missing.toFixed(1); // compiles; at runtime: TypeError: Cannot read properties of undefined (reading 'toFixed')
What’s happening
  1. The annotation says every string key maps to a number. The literal's alice and bob are checked against that: both numbers, so it passes.
  2. Writing scores.carol or scores["dave"] is allowed even though those keys weren't in the literal — the index signature covers every string key, so dot and bracket access behave the same.
  3. scores.erin = "A" fails because the value type is wrong; the key is fine.
  4. scores.zoe is typed number, not number | undefined. The checker takes the signature at its word. At runtime zoe doesn't exist, missing is undefined, and .toFixed throws.
  5. The fix is the noUncheckedIndexedAccess compiler option (see A practical tsconfig.json): it makes every index-signature read number | undefined, forcing you to check. Alternatively use a Map, whose get already returns V | undefined.
AdvancedNamed properties alongside index signatures

Named properties and index signatures can live together, but every named property must fit the index signature, because the signature is a promise about all string keys — including that one:

TypeScript
interface Config {
  [key: string]: number;
  name: string; // ❌ TS2411: Property 'name' of type 'string' is not assignable to 'string' index type 'number'.
}

interface ConfigFixed {
  [key: string]: number | string;
  name: string;
  retries: number;
}

interface Headers {
  readonly [key: `x-${string}`]: string;
  accept: string;
}

const h: Headers = { accept: "text/html", "x-request-id": "abc", authorization: "t" }; // ❌ TS2353: Object literal may only specify known properties, and 'authorization' does not exist in type 'Headers'.
What’s happening
  1. In Config, name is a string key, so config.name must also satisfy "every string key is a number". It's a string, so TS2411.
  2. ConfigFixed widens the index signature to number | string. Now name: string and retries: number both fit. The price: an unknown key now reads as number | string, which is less useful.
  3. Headers uses a template literal pattern as the key type (TypeScript 4.4+): only keys that start with x- are covered. readonly means you can read through the signature but not assign.
  4. In h, accept matches the named property and "x-request-id" matches the pattern. authorization matches neither, so in a fresh object literal it's an excess property and TS2353 fires.
  5. Key types allowed in an index signature: string, number, symbol, template literal patterns, and unions of these. A number index signature ({ [i: number]: T }) is how array-like types are described; numeric keys are still strings at runtime, which is why a number signature's value type must be compatible with any string signature in the same type.
AdvancedImplicit index signatures: interface vs type

One more difference between interfaces and type aliases lives here, and it's a classic "why doesn't this compile?" question:

TypeScript
interface UserI {
  name: string;
}

type UserT = {
  name: string;
};

declare const ui: UserI;
declare const ut: UserT;

const a: Record<string, unknown> = ut; // fine
const b: Record<string, unknown> = ui; // ❌ TS2322: Type 'UserI' is not assignable to type 'Record<string, unknown>'.
What’s happening
  1. Record<string, unknown> is { [key: string]: unknown }: an object where every string key holds something. Any object would seem to qualify.
  2. For the type alias UserT, the checker infers an implicit index signature: it knows every property of UserT (name: string), all of them fit unknown, so the assignment passes.
  3. For the interface UserI, the checker refuses to infer one, and reports "Index signature for type 'string' is missing in type 'UserI'". The reason is declaration merging: an interface can be re-opened elsewhere and gain properties, so the checker won't assume it knows the complete set of keys.
  4. Fixes if you hit this with an interface: use a type alias, add an explicit index signature to the interface, or loosen the parameter to object. This is the one place where switching interface to type changes what compiles.

#Index signatures vs mapped types

Both describe "an object whose keys are some set of strings". The difference is whether that set is open or closed.

An index signature is open: { [lang: string]: string } allows any number of keys — zero, one, or a thousand — and promises nothing about which exist. A mapped type is closed: { [L in "en" | "fr"]: string } iterates over a specific union of keys and produces exactly one property for each, so the result is { en: string; fr: string } — both required, nothing else allowed in a literal. Record<K, V> is a mapped type (see Record<K, V>); with K = string the mapped type has no specific keys to iterate, so it becomes an index signature.

TypeScript
type Lang = "en" | "fr" | "de";

type Open = { [lang: string]: string };
type Closed = { [L in Lang]: string }; // type Closed = { en: string; fr: string; de: string }

const greetingsOpen: Open = { en: "Hello" }; // fine: any subset of any keys
const greetingsClosed: Closed = { en: "Hello", fr: "Bonjour" }; // ❌ TS2741: Property 'de' is missing in type '{ en: string; fr: string; }' but required in type 'Closed'.

type Wrong = { [lang: Lang]: string }; // ❌ TS1337: An index signature parameter type cannot be a literal type or generic type. Consider using a mapped object type instead.

type Same = Record<string, string>; // identical to Open
What’s happening
  1. Open accepts { en: "Hello" } — it would accept {} too, or { xx: "?" }. It describes values, not keys.
  2. Closed is a mapped type: the checker takes the union "en" | "fr" | "de" and, for each member, emits one property with type string. The result has three required properties.
  3. greetingsClosed leaves out de, so it fails. That's the point of a closed type: adding "es" to Lang will flag every object that forgot a Spanish greeting.
  4. Wrong tries to put a literal union in an index signature. Index signature keys must be string, number, symbol or a template pattern, and the compiler's own message tells you the alternative: a mapped type.
  5. Record<string, string> expands to { [P in string]: string }; mapping over plain string can't produce individual properties, so the checker produces the index signature { [x: string]: string } — the same type as Open.
AdvancedPer-key value types with mapped types

Mapped types can also do things index signatures can't: give each key a different value type, derived from the key.

TypeScript
interface Settings {
  theme: "light" | "dark";
  fontSize: number;
}

type Setters = {
  [K in keyof Settings]: (value: Settings[K]) => void;
};

const setters: Setters = {
  theme: (value) => console.log(value), // value: "light" | "dark"
  fontSize: (value) => console.log(value.toFixed(0)), // value: number
};
What’s happening
  1. keyof Settings is "theme" | "fontSize". The mapped type visits each key in turn.
  2. For K = "theme", the property type is (value: Settings["theme"]) => void, i.e. (value: "light" | "dark") => void.
  3. For K = "fontSize", it's (value: number) => void.
  4. In setters, each arrow function gets its parameter type from the matching property — that's why value.toFixed(0) is allowed for fontSize but would be an error for theme.
  5. An index signature can only say "every key has this one value type". Per-key types need a mapped type, and mapped types can only be written as type aliases (an interface body that tries [K in …] gets "TS7061: A mapped type may not declare properties or methods").
AdvancedChoosing index signature or mapped type

Rule of thumb: if the keys come from data (user ids, URLs, words) use an index signature or a Map. If the keys come from a type you already have (a union of names, keyof something), use a mapped type or Record so the compiler can tell you when a key is missing.

#PropertyKey and keyof any

Advanced

JavaScript allows three kinds of property key: strings, numbers (which the engine turns into strings) and symbols. TypeScript has a built-in alias for exactly that set — PropertyKey, declared in lib.es5.d.ts as string | number | symbol — and you can compute the same type with keyof any. They show up as constraints: "this type parameter must be something usable as a key."

Why does keyof any produce that? keyof T is "the keys you could index T with". For any, you can index with anything a key can be, so the answer is every possible key type. The built-in utility types use it: Record is declared as type Record<K extends keyof any, T> = { [P in K]: T }, and Omit takes K extends keyof any. PropertyKey is the more readable spelling of the same thing.

TypeScript
type K1 = keyof any; // type K1 = string | number | symbol
type K2 = PropertyKey; // type K2 = string | number | symbol

function groupBy<T, K extends PropertyKey>(items: readonly T[], getKey: (item: T) => K): Partial<Record<K, T[]>> {
  const groups: Partial<Record<K, T[]>> = {};
  for (const item of items) {
    const key = getKey(item);
    (groups[key] ??= []).push(item);
  }
  return groups;
}

type Team = "web" | "api";
type Person = { name: string; team: Team };

const people: Person[] = [
  { name: "Ana", team: "web" },
  { name: "Raj", team: "api" },
  { name: "Lee", team: "web" },
];

const byTeam = groupBy(people, (p) => p.team); // const byTeam: Partial<Record<Team, Person[]>>
console.log(byTeam.web?.length); // 2

groupBy(people, (p) => ({ id: p.name })); // ❌ TS2322: Type '{ id: string; }' is not assignable to type 'PropertyKey'.
What’s happening
  1. K extends PropertyKey constrains the key type: whatever getKey returns must be a string, number or symbol, because it's going to be used as an object key.
  2. For byTeam, the checker infers T = Person from people, and K = Team from the callback's return type (p.team is Team). If team were typed string, K would be string and you'd get an index signature instead of named keys.
  3. The return type Partial<Record<Team, Person[]>> is an object with optional web and api arrays. It's Partial because a team with no members never gets a key — the honest type.
  4. At runtime, the loop computes "web", "api", "web". groups[key] ??= [] creates the array the first time a key is seen, then push adds the item: { web: [Ana] }, { web: [Ana], api: [Raj] }, { web: [Ana, Lee], api: [Raj] }. So byTeam.web?.length is 2.
  5. The last call returns an object from getKey. The constraint rejects it: an object can't be a property key (JavaScript would turn it into the string "[object Object]", which is never what you want).

Two keyof results that catch people out:

TypeScript
type Dict = { [key: string]: boolean };
type NumIndexed = { [index: number]: string };

type DictKeys = keyof Dict; // type DictKeys = string | number
type NumIndexedKeys = keyof NumIndexed; // type NumIndexedKeys = number
type Nothing = keyof unknown; // type Nothing = never
What’s happening
  1. Dict has a string index signature, yet keyof Dict is string | number. Because JavaScript converts numeric keys to strings, dict[0] reads the same property as dict["0"] — so a number is a valid key for a string-indexed object, and keyof includes it.
  2. NumIndexed has a number index signature, so keyof is just number. A string like "length" isn't covered by it.
  3. keyof unknown is never: you know nothing about an unknown value, so there's no key you can safely use. (keyof object and keyof {} are also never.) Compare keyof any, where you're allowed everything.
  4. This is why generic code that needs "string keys only" writes Extract<keyof T, string> or keyof T & string — see The keyof operator.

Built from Rohit’s “Typescript Interview” study doc. Examples are checked with the TypeScript compiler in strict mode.

RohitDownloads · All chapters

Sync progress across devices

Type the same private phrase on your Mac and your phone, and your Learned ticks follow you between them — on every study site.

The phrase never leaves this device: only a fingerprint of it is sent, and the server stores a fingerprint of that. Anyone who knows the phrase could see or change your ticks, so pick something you don’t use elsewhere.

Sync is on in this browser.

Sync ID

This ID must be the same on every device. If another device shows a different one, its phrase is different (capital letters count): tap “Turn off here” on it and type the phrase again exactly.