Lesson 8 of 20

Interfaces

An Interface Names a Shape

An interface gives a name to the shape of an object: which properties it has, and what type each one holds. That is all it is — a description, written once, that you can then reuse in every annotation instead of repeating a block of braces.

This is the point where TypeScript starts feeling worthwhile rather than fussy. Real programs are built around a handful of central shapes: a user, an order, a course, an API response, a component's props. Writing each of those down as an interface gives you one place to look when you need to know what a user contains, one place to change when the shape changes, and an editor that can autocomplete every field.

Interfaces are pure type information. Nothing is emitted to JavaScript, there is no runtime object called User, and an interface cannot check the data arriving from your server. Use them to describe what you expect, and validate the actual data separately.

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

const student: User = {
  id: 118,
  name: "Ananya Sharma",
  email: "ananya@example.com"
};

// The same interface everywhere the shape appears
function sendWelcome(user: User): void {
  console.log(`Welcome ${user.name} (${user.email})`);
}

function listNames(users: User[]): string[] {
  return users.map(u => u.name);
}

// Missing a property is an error at the point of construction
const broken: User = { id: 2, name: "Rahul" };
// ERROR: Property 'email' is missing in type '{ id: number; name: string; }'
//        but required in type 'User'.
Notes
  • Naming convention: use singular PascalCase nouns — User, Order, ButtonProps. The older habit of prefixing with a capital I, as in IUser, comes from C# and is not used by the TypeScript codebase itself or by most modern JavaScript projects.

The Shape Is the Contract, Not the Name

TypeScript compares shapes, not names. An object satisfies User if it has the required properties with compatible types — it does not need to mention User anywhere, and it may carry extra properties on top. This is structural typing, and it is why TypeScript fits so comfortably over ordinary JavaScript, where objects are assembled ad hoc all the time.

It has a practical consequence worth planning around. Two interfaces with different names but identical shapes are interchangeable, so naming alone will not stop you passing a Metres where a Feet is expected. If you need those kept apart, you need a branded type, which the best-practices lesson covers.

Now the rule that generates the most confused questions. Object literals get an extra check called the excess property check: unknown properties are rejected. Assign the same literal to a variable first and it passes. This is not inconsistency — TypeScript reasons that a literal written directly at a call site has no other purpose, so an unrecognised property is almost certainly a typo, whereas a variable might be shared with other code that needs the extra fields.

When you hit that error, the right response is almost always to fix the property name or add it to the interface. Silencing it with as User works and throws away the check that just caught your typo.

Example
interface Point {
  x: number;
  y: number;
}

function plot(p: Point) {
  console.log(p.x, p.y);
}

// Never mentions Point, has an extra property — still fits
const marker = { x: 5, y: 9, label: "start" };
plot(marker);   // OK

// The same object written inline is checked more strictly
plot({ x: 5, y: 9, label: "start" });
// ERROR: Object literal may only specify known properties,
//        and 'label' does not exist in type 'Point'.

// Which is exactly what you want when it is a typo
interface Config { timeout: number; retries: number }

const c: Config = { timeout: 5000, retires: 3 };
// ERROR: 'retires' does not exist in type 'Config'.
// Did you mean to write 'retries'?
Notes
  • Structural typing is what makes an interface useful for describing data you did not create — a JSON response, a third-party object, the result of Object.fromEntries. You are describing the shape you need, not demanding that someone declare it.

Optional and readonly Properties

A ? marks a property that may be absent. Its type inside the object becomes T | undefined, so you must check before using it — and that check is the whole point. An optional discount that you read without checking is a runtime error waiting for the first order that has no discount.

Use optional properties for things that are genuinely sometimes missing, not as a way to avoid filling in test data. Every ? you add is a check that every consumer of the type now has to write, so an interface where most fields are optional pushes work onto everyone who touches it.

readonly marks a property that cannot be reassigned after the object is created. It is the right modifier for identity fields — an id, a createdAt — where changing the value would mean you are looking at a different record. Two limits matter. It is checked at compile time only, so nothing stops plain JavaScript from writing to the property at runtime; if you need real immutability, use Object.freeze. And it is shallow: a readonly property holding an object still lets you change that object's own fields.

Example
interface Product {
  readonly id: number;
  name: string;
  price: number;
  description?: string;
  readonly tags: string[];
}

const item: Product = {
  id: 91,
  name: "Wireless Mouse",
  price: 799,
  tags: ["input", "usb"]
};

item.price = 749;    // OK
item.id = 92;        // ERROR: Cannot assign to 'id' because it is a read-only property.

// Optional means you must check
console.log(item.description.length);
// ERROR: 'item.description' is possibly 'undefined'.
console.log(item.description?.length ?? 0);   // fine

// readonly is shallow — the array itself is still mutable
item.tags.push("wireless");   // allowed!
// Use readonly string[] for the element type to close that too:
//   readonly tags: readonly string[];
Notes
  • readonly exists only in the type system. Compile the file and the modifier is gone, so a JavaScript caller — or a JSON.parse result cast to your type — can overwrite the field without anything complaining.

Extending Interfaces

An interface can build on others with extends, inheriting all their members. This is how you model shared structure without repeating it: a set of timestamp fields that every stored record has, a base set of props that several components share, an API envelope that wraps different payloads.

You can extend more than one interface at once, and the result simply contains everything. If two parents declare the same property with the same type, that is fine. If they declare it with different types, extends reports an error and tells you exactly which member conflicts — which is genuinely useful, and is one of the practical differences between extends and the intersection operator you will meet in the next lesson.

A child interface may also narrow an inherited property, as long as the narrower type is assignable to the original: redeclaring status: string as status: "active" | "blocked" is allowed, while changing it to number is not. Interfaces can extend object-shaped type aliases too, so mixing the two styles is not a problem.

Example
interface Timestamps {
  readonly createdAt: Date;
  updatedAt: Date;
}

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

// Everything from both, plus its own members
interface StoredUser extends User, Timestamps {
  lastLoginAt?: Date;
}

const record: StoredUser = {
  id: 118,
  name: "Meera",
  email: "meera@example.com",
  createdAt: new Date(),
  updatedAt: new Date()
};

// Narrowing an inherited property is allowed
interface Account { status: string }
interface StrictAccount extends Account {
  status: "active" | "blocked";   // OK — narrower than string
}

// Widening or changing it is not
interface BadAccount extends Account {
  status: number;
}
// ERROR: Interface 'BadAccount' incorrectly extends interface 'Account'.

Methods, Call Signatures and Index Signatures

Interfaces describe behaviour as well as data. A method is written as a name, a parameter list and a return type. Any object that supplies functions of the right shape satisfies it, whether that object is a class instance or a plain object literal — structural typing again.

An index signature describes an object used as a lookup table, where the keys are not known in advance: [key: string]: string means "any string key maps to a string value". This is the right tool for a dictionary of translations, a map of colour names to hex codes, or a bag of query parameters.

Index signatures come with one rule that surprises people: every explicitly declared property must also be compatible with the index signature's value type. If your index says all values are strings, you cannot add a count: number alongside it. The reason is that obj["count"] would then contradict the index signature. When you need mixed types, either widen the index value to a union or model the object properly with named properties.

Be aware that reading a key from an index signature is typed as always present — colors["chartreuse"] is string, not string | undefined, even though it is almost certainly missing. That is the same gap you met with array indexing, and the same flag, noUncheckedIndexedAccess, closes it.

Example
interface Calculator {
  add(a: number, b: number): number;
  subtract(a: number, b: number): number;
  history: string[];
}

const calc: Calculator = {
  add: (a, b) => a + b,          // parameters typed by context
  subtract: (a, b) => a - b,
  history: []
};

// A lookup table with unknown keys
interface ColorMap {
  [name: string]: string;
}

const colors: ColorMap = {
  red: "#ff0000",
  green: "#00ff00"
};

colors.blue = "#0000ff";        // any string key is allowed
const missing = colors.purple;   // typed string — but undefined at runtime

// Declared properties must match the index signature
interface Broken {
  [key: string]: string;
  count: number;
}
// ERROR: Property 'count' of type 'number' is not assignable to
//        'string' index type 'string'.
Notes
  • For a lookup table with a known set of keys, prefer the Record utility type: Record<"admin" | "teacher", string[]> forces you to supply an entry for every key. The utility-types lesson covers it.

Declaration Merging: the Interface-Only Superpower

Declare the same interface name twice in the same scope and TypeScript does not complain about a duplicate — it merges them into one interface containing all the members. Type aliases cannot do this; declaring a type twice is an error.

Inside your own code this is mostly a hazard. Two developers can each add an interface called Options in the same file and end up with a single type nobody wrote, and the errors that follow point at the usage rather than the cause. If you want a merge, do it on purpose with extends.

Where merging genuinely earns its place is augmenting types you do not own. Because interfaces from a library merge with your own declarations of the same name, you can add a property to something declared elsewhere. Adding a field to Express's Request object so your authentication middleware can attach the current user is the standard example, and it is a real, everyday use.

This ability to be reopened is the main reason to prefer interface for public object shapes in a library, and the main reason some teams prefer type for internal ones, where being unable to reopen a type is a feature. The next lesson compares the two properly.

Example
// Two declarations, one interface
interface Book {
  title: string;
}

interface Book {
  author: string;
}

const b: Book = { title: "Wings of Fire", author: "A. P. J. Abdul Kalam" };
// Both properties are required — they merged

// The useful case: adding to a type from a library
declare global {
  interface Window {
    dataLayer: unknown[];
  }
}

window.dataLayer.push({ event: "lesson_complete" });   // now type-checked

// Merging with a conflicting type is rejected
interface Book { title: number }
// ERROR: Subsequent property declarations must have the same type.
Notes
  • declare global only works inside a file that is already a module — one with at least one import or export. In a script file with no imports, the declarations are global already and the wrapper is an error.
Ask AI