Skip to content
Chapter 4Lesson 2

Object shapes — type, interface, and field modifiers

How TypeScript describes the shape of an object, choosing between type and interface and locking down fields with optional and readonly modifiers.

The previous lesson covered the vocabulary for scalar values: primitives, literal unions, and the four corners. This lesson does the same for the next most common type you’ll write, the object shape. Two failures from real codebases set up why it matters.

src/lib/auth.ts
type User = {
id: string;
email: string;
};
// src/lib/profile.ts
interface User {
id: string;
email: string;
}

You open two files in the same repo. One declares User with type, the other with interface. They produce the same shape at the call site, so neither one is wrong, but they can’t both be the canonical form. Every new engineer then loses time during onboarding deciding which one to write next. The fix is one rule with one narrow exception, and this lesson covers both.

Both are vocabulary failures, just like the typo and any bugs in the previous lesson. The engineer reached for the wrong keyword or skipped a modifier the language already provides.

This lesson leaves you with four habits:

  • type by default; interface only for declaration merging.
  • ? for optional fields, with the | undefined distinction the strict tsconfig will eventually enforce.
  • readonly on a field locks the binding, not the value behind it.
  • readonly T[] for array immutability, and Readonly<T> for the per-field shorthand.

A few things are out of scope here: dynamic keys come in the next lesson, and unions, intersections, and narrowing follow in the lessons after that. This lesson stays on a single object shape with known field names.

The rule is that type is the default for every alias the course writes, whether that’s objects, primitives, tuples, unions, intersections, generics, conditional types, or mapped types. The project’s code conventions hold to this rule because it keeps the codebase legible and saves the team from debating the call on every declaration.

type User = {
id: string;
email: string;
name: string;
};

That’s the canonical shape, and it’s what almost every type declaration in this course looks like: one keyword, roughly alphabetical fields, and semicolons on the field lines (or commas, since Biome handles whichever you write).

type wins as the default because it scales. interface can only declare object shapes and classes. type declares all of that plus everything else the chapter will introduce: unions in a couple of lessons, intersections in the same lesson, conditional and mapped types in the next chapter, and generic helpers throughout. Defaulting to type removes a decision you’d otherwise face every time you reach for a non-object shape.

One piece of vocabulary is worth pinning down, because it shows up everywhere in the TypeScript docs and in this chapter: alias . User above is a type alias for { id: string; email: string; name: string }. The alias has no runtime presence; it’s erased at compile time.

interface earns one trigger: declaration merging

Section titled “interface earns one trigger: declaration merging”

There’s exactly one job type can’t do: declaration merging . Two interface declarations of the same name in the same scope merge into one combined shape. Two type declarations of the same name are a duplicate-identifier error instead.

type User = {
id: string;
};
type User = {
email: string;
};
// Error: Duplicate identifier 'User'.

Two type declarations with the same name collide. The compiler refuses to choose between them.

The canonical case for the merge is augmenting a type that a third-party package ships. Better Auth exports a Session type, and your app needs to add a currentOrgId field to it. Next-intl exports an AppConfig type, and your app fills in the shape of your translation messages and locale list. The mechanism is a declare module 'package-name' block containing an interface declaration that targets the package’s existing type. Every import of the package, anywhere in your codebase, then sees the merged shape.

You won’t write a declare module block in this chapter; lesson 4 of chapter 6 covers the full pattern. What matters here is the decision: write type by default, and reach for interface only when you’re inside a declare module block augmenting a third-party type.

Three differences between type and interface show up in blog posts and Stack Overflow threads. None of them change the call at this lesson’s scale, so it’s worth settling all three in one place and then setting them aside.

extends vs &. interface B extends A and type B = A & { /* extra fields */ } produce structurally equivalent types for everything this course will type. The course composes with & because it’s the only form that works for unions too. Composition comes a few lessons from now.

Semicolons, commas, trailing commas. These are cosmetic. Biome enforces a single style on save, so you write whichever feels natural and stop reading it back.

Performance at extreme scale. interface checks marginally faster than very large unions of intersections, but only in the pathological cases that library authors run into. At a single-app SaaS scale, the difference doesn’t matter.

The only difference that affects the call is declaration merging. Everything else is style.

A ? after a field name marks the field as optional: the property may be absent from the object entirely.

type User = {
id: string;
email: string;
name?: string;
};
const anon: User = { id: '1', email: 'anon@example.com' };
const lina: User = { id: '2', email: 'lina@example.com', name: 'Lina' };

Both literals satisfy User. The first omits name, the second sets it. That covers the common case: a field a database column allows NULL on, a field the user hasn’t filled in yet, or a field a particular API response doesn’t return.

There are actually three closely related forms here: name?: string, name: string | undefined, and name?: string | undefined. The differences between them are real, even though TypeScript sometimes lets you slide between them.

type User = {
id: string;
name?: string;
};
const user: User = { id: '1' };

name may be absent from the object. 'name' in user is false. Object.keys(user) returns ['id']. JSON.stringify(user) produces '{"id":"1"}', with no name key in the output.

The runtime difference between “absent” and “present but undefined” is real, and it doesn’t go away just because the compiler sometimes ignores it. The in operator separates them. Object.keys enumerates the second but not the first. JSON.stringify silently drops undefined values. A library that iterates with for (const key in obj) will skip the absent field and visit the explicit-undefined one.

Under plain strict, TypeScript treats name?: string and name: string | undefined as assignable to each other at object-literal sites. You can write { name: undefined } for a name?: string field and the compiler accepts it. That’s a deliberate ergonomic choice, and the cost is that the runtime distinction the type system describes isn’t enforced.

Later in the course, when the project pins the full strict tsconfig (lesson 5 of chapter 24), one of the flags it turns on is exactOptionalPropertyTypes . With that flag on, a name?: string field rejects the value undefined: the field must be either absent or a string. The compiler finally models what the runtime already does.

The readonly modifier: locks the binding, not the value

Section titled “The readonly modifier: locks the binding, not the value”

readonly before a field name forbids reassignment of the property after the object is constructed. It does not freeze the value the property points at. The mental model is identical to const from the JavaScript value-model chapter: the binding is locked, the value is not.

This is the field-level rule, and it’s where most engineers form a wrong intuition, so it’s worth walking through line by line.

type Invoice = {
readonly id: string;
readonly issuedAt: Date;
readonly customer: { name: string; email: string };
};
const invoice: Invoice = {
id: 'inv_1',
issuedAt: new Date(),
customer: { name: 'Lina', email: 'lina@example.com' },
};
invoice.id = 'inv_2';
invoice.customer = { name: 'Mara', email: 'mara@example.com' };
invoice.customer.name = 'Mara';

The declaration. Three readonly fields. The shape says that once an Invoice is constructed, you cannot reassign id, issuedAt, or customer on it. That’s what’s locked.

type Invoice = {
readonly id: string;
readonly issuedAt: Date;
readonly customer: { name: string; email: string };
};
const invoice: Invoice = {
id: 'inv_1',
issuedAt: new Date(),
customer: { name: 'Lina', email: 'lina@example.com' },
};
invoice.id = 'inv_2';
invoice.customer = { name: 'Mara', email: 'mara@example.com' };
invoice.customer.name = 'Mara';

The two reassignments that fail. Writing to invoice.id is the obvious case: the type says readonly, so the compiler refuses. Writing to invoice.customer follows the same rule. Even though the new value structurally matches { name: string; email: string }, you’re reassigning the property, and customer is readonly.

type Invoice = {
readonly id: string;
readonly issuedAt: Date;
readonly customer: { name: string; email: string };
};
const invoice: Invoice = {
id: 'inv_1',
issuedAt: new Date(),
customer: { name: 'Lina', email: 'lina@example.com' },
};
invoice.id = 'inv_2';
invoice.customer = { name: 'Mara', email: 'mara@example.com' };
invoice.customer.name = 'Mara';

The mutation that slips through. readonly customer locks the customer property, not the object the property points to. The nested name field on that inner object is still mutable. invoice.customer.name = 'Mara' compiles cleanly, and the customer’s name is silently overwritten. This is the bug the introduction described.

1 / 1

The rule fits in a sentence: readonly locks the binding from the alias to the value, not the value itself. It’s the field-level analogue of const, so the same rules you internalized for const apply here, one level deeper into the data.

For nearly all SaaS code, the field-level readonly is the right reach. React’s rendering model already discourages mutating values it owns, since the framework re-runs components with fresh references on every render. The cases where a nested write actually causes trouble are rarer than blog posts make them sound. When you do need to lock the nested value too, such as with typed config tables, frozen registries, or lookup tables you want to guarantee immutable, as const (lesson 7 of this chapter) is the right move. There’s also a deeper “deep readonly” pattern for the rare cases where neither approach fits, but you don’t need it today.

Array-level readonly: readonly T[] and Readonly<T>

Section titled “Array-level readonly: readonly T[] and Readonly<T>”

Now return to the introduction’s Invoice with lines. The fix that ships in real codebases isn’t a readonly on the field; it’s a readonly on the array type.

type Invoice = {
readonly id: string;
readonly lines: InvoiceLine[];
};
const addLine = (invoice: Invoice, line: InvoiceLine) => {
invoice.lines.push(line);
};

readonly on the field locks the lines reference, so invoice.lines = [] would error. But the array methods aren’t constrained: .push, .pop, .splice, .sort, and index-write all still compile. The bug from the introduction is back.

readonly T[] forbids .push, .pop, .shift, .unshift, .splice, .sort, .reverse, and direct index-write, while leaving every read method intact. The longer-named equivalent is ReadonlyArray<T>, the same type written as a utility. The course writes readonly T[] for concision.

When you have an existing object type and want a fully frozen variant of it without manually retyping every field, TypeScript ships a per-field shorthand:

type Invoice = {
id: string;
issuedAt: Date;
customer: { name: string; email: string };
};
type FrozenInvoice = Readonly<Invoice>;

Readonly<T> applies readonly to every top-level property of T. FrozenInvoice here is exactly equivalent to writing readonly in front of each of Invoice’s three fields. You reach for it when you already have a type and want a variant where every field is locked. It’s shallow: the customer.name field is still mutable, just as in the manual-readonly case from the previous section. The full utility-type surface (Pick, Omit, Partial, and friends) comes in the next chapter. Readonly<T> shows up here because it’s the per-field shorthand for the rule this section just covered.

There’s one case worth flagging ahead of time. When the object is a literal config you’re writing inline, such as a routes map or a permissions table, as const (lesson 7 of this chapter) is the right reach. It locks every property at the narrowest literal type and applies readonly everywhere in one keyword. You don’t need it for the Invoice derived from a database row, but you’ll want it for the literal config you write next to the call site.

There’s one more rule worth naming once, because if you don’t know it exists, it’s confusing the first time you hit it.

type User = { id: string; email: string };
const direct: User = { id: '1', email: 'lina@example.com', role: 'admin' };
// ^^^^
// Error: Object literal may only specify known properties,
// and 'role' does not exist in type 'User'.
const draft = { id: '1', email: 'lina@example.com', role: 'admin' };
const flowed: User = draft;

The line declaring direct errors. The line assigning draft to flowed compiles cleanly, with the extra role field silently along for the ride.

The reason is that TypeScript runs an extra check on object literals assigned directly to a known type. If the literal has extra properties the target type doesn’t declare, the compiler flags them. When the same value flows through a variable first, the check is skipped. The variable’s inferred type already structurally matches User (a User is allowed to have extra properties at runtime; you just can’t put them in the literal), and TypeScript doesn’t re-check at the second assignment.

The literal-site error is a feature. It catches the typo emial instead of email at the most useful spot, where you wrote it rather than where it’s read three files away. The variable-flow loophole isn’t a bug either; it’s the language preferring structural compatibility over strict literal matching once a value has a name.

When the variable-flow path is the one hiding a bug, type the source variable (const draft: User = { ... }) so the literal-site check fires there too. The wrong move is to silence the literal-site error with as User. Type assertions don’t fix anything; they hide the problem. Lesson 6 of this chapter unpacks that posture in full.

Everything in this lesson assumed objects with known field names: id, email, name, lines. Objects with dynamic keys, such as a cache keyed by user ID, a lookup table from status to label, or a JSON shape whose keys aren’t known at design time, need a different form: an index signature or Record<K, V>. The next lesson covers that surface in full.

You’re typing a User row for an invoicing app. The project’s tsconfig has exactOptionalPropertyTypes on, so the ? vs | undefined distinction is enforced. For each field, pick the modifier combination that matches the field’s contract.

id — a UUID assigned at row creation, never reassigned in app code, and always present on every row. Which declaration matches the contract?

id: string
id?: string
readonly id?: string
readonly id: string

email — captured at sign-up, always present on every row, and never reassigned after creation (email changes go through a separate pendingEmail workflow). Which declaration matches the contract?

email: string
email?: string
email: string | undefined
readonly email: string

name — the user may not have set one yet. When the field hasn’t been filled in, it’s absent from the row entirely (no null, no undefined value — just no key). App code never reassigns it after the row is loaded. Which declaration matches the contract?

name: string
name?: string
name: string | undefined
readonly name?: string

lastSeenAt — present on every row, but the database column allows NULL. When the user has never signed in, the field is present with the value null. App code never reassigns it. Which declaration matches the contract?

lastSeenAt: Date | null
lastSeenAt?: Date
lastSeenAt?: Date | null
readonly lastSeenAt: Date | null

overrides — a list of role overrides loaded with the row. App code must not mutate the array (no .push, no index-write) and must not reassign the field. Which declaration matches the contract?

overrides: RoleOverride[]
readonly overrides: RoleOverride[]
overrides: readonly RoleOverride[]
readonly overrides: readonly RoleOverride[]

If you can make the call on each of those five fields without thinking, you’ve got the ? / | undefined / readonly / readonly T[] matrix down. The wrong answers aren’t decoy noise; each one is a real mistake you’ll catch yourself making in the first month of writing types.