Object Types: Literals, Optional/Readonly Properties, and Index Signatures

Learn to describe the shape of plain objects using object type literals, control which properties are required, optional, or readonly, and safely type dictionary-style objects with index signatures.

Almost everything you model in a real application — a user, a product, a form's state — is a plain JavaScript object, and TypeScript's object types are how you tell the compiler exactly what shape that object must have.

Object type literals and property modifiers

An object type literal is written as a set of property-name-to-type pairs inside curly braces, either inline (function f(u: { id: number; name: string }) {}) or bound to a name with type or interface. Every property you list without a ? is required: any value assigned to that type must include it, with a matching type, or TypeScript raises a compile error. This is the default and strictest mode — it forces every consumer of the type to supply complete data.

Adding a ? after a property name makes it optional: the property may be omitted entirely, and wherever you read it back its type is implicitly widened to include undefined (so email?: string is read as string | undefined). This is different from writing email: string | undefined, which requires the key to be present (even if its value is undefined) — optional properties can be left out of the object literal completely, while a string | undefined property must still appear as a key.

The readonly modifier is orthogonal to optionality: it controls whether the property can be reassigned after the object is created, not whether it exists. readonly is a compile-time-only guarantee enforced by the type checker; it produces no runtime protection and does nothing to prevent mutation through Object.assign, a type assertion, or a differently-typed alias to the same object.

These modifiers compose freely — a property can be required and readonly, optional and mutable, or optional and readonly — letting a single object type literal express exactly which fields are guaranteed, which are situational, and which are fixed at creation time, which is precisely the shape of most real-world API payloads and database rows.

Nested objects and index signatures

Object types nest naturally: a property's type can itself be another object type literal, letting you describe deeply structured data like { user: { profile: { avatarUrl: string } } } directly, or by referencing a separately declared type for the nested shape (usually the more readable and reusable option once nesting goes more than one level deep).

When you don't know the exact set of keys ahead of time — a lookup table, a cache, a set of feature flags keyed by name — you use an index signature: { [key: string]: T } declares that the object may have any number of string keys, and every one of them must map to a value of type T. This is how TypeScript types dictionary-style objects, as opposed to objects with a fixed, known set of properties.

An index signature can also use number as the key type (relevant for array-like objects), and a type can mix an index signature with specific named properties, as long as each named property's type is compatible with the index signature's value type — you cannot declare id: number alongside [key: string]: string, because id (a number) would violate the index signature's promise that every string key maps to a string.

A common pitfall is forgetting that an index signature is a blanket promise about every possible key, including ones that were never actually set — TypeScript will let you write settings["nonexistentKey"] and treat the result as type T, even though at runtime it's undefined. Enabling the noUncheckedIndexedAccess compiler flag closes this gap by typing every index access as T | undefined, forcing an explicit check before use.

Excess property checking and exact optional semantics

TypeScript's structural typing normally lets an object with extra, unlisted properties satisfy a type — if it has all the required properties with matching types, additional properties are ignored. But when you write an object literal directly where a typed value is expected (as opposed to assigning a pre-existing variable), TypeScript applies excess property checking: it flags any property in the literal that doesn't appear in the target type, catching likely typos like nmae instead of name right at the call site.

Excess property checking is a special, stricter check that only applies to fresh object literals in an assignment or argument position; storing the same object in a variable first and then assigning that variable bypasses the check entirely, because the variable's inferred type structurally satisfies the target even with the extra property. This asymmetry is intentional: it protects against literal typos while still allowing normal structural subtyping for variables and function returns.

A related nuance is exact optional property semantics: by default, TypeScript treats email?: string as accepting either a missing key or a key explicitly set to undefined. The exactOptionalPropertyTypes compiler option tightens this so that an optional property must either be completely absent or hold an actual string — explicitly assigning undefined to it becomes an error, which matters for code that distinguishes "field was never set" from "field was cleared."

Typing Object.keys and Object.entries

A common source of confusion is that Object.keys(obj) returns string[], not a union of the object's literal key names, even when obj's type has a fixed, known set of keys. This is deliberate on TypeScript's part: because of structural typing, a variable typed as User could at runtime hold an object with extra properties beyond what User declares, so TypeScript cannot soundly narrow the return type to just the declared keys.

The same reasoning applies to Object.entries(obj), which returns [string, T][] (an array of key-value tuples) with the key typed as string and the value typed as the union of all the object's property types — again widened rather than narrowed to each property's specific type. This means you'll often need a type assertion, like Object.keys(obj) as (keyof User)[], when you specifically need the keys typed as the literal union of property names, and you accept the small soundness gap that comes with it.

In practice, this limitation rarely blocks real code: Object.keys and Object.entries are most useful for iteration, where the loop body typically narrows or checks each value anyway, and for cases needing precise key typing, a keyof assertion or a small typed helper function wrapping Object.keys is the idiomatic workaround.

Object type literal with mixed modifiers

id, name, and createdAt are required; email is optional and may be omitted. createdAt can be set once, at construction, but never reassigned afterward — enforced only by the compiler. SettingsMap accepts any string key, but every value stored under it must be a boolean.

type User = {
  id: number;
  name: string;
  email?: string;
  readonly createdAt: Date;
};

type SettingsMap = {
  [key: string]: boolean;
};

Code Examples

goodUser omits the optional email field, which is allowed. badUser misspells name as nmae; because it's a fresh object literal assigned directly to a User-typed variable, TypeScript's excess property check flags the unknown nmae key as an error rather than silently ignoring it. Expected result: Error on badUser: Object literal may only specify known properties, and 'nmae' does not exist in type 'User'.

Common mistake: Assuming any extra key on an object literal is silently ignored — it only is once the object is stored in an intermediate variable first.

The index signature promises that every string key maps to a boolean, so TypeScript types settings["unknownFlag"] as boolean even though that key was never set. Calling .toString() on the real runtime value, undefined, throws. Expected result: TypeScript compiles without error; the program crashes at runtime with 'Cannot read properties of undefined (reading toString)'. Enabling noUncheckedIndexedAccess in tsconfig.json would type the access as boolean | undefined, forcing a check before use and catching this at compile time instead of runtime.

type User = {
  id: number;
  name: string;
  email?: string;
  readonly createdAt: Date;
};

const goodUser: User = {
  id: 1,
  name: "Ada",
  createdAt: new Date(),
};

const badUser: User = {
  id: 2,
  nmae: "Grace", // excess property check catches this typo
  createdAt: new Date(),
};
type SettingsMap = {
  [key: string]: boolean;
};

const settings: SettingsMap = {
  darkMode: true,
  betaFeatures: false,
};

const value = settings["unknownFlag"]; // typed boolean, but is actually undefined
console.log(value.toString()); // runtime crash: Cannot read properties of undefined

Index signature access vs. reality

An index signature is a blanket promise about every possible key, including ones you never actually set — the compiler trusts it unless noUncheckedIndexedAccess is on.

type SettingsMap = { [key: string]: boolean }

  settings = { darkMode: true }

  settings["darkMode"]     -> type: boolean   -> runtime: true
  settings["unknownFlag"]  -> type: boolean   -> runtime: undefined  (mismatch!)

  noUncheckedIndexedAccess fixes the type:
  settings["unknownFlag"]  -> type: boolean | undefined

Important Rules

Use required properties by default; reach for ? only when a field can genuinely be absent from valid data, not as a shortcut when you're unsure of the real shape.

Remember readonly is compile-time only — it does not freeze the object at runtime and can be bypassed with type assertions or aliasing.

Excess property checking only fires on fresh object literals assigned or passed directly — assigning through an intermediate variable bypasses it, so don't rely on it as a general-purpose validator.

Prefer index signatures for genuinely dynamic key sets (dictionaries, lookup tables); for a fixed, known set of fields, use named properties instead so each one gets its own precise type.

Turn on noUncheckedIndexedAccess in stricter codebases so index signature reads are typed as T | undefined, matching what can actually happen at runtime.

Where this shows up

API response types, form state objects, and configuration objects in real TypeScript codebases are almost always object type literals with a mix of required, optional, and readonly fields; dictionary-shaped data like feature flag maps, localization tables, and caches are typed with index signatures, and teams that hit runtime 'undefined is not an object' bugs from an index signature almost always fix it by turning on noUncheckedIndexedAccess.

const user: User = someVariable; // someVariable has an extra 'nmae' typo property and it compiles fine const user: User = { id: 1, name: "Ada", createdAt: new Date() }; // check literals directly, or validate shape before storing in a variable

Excess property checking only applies to object literals written directly in the assignment; once a mistyped object is sitting in a variable, structural typing lets it through silently because it still has every required property.

[multiple-choice] What does the readonly modifier on an object type property actually enforce? A. It freezes the object at runtime, like Object.freeze() B. It is a compile-time-only check preventing reassignment through that typed reference C. It makes the property optional D. It converts the property to a string

B. It is a compile-time-only check preventing reassignment through that typed reference — readonly is enforced only by the TypeScript compiler; it has no effect at runtime and can be bypassed via type assertions or a differently-typed alias to the same object.

[true-false] Excess property checking applies to any object assigned to a typed variable, even if the object was first stored in an intermediate variable. A. True B. False

B. False — Excess property checking only fires on fresh object literals written directly in the assignment or call. Storing the object in a variable first bypasses it because structural typing only checks that required properties are present and correctly typed.

[multiple-choice] Given type M = { [key: string]: boolean }, what does TypeScript infer as the type of m["someKey"] when someKey was never actually set on m, without noUncheckedIndexedAccess enabled? A. undefined B. boolean C. boolean | undefined D. never

B. boolean — Without noUncheckedIndexedAccess, TypeScript trusts the index signature's blanket promise and types every string-key access as boolean, even though the real runtime value for an unset key is undefined.

Define a type Product with required id: number and name: string, optional discount?: number, and readonly sku: string. Then define a PriceTable type using an index signature mapping product SKUs (strings) to numeric prices, and write one object literal for each that satisfies its type.

type Product = { id: number; name: string; discount?: number; readonly sku: string; }; type PriceTable = { [sku: string]: number; }; const p: Product = { id: 1, name: "Widget", sku: "WID-001" }; const prices: PriceTable = { "WID-001": 9.99, "WID-002": 14.99 };

Remember the index signature's value type applies to every possible string key, even ones you never set.