Skip to content
Chapter 4Lesson 2

Object types and field modifiers

Typing object shapes in TypeScript with type and interface, and the optional and readonly field modifiers.

The previous lesson typed scalar values. This one types what you reach for next: a single object with known fields. Two bugs show why the details matter.

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

Two files declare User, one with type and one with interface. Same shape, so neither is wrong, but they can’t both be canonical, and every new engineer wastes time deciding which to copy. The fix is one rule with one exception.

Both are vocabulary failures: the wrong keyword, or a missing modifier the language already provides. This lesson builds four habits:

  • Use type by default; reach for interface only to merge declarations.
  • Mark optional fields with ?.
  • Use readonly on a field to lock the binding, not the value behind it.
  • Use readonly T[] to lock an array.

Dynamic keys come next; here we stay on a single shape with known field names.

Default to type for every alias you write: object, primitive, tuple, union, intersection, generic, conditional, or mapped type. One keyword for everything saves the team from re-deciding on every declaration.

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

Almost every type declaration in this course looks like that: one keyword, semicolons on the field lines.

type wins because it scales. interface can only declare object shapes and classes; type declares those plus unions, intersections, conditional and mapped types, and generic helpers, all of which the course uses.

One term, since it runs through the TypeScript docs: an alias is a name bound to a type. User above aliases { id: string; email: string; name: string }. It has no runtime presence; the compiler erases it.

interface exists for one job: declaration merging

Section titled “interface exists for one job: declaration merging”

The one thing type can’t do is declaration merging : two interface declarations of the same name merge into one shape, while two type declarations collide as a duplicate-identifier error.

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

Two type declarations with the same name collide.

The case that earns the merge is augmenting a type a third-party package ships. Better Auth exports a Session type, say, and your app needs a currentOrgId field on it. You write a declare module 'package-name' block holding an interface that targets the package’s type, and every import then sees the merged shape. Chapter 6 covers the full pattern.

The decision: write type by default, and reach for interface only inside a declare module block augmenting a third-party type.

Differences that don’t affect the choice

Section titled “Differences that don’t affect the choice”

Three other differences get cited often, but none change the decision at app scale. interface B extends A and type B = A & { ... } produce equivalent types; the course uses & because it also composes unions. Semicolons versus commas between fields are cosmetic, and Biome normalizes them on save. interface checks marginally faster than huge unions of intersections, but only at a library scale an app never reaches. Declaration merging stays the only difference that matters.

The ? modifier: optional versus undefined fields

Section titled “The ? modifier: optional versus undefined fields”

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

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. Optional fields cover the common case: a nullable database column, a value the user hasn’t filled in, a field an API response leaves out.

Three forms look interchangeable but aren’t: name?: string, name: string | undefined, and name?: string | undefined.

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

name may be absent. 'name' in user is false, Object.keys(user) returns ['id'], and JSON.stringify(user) produces '{"id":"1"}'.

That runtime difference is real, but plain strict ignores it: it treats name?: string and name: string | undefined as mutually assignable, so it accepts { name: undefined } for a name?: string field. The ergonomic win costs you enforcement of the distinction.

Chapter 28 pins the full strict tsconfig, which turns on exactOptionalPropertyTypes . With it on, name?: string rejects undefined: the field must be absent or a string, modeling 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 reassigning that property after construction. It does not freeze the value the property points at. The model is const: the binding is locked, the value is not.

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. Once an Invoice is constructed, you cannot reassign id, issuedAt, or customer. Those bindings are 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. Both invoice.id and invoice.customer are readonly, so the compiler refuses, even though the new customer value matches the shape.

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 property, not the object it points to. The nested name is still mutable, so invoice.customer.name = 'Mara' compiles and silently overwrites it. This is the bug from the introduction.

1 / 1

For nearly all app code, field-level readonly is the right reach. React already discourages mutating values it owns, re-running components with fresh references on every render. When you do need to lock the nested value too, such as a typed config or a lookup table, as const (the as const and satisfies lesson) is the move.

The readonly array: readonly T[] and Readonly<T>

Section titled “The readonly array: readonly T[] and Readonly<T>”

Return to the introduction’s Invoice with lines. The fix isn’t readonly on the field; it’s 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 = [] errors. But the array methods aren’t constrained: .push, .pop, .splice, .sort, and index-write all still compile. The introduction’s bug is back.

readonly T[] forbids .push, .pop, .shift, .unshift, .splice, .sort, .reverse, and index-write, while leaving every read method intact. Its longer-named equivalent is ReadonlyArray<T>; the course writes readonly T[].

To lock every field of an existing type at once, TypeScript ships Readonly<T>:

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, so FrozenInvoice is Invoice with readonly on all three fields. Like the manual version, it’s shallow: customer.name stays mutable. The rest of the utility types (Pick, Omit, Partial) come next chapter.

For a literal config you write inline, a routes map or permissions table, reach for as const (the as const and satisfies lesson) instead: it locks every property at its narrowest literal type with one keyword.

TypeScript checks an object literal for extra properties depending on whether you assign it directly to a typed target or let it flow through a variable first.

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;

direct errors, but flowed compiles cleanly, carrying the extra role field. TypeScript runs an extra check on object literals assigned directly to a known type: if the literal declares properties the target doesn’t, the compiler flags them. Through a variable the check is skipped, because the inferred type structurally matches User (which may carry extra properties at runtime); you just can’t write them in the literal.

The literal-site check catches a typo like emial for email where you wrote it, not three files away where it’s read. When the variable-flow path hides a bug, type the source variable (const draft: User = { ... }) so the check fires there too. Don’t silence the error with as User: an assertion hides the mismatch rather than fixing it, a posture the Narrowing and assertions lesson takes apart.

You’re typing a User row for an invoicing app, with exactOptionalPropertyTypes on, so ? and | undefined mean different things. For each field, pick the modifier combination that matches its 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[]

Pick these five without hesitating and you have the ? / | undefined / readonly / readonly T[] matrix down.