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
// 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.
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.
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.
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
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
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'.
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
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
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.
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
// "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
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
// 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.
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
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
- 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.
- Write a function taking
{ name: string }. Call it with an object that hasnameand 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. - Give the interface an optional
nickname?: stringand try to read.lengthon it without checking. Note the error code — you will see it many times. - Model a shopping basket as
Record<string, number>mapping SKU to quantity. Read a SKU that is not there, and notice whatnoUncheckedIndexedAccessforces you to do about it. - Take two interfaces you have written and combine them, once with
&and once withextends. 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 includesundefinedand you have to handle absence.readonlyis checked at compile time and gone at run time.Object.freezeis the runtime equivalent.interfacemerges when re-declared, which is how you extend other people's types and a hazard inside your own.