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.
type User = { id: string; email: string;};
// src/lib/profile.tsinterface 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.
type Invoice = { id: string; lines: InvoiceLine[];};
const addLine = (invoice: Invoice, line: InvoiceLine) => { invoice.lines.push(line);};invoice is the source of truth for a rendered invoice. A helper appends a line in place, the parent component holds a stale reference for a render cycle, and two screens show different totals. The fix is a readonly modifier, but readonly locks only one of the two things engineers expect it to.
Both are vocabulary failures: the wrong keyword, or a missing modifier the language already provides. This lesson builds four habits:
- Use
typeby default; reach forinterfaceonly to merge declarations. - Mark optional fields with
?. - Use
readonlyon 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.
type by default
Section titled “type by default”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.
interface User { id: string;}
interface User { email: string;}
const user: User = { id: '1', email: 'lina@example.com' };Both declarations merge into one User with both fields: useful when you mean it, a silent trap when two unrelated declarations collide by accident.
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"}'.
type User = { id: string; name: string | undefined;};
const user: User = { id: '1', name: undefined };name must be present in the literal, even as undefined. 'name' in user is true and Object.keys(user) returns ['id', 'name'], but JSON.stringify(user) still produces '{"id":"1"}', since it drops undefined values.
type User = { id: string; name?: string | undefined;};
const absent: User = { id: '1' };const present: User = { id: '2', name: undefined };The most permissive shape: it accepts both absence and an explicit undefined. Under the project’s default tsconfig, name?: string is treated as this form.
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.
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.
type Invoice = { readonly id: string; readonly lines: readonly InvoiceLine[];};
const addLine = (invoice: Invoice, line: InvoiceLine) => { invoice.lines.push(line); // Error: Property 'push' does not exist on // type 'readonly InvoiceLine[]'.};The array type is now readonly InvoiceLine[]. The mutating methods disappear from its surface, while the read methods (.map, .filter, .find, indexed read) stay. The push now errors at compile time, not after a stale-render incident.
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.
Excess property checks on object literals
Section titled “Excess property checks on object literals”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.
Pick the right modifier combination
Section titled “Pick the right modifier combination”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: stringid?: stringreadonly id?: stringreadonly id: stringreadonly. It’s always present on the row, so no ? — absence isn’t a valid state.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: stringemail?: stringemail: string | undefinedreadonly email: stringid. The row guarantees email is present, so no ? — absence isn’t a valid state. App code never reassigns it, so readonly locks the binding.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: stringname?: stringname: string | undefinedreadonly name?: string?. The row is never rewritten in app code, so readonly locks the binding. Under exactOptionalPropertyTypes, ? means absent or a string — exactly the contract here. name: string | undefined would force the key to be present, and a bare name?: string would leave the binding reassignable.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 | nulllastSeenAt?: DatelastSeenAt?: Date | nullreadonly lastSeenAt: Date | null? — null is the value, not absence. Date | null models the database NULL. App code never rewrites the field, so readonly locks the binding. A bare lastSeenAt: Date | null would leave the binding reassignable; lastSeenAt?: Date would lose the null and model absence instead.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[]readonly on the field locks the binding so app code can’t reassign user.overrides. readonly RoleOverride[] removes .push, .pop, and .splice from the array’s surface. Either one alone leaves the other layer mutable.Pick these five without hesitating and you have the ? / | undefined / readonly / readonly T[] matrix down.
External resources
Section titled “External resources”Official treatment of object shapes, optional fields, and readonly. The reference for everything in this lesson.
The canonical reference for the one trigger that earns interface. Read this before you write a declare module block.
Matt Pocock's piece on the default-to-type posture. It reaches the same conclusion this lesson does, with a couple of edge cases worth seeing.
The flag that finally enforces the runtime distinction between absent and present-but-undefined fields. The reference for the strict-config future this lesson points at.