TypeScript Interfaces and Contracts

In JavaScript, an object's "contract" lives in convention: a README, a JSDoc comment, a teammate's memory. Interfaces make that contract a compile-time artifact the checker enforces on every read and write. This lesson covers the syntax — but more importantly, the two jobs interfaces do better than type aliases: naming shapes and merging declarations.

Why interfaces exist

JavaScript already checks shapes — at runtime, by failing. The classic failure: a typo'd property or a missing field from an API survives every test that doesn't hit the exact path, then explodes as undefined is not a function in production. An interface moves that discovery from runtime to compile time by writing the contract down once, where the checker sees every use.

The contract in action

Purpose of the example: define an object contract and watch the checker enforce it at every touchpoint — construction, mutation, and property access.

interface User {
  id: string;
  email: string;
  active: boolean;
}

// Construction: every required field must be present, with the right types
const u: User = {
  id: "u_42",
  email: "ada@example.com",
  active: true,
  // admin: true,          // ← error: excess property (see below)
};

// Mutation: only known properties, only compatible types
u.active = false;
// u.actve = false;        // ← error: Property 'actve' does not exist

// Access: no undefined-in-disguise
console.log(u.email.toUpperCase()); // checker knows email is always string

What you should see: a program that compiles only when every contract line is honored. The commented lines each produce a distinct, precise diagnostic — including the typo actve, which in JavaScript would silently create a new property and fail later, somewhere else.

Structural typing: the rule behind the check

Interfaces are names for shapes, not gated clubs. Compatibility is decided by structure, not by declared identity — which is why TypeScript stays compatible with idiomatic JavaScript:

A Person value flowing into a LoggedIn slot: the checker compares shape lists, not names; extra members are allowed, missing members are not
Figure 1 — Structural typing. The checker asks "does this value have at least these members with these types?" — never "was this value declared as this interface?" Extra members are fine; missing members are the error.
interface LoggedIn { userId: string; since: Date }

// No "implements", no declaration linking — it just fits:
const session = { userId: "u_42", since: new Date(), device: "macOS" };
const rec: LoggedIn = session;   // OK: has userId: string and since: Date

What you should see: assignment accepted without any explicit relationship between the types. What we learn: in TypeScript, "is a" is replaced by "has the shape of" — the reverse of nominal systems like Java, and the reason integrating untyped JS data is straightforward.

Excess property checking: the one special case

Structural typing allows extra properties, yet the first example flagged admin: true as an error. That is excess property checking: when an object literal is assigned directly to a typed target, the checker treats unknown keys as probable typos. The rule is narrow and worth memorizing:

const later: LoggedIn = session;              // OK — variable, no literal check
const direct: LoggedIn = {                    // ← error on 'usrId' (typo guard)
  usrId: "u_42", since: new Date(), device: "macOS",
};

What you should see: the literal with the typo fails even though structurally "more" information is present. What we learn: freshness is checked only at the literal; store-then-assign flow bypasses it, which is why typos on untyped boundaries still need runtime tests.

Member syntax: optional, readonly, and methods

Contracts are more useful when they express how a field behaves, not just its type.

Optional and readonly members

Purpose of the example: model "sometimes absent" and "set once, then immutable" — two facts JavaScript cannot express but every API implies.

interface Profile {
  readonly id: string;        // assignable at creation, never after
  displayName?: string;       // may be absent — type is string | undefined
}

function render(p: Profile): string {
  // strictNullChecks forces you to handle absence:
  return p.displayName ?? "Anonymous user"; // ← compiler-required fallback
}

const p: Profile = { id: "p_1" };
p.displayName = "Ada";   // OK
// p.id = "p_2";         // ← error: Cannot assign to 'id' — read-only

What you should see: removing the ?? fallback produces an error under strict mode. What we learn: ?: is documentation the compiler enforces; readonly catches mutation bugs at compile time that would otherwise rely on code review.

Composition and API contracts

Contracts scale by composition: small named interfaces assembled into larger ones, mirroring how production APIs actually evolve.

extends: building larger contracts

Purpose of the example: compose contracts instead of repeating fields — the interface equivalent of "spread this base shape".

interface Timestamped {
  createdAt: Date;
  updatedAt: Date;
}

interface Product extends Timestamped {
  id: string;
  name: string;
  price: number;
}

// Every Product is also a valid Timestamped — structurally guaranteed:
function audit(t: Timestamped): Date { return t.updatedAt; }
const book: Product = {
  id: "b_1", name: "Types at Scale", price: 39,
  createdAt: new Date(), updatedAt: new Date(),
};
console.log(audit(book)); // fine: Product extends Timestamped

What you should see: one level of composition; real codebases stack several. What we learn: extends on interfaces is declarative and merge-free — every extension is checked, and a conflicting redefinition (e.g. re-typing createdAt: string) is an immediate error, unlike class inheritance where the constraint is looser.

Index signatures: dynamic keys

Some objects genuinely have unknown key sets — dictionaries, caches, i18n tables. Index signatures type the "rest":

interface Messages {
  greeting: string;              // fixed, required key
  [locale: string]: string;      // any other key must map to string
}

const i18n: Messages = { greeting: "Hello", fr: "Bonjour", de: "Hallo" };
console.log(i18n["fr"]);         // all reads return string — never undefined here

What you should see: a trade-off, not a free lunch: with noUncheckedIndexedAccess (set in the setup lesson) the indexed read becomes string | undefined — honest about what a map lookup is. What we learn: index signatures trade precision for flexibility; prefer Record<string, string> or Map when the whole object is dynamic.

Interface versus type alias

By now you have seen both interface X { … } and type X = { … } describe object shapes. The choice is a team-level convention with one technical backbone.

The capability table

Capabilityinterfacetype
Describe object shapesyesyes
Unions, primitives, tuples, conditional typesnoyes
Declaration merging (reopen and extend)yesno
extends other object typesyesvia intersection &

Declaration merging: the one interface-only power

Purpose of the example: show the one behavior only interfaces have — and why it is a double-edged sword.

// augmenting a library interface — merging in action
interface Window { sageReady: boolean }   // your addition to a lib interface
interface Window { analytics: unknown }   // declared elsewhere — merges with it

// At use time, both members exist on Window. Types cannot do this:
// type Window { sageReady: boolean }  // ← duplicate identifier error

What we learn: merging is essential for augmenting library and DOM types (the declarations lesson builds on it) but dangerous for your own domain types — an accidental second declaration silently widens a contract instead of erroring. Common professional convention: interface for object shapes and public API contracts, type for everything else.

Contract evolution and versioning

Backend contracts change; your interfaces must make that visible.

Three practices for changing contracts

  • Version the type (UserV2) while migrating instead of mutating it in place, so both shapes coexist during the rollout.
  • Write an explicit adapter function from DTO to domain type, so every field rename is one compiler-guided change rather than a grep-and-hope search.
  • Parse at the boundary: never let a raw API response object reach a component — validate it into the interface first (the errors lesson covers the gap checking cannot close).

Practice: contracts under pressure

Refactor with the checker

Take the User contract, rename email to primaryEmail, and let tsc --noEmit list every broken touchpoint. What you should see: a compile-error-driven todo list — exactly the places a JavaScript rename would have found by grepping and hoping. This is the daily workflow benefit interfaces buy.

Model a real response

Pick a public API response (GitHub's GET /users/:name is a good one) and write its interface: required fields, optional fields (?), readonly identity fields, and an index signature where the API allows extra keys. What you should see: the decisions you are forced to make — "is bio always present?" — are the same ones JavaScript developers leave implicit; here they become checked facts. Then read the classes lesson, where these contracts meet implementation.