Emmanuel Akinfulubi

Lesson 03 of 1818 min read

Objects, Interfaces and Structural Typing

Describing the shape of data is most of what you will do. And a type here is a description of a shape, not a name you have to match.

By the end: Describe any object shape, and explain why a name never has to match.

Describing the shape of an object is most of what you will actually do in TypeScript.

Functions and arrays are easy. Unions come later. But the bulk of a real codebase is objects moving between functions, and getting fluent at describing them is what makes the rest comfortable. There is also one idea here that people carry wrong assumptions into from other languages, and it is worth unlearning early: in TypeScript a type is a description of a shape, not a name that has to match.

Two ways to write the same thing

interface and type, doing the same job

// An interface.
interface Book {
  title: string;
  pages: number;
}

// A type alias. For describing an object, these are interchangeable.
type Magazine = {
  title: string;
  issue: number;
};

function describe(item: { title: string }): string {
  return `"${item.title}"`;
}

const persuasion: Book = { title: 'Persuasion', pages: 249 };
const wired: Magazine = { title: 'Wired', issue: 412 };

console.log(describe(persuasion));
console.log(describe(wired));
console.log(persuasion.pages, wired.issue);

Output


      

Structural typing

Here is the idea to unlearn. If you have written Java or C#, you are used to a value being an Book because it was declared as one. TypeScript does not work that way. A value fits a type if it has the right shape. Nothing else is consulted — not the name, not where it came from, not what it was declared as.

Nothing declares itself to be a Book

interface Book {
  title: string;
  pages: number;
}

function shelve(book: Book): string {
  return `${book.title} (${book.pages}pp)`;
}

// Declared as a Book. Obviously fine.
const declared: Book = { title: 'Emma', pages: 474 };

// Never mentions Book, and is accepted, because the shape matches.
const anonymous = { title: 'Persuasion', pages: 249 };

// Came from a completely unrelated interface, and is also accepted.
interface Pamphlet {
  title: string;
  pages: number;
  stapled: boolean;
}
const leaflet: Pamphlet = { title: 'Fire Safety', pages: 2, stapled: true };

console.log(shelve(declared));
console.log(shelve(anonymous));
console.log(shelve(leaflet));
console.log('');
console.log('Three different origins, one shape. That is structural typing.');

Output


      

The exception: fresh object literals

There is one place the extra-properties rule reverses, and it exists to catch typos. Assign an object literal directly and the compiler gets strict about surplus keys.

Excess property checking on a literal

TS2353
interface Book {
  title: string;
  pages: number;
}

// Passed as a literal, right at the call, so the compiler applies excess
// property checking: `subtitle` is not part of Book, and this is almost
// certainly a mistake rather than a deliberate extra.
function shelve(book: Book): string {
  return book.title;
}

console.log(shelve({ title: 'Emma', pages: 474, subtitle: 'A Novel' }));

What the compiler says

error TS2353: Object literal may only specify known properties, and 'subtitle' does not exist in type 'Book'.

The same object assigned through a variable first is fine, because at that point it is no longer fresh — the compiler assumes you know what you are doing.

The same value, via a variable, accepted

interface Book {
  title: string;
  pages: number;
}

function shelve(book: Book): string {
  return book.title;
}

// No longer a fresh literal, so excess property checking does not apply.
const withExtra = { title: 'Emma', pages: 474, subtitle: 'A Novel' };
console.log(shelve(withExtra));

console.log('Excess property checking is a typo-catcher, not a rule about shapes.');

Output


      

Optional properties

A question mark makes a property optional

interface Profile {
  name: string;
  bio?: string;          // may be absent
  avatarUrl?: string;
}

function render(profile: Profile): string {
  // Optional properties are string | undefined, so you must handle absence.
  const bio = profile.bio ?? '(no bio yet)';
  return `${profile.name}: ${bio}`;
}

console.log(render({ name: 'Ada' }));
console.log(render({ name: 'Grace', bio: 'Compiler pioneer' }));

Output


      

Optional means you have to check

TS18048
interface Profile {
  name: string;
  bio?: string;
}

function length(profile: Profile): number {
  // bio might not be there, so reaching straight for .length is unsafe.
  return profile.bio.length;
}

console.log(length({ name: 'Ada' }));

What the compiler says

error TS18048: 'profile.bio' is possibly 'undefined'.

Absent is not the same as present-and-undefined

TS2375
interface Profile {
  name: string;
  bio?: string;
}

// Under exactOptionalPropertyTypes, handing over an explicit undefined is a
// different claim from leaving the key out, and it is rejected.
const p: Profile = { name: 'Ada', bio: undefined };

console.log(p.name);

What the compiler says

error TS2375: Type '{ name: string; bio: undefined; }' is not assignable to type 'Profile' with 'exactOptionalPropertyTypes: true'. Consider adding 'undefined' to the types of the target's properties.

readonly properties

readonly stops assignment at compile time

interface Config {
  readonly host: string;
  readonly port: number;
  retries: number;
}

const config: Config = { host: 'localhost', port: 8080, retries: 3 };

// Allowed: retries is not readonly.
config.retries = 5;

console.log(config);
console.log('host is readable as always:', config.host);

Output


      

Writing to a readonly property

TS2540
interface Config {
  readonly host: string;
}

const config: Config = { host: 'localhost' };

config.host = 'example.com';

console.log(config);

What the compiler says

error TS2540: Cannot assign to 'host' because it is a read-only property.

What readonly compiles to, which is nothing

interface Config {
  readonly host: string;
  readonly port: number;
}

const config: Config = { host: 'localhost', port: 8080 };

console.log(`${config.host}:${config.port}`);

// Compare with the runtime version, which actually resists.
const frozen = Object.freeze({ host: 'localhost' });
try {
  // In strict mode this throws rather than being ignored.
  (frozen as { host: string }).host = 'elsewhere';
} catch (err) {
  console.log('Object.freeze refused:', (err as Error).constructor.name);
}
What this compiles to
const config = { host: 'localhost', port: 8080 };
console.log(`${config.host}:${config.port}`);
// Compare with the runtime version, which actually resists.
const frozen = Object.freeze({ host: 'localhost' });
try {
    // In strict mode this throws rather than being ignored.
    frozen.host = 'elsewhere';
}
catch (err) {
    console.log('Object.freeze refused:', err.constructor.name);
}

Output


      

Index signatures, for keys you do not know in advance

Describing an object used as a lookup table

// "any string key, and the value is a number".
interface Counts {
  [word: string]: number;
}

const counts: Counts = {};
for (const word of ['a', 'b', 'a']) {
  counts[word] = (counts[word] ?? 0) + 1;
}

console.log(counts);

// Record<K, V> is the same idea and reads better.
const scores: Record<string, number> = { alice: 10, bob: 8 };
console.log(scores['alice']);

// Constrain the keys and you get real checking back.
type Suit = 'hearts' | 'spades';
const bySuit: Record<Suit, number> = { hearts: 13, spades: 13 };
console.log(bySuit.hearts + bySuit.spades);

Output


      

Combining shapes

Intersections and extension

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

interface Article {
  title: string;
  body: string;
}

// With a type alias, use & to intersect.
type StoredArticle = Article & Timestamps;

// With an interface, use extends. Same result.
interface StoredArticleToo extends Article, Timestamps {
  slug: string;
}

const now = new Date('2026-03-01T12:00:00Z');

const a: StoredArticle = {
  title: 'On Types',
  body: '...',
  createdAt: now,
  updatedAt: now,
};

const b: StoredArticleToo = { ...a, slug: 'on-types' };

console.log(a.title, a.createdAt.getUTCFullYear());
console.log(b.slug);

Output


      

Nesting, and pulling shapes apart

Name the inner shapes when they are worth naming

// Inline nesting is fine for something used once.
interface OrderInline {
  id: string;
  customer: { name: string; email: string };
}

// But if the inner shape is reused, or you want to talk about it, name it.
interface Customer {
  name: string;
  email: string;
}

interface Order {
  id: string;
  customer: Customer;
  lines: OrderLine[];
}

interface OrderLine {
  sku: string;
  quantity: number;
}

function summarise(order: Order): string {
  const units = order.lines.reduce((n, line) => n + line.quantity, 0);
  return `${order.id} for ${order.customer.name}: ${units} units`;
}

const order: Order = {
  id: 'A-1001',
  customer: { name: 'Ada', email: 'ada@example.com' },
  lines: [
    { sku: 'desk', quantity: 1 },
    { sku: 'chair', quantity: 4 },
  ],
};

console.log(summarise(order));

// Naming the inner type also lets you write functions over just that part.
function contact(customer: Customer): string {
  return `${customer.name} <${customer.email}>`;
}
console.log(contact(order.customer));

Output


      

Interfaces can be reopened

One real difference between interface and type. Declaring the same interface twice merges the declarations rather than erroring. This is how you extend types that belong to somebody else.

Declaration merging

interface Window2 {
  title: string;
}

// Declared again, and the two merge rather than clashing.
interface Window2 {
  version: number;
}

// Both properties are now required.
const w: Window2 = { title: 'Main', version: 2 };
console.log(w.title, w.version);

Output


      

A type alias refuses the same trick

TS2300
type Settings = {
  theme: string;
};

type Settings = {
  locale: string;
};

const s: Settings = { theme: 'dark', locale: 'en' };
console.log(s);

What the compiler says

error TS2300: Duplicate identifier 'Settings'.
error TS2300: Duplicate identifier 'Settings'.
error TS2353: Object literal may only specify known properties, and 'locale' does not exist in type 'Settings'.

Your turn

  1. Write an interface for a blog comment: an id, a body, an author name, an optional parent comment id, and a readonly creation date. Then write a function that returns a one-line summary of one.
  2. Write a function taking { name: string }. Call it with an object that has name and two other properties, first as a literal at the call site, then via a variable. Explain to yourself why only one of those is an error.
  3. Give the interface an optional nickname?: string and try to read .length on it without checking. Note the error code — you will see it many times.
  4. Model a shopping basket as Record<string, number> mapping SKU to quantity. Read a SKU that is not there, and notice what noUncheckedIndexedAccess forces you to do about it.
  5. Take two interfaces you have written and combine them, once with & and once with extends. Confirm the results behave identically.

Recap

  • A type describes a shape, not a name. A value fits if it has the right properties, regardless of how it was declared or where it came from.
  • An object with extra properties satisfies a smaller type — except when it is a fresh literal in the assignment position, where excess property checking catches typos.
  • ? makes a property optional, which means its type includes undefined and you have to handle absence.
  • readonly is checked at compile time and gone at run time. Object.freeze is the runtime equivalent.
  • interface merges when re-declared, which is how you extend other people's types and a hazard inside your own.

Saved in this browser only.

Newsletter

New writing, when it lands.

Occasional notes on shipping software with AI agents. No schedule, no filler, unsubscribe in one click.