Mert Özen Further With Every Line
Frontend TypeScript Basics — #06

Object Types in TypeScript: Optional and readonly Properties and Index Signatures

Mert Özen Nov 3, 2026 7 min 2 views
Object Types in TypeScript: Optional and readonly Properties and Index Signatures

Inline object types, optional and readonly properties, index signatures, Record, and the fresh object literal rule behind excess property checking: why TypeScript only sometimes catches extra properties.

Version note: The code in this post targets TypeScript 7.0 and Node 26.

Predict First

A small function that prints a user. Its parameter's type is an object with a name and an age:

function printUser(user: { name: string; age: number }) {
  console.log(`${user.name} (${user.age})`);
}

const ada = { name: "Ada", age: 36, email: "ada@example.com" };

printUser(ada);
printUser({ name: "Grace", age: 45, email: "grace@example.com" });

Both calls carry an email property the function doesn't expect. One goes through a variable, the other passes an object written directly in the call. Do you think both compile, both error, or one passes and the other gets stuck? If one gets stuck, which one? The answer is in the section on excess property checking.

Inline Object Types

The { name: string; age: number } you see in the parameter above is an object type. It says which properties an object has and the type of each one. Because we write it right where it's used, without giving the type a name, we call it an inline object type. You can separate properties with semicolons or commas; both are valid.

TypeScript checks the object you pass to something expecting this type, property by property. If a property is missing, it tells you straight away:

function printUser(user: { name: string; age: number }) {
  console.log(`${user.name} (${user.age})`);
}

// Error: Property 'age' is missing in type '{ name: string; }' but required in type '{ name: string; age: number; }'.
printUser({ name: "Alan" });

You remember reading error messages from the post where we set up the environment: first comes the type of what you passed ({ name: string; }), then the expected type, and the difference between them is spelled out. Object types are used the same way in function parameters, variable annotations and return types.

Optional Properties

Not every user has an email address. Put a ? after a property name and you're saying that property might not be there at all:

function sendWelcome(user: { name: string; email?: string }) {
  // Error: 'user.email' is possibly 'undefined'.
  console.log(user.email.toLowerCase());

  if (user.email !== undefined) {
    console.log(`Sending to ${user.email.toLowerCase()}`);
  }
}

sendWelcome({ name: "Ada" });
sendWelcome({ name: "Grace", email: "Grace@Example.com" });
// Sending to grace@example.com

Both calls are valid. Inside the function, though, the type of user.email is string | undefined. The rule we saw in the previous posts applies here too: TypeScript won't let you call toLowerCase without checking. Once you've checked with if, it knows inside the block that the value is a string.

When we set up the environment, we saw that tsc --init turns on the exactOptionalPropertyTypes setting. With this setting on, email?: string means "this property may be left out," not "it may hold the value undefined." So the call sendWelcome({ name: "Grace", email: undefined }) gives a TS2379 error under this setting. With the setting off, there's no difference between the two cases. Knowing which one applies in your project keeps errors like this from surprising you.

Readonly Properties

Some properties shouldn't change once created, like a record's ID. You write readonly in front of the property name:

const product: { readonly id: number; title: string; tags: string[] } = {
  id: 1,
  title: "Keyboard",
  tags: [],
};

product.title = "Mechanical Keyboard";
// Error: Cannot assign to 'id' because it is a read-only property.
product.id = 2;
product.tags.push("sale");

title can be changed, id can't. Look at the last line: adding a new element to tags works fine. readonly only prevents reassigning the property itself, not changes to the array or object inside it. In other words, it's shallow, exactly like the shallowness we discussed for Object.freeze in the JavaScript series. If you also want tags not to change, you make its type readonly string[], as in the previous post. And again, don't forget: readonly does nothing at runtime; it only limits the operations TypeScript allows.

Index Signatures: Objects Whose Keys Aren't Known in Advance

So far we've known the property names ahead of time. But how do you write the keys of an object that maps product codes to stock counts? The codes come from data, and there's no telling how many there are. That's what an index signature is for:

const stock: { [sku: string]: number } = {
  "KB-01": 12,
  "MS-02": 0,
};

stock["HD-03"] = 5;

const count = stock["XX-99"];
// const count: number
console.log(count.toFixed(0));

[sku: string]: number means "for every key that's a string, the value is a number." The sku inside the square brackets is just a label for readability; you could have written key instead. Now you can read and write with any key, and TypeScript knows the value is a number.

But do you see the problem on the last line? There's no key called "XX-99". TypeScript still shows the type of count as number, and when the code runs you get "Cannot read properties of undefined (reading 'toFixed')." This is the same situation we saw with array indexes in the previous post, and the fix is the same: with noUncheckedIndexedAccess on, the type of count becomes number | undefined and TypeScript asks you to write a check.

You can write known properties alongside an index signature, on one condition: the known properties must fit the index signature too.

// Error: Property 'label' of type 'string' is not assignable to 'string' index type 'number'.
function report(data: { [sku: string]: number; label: string }) {
  console.log(data.label);
}

The logic: you said "every string key's value is a number," and label is a string key too, but its value is a string. The two rules contradict each other.

Excess Property Checking and Fresh Object Literals

Now for the question from the opening. printUser(ada) compiles, but the second call gives this error:

error TS2353: Object literal may only specify known properties, and 'email' does not exist in type '{ name: string; age: number; }'.

Same property, same function; why a different result? TypeScript's type system generally works on an "at least these" basis. The ada variable has name and age; an extra email doesn't break the function, because the function never reads it. This flexibility is called structural typing, and it gets its own post.

But when you pass an object literal written directly in place, that is, a "fresh" object not yet assigned to any variable, TypeScript performs an extra check: if you wrote a property that isn't in the type, you've most likely made a mistake. Nothing else uses this object, so the extra property can't serve any purpose. The biggest benefit of this check is catching typos:

function sendWelcome(user: { name: string; email?: string }) {
  console.log(user.name);
}

// Error: Object literal may only specify known properties, but 'emial' does not exist in type '{ name: string; email?: string | undefined; }'. Did you mean to write 'email'?
sendWelcome({ name: "Grace", emial: "grace@example.com" });

Because email is optional, without this check emial would slip through silently and the email would never be sent. TypeScript doesn't just find the mistake; it suggests the fix too. The same check runs when you assign an object literal to an annotated variable: const grace: { name: string; age: number } = { name: "Grace", age: 45, email: "..." } gives the same error.

Assigning the object to a variable first technically gets you past this check, but don't do it to silence the error. An excess property error is usually a sign of a genuine mistake, or of a type that's missing something.

A Quick Look at Record

There's a shorter way to write an index signature: Record.

const prices: Record<string, number> = { "KB-01": 49.9 };

// Error: Property 'dark' is missing in type '{ light: string; }' but required in type 'Record<"dark" | "light", string>'.
const themeLabels: Record<"light" | "dark", string> = {
  light: "Light mode",
};

Record<string, number> means the same as { [key: string]: number }. Its real strength shows in the second example: when you give specific literals as keys, it requires every one of them to be present. Record is one of TypeScript's built-in utility types; we'll cover them in detail in the Intermediate series, along with how they work.

Common Pitfalls

The first is thinking the {} type means "an empty object." {} actually means "anything except null and undefined." const anything: {} = 42; compiles without complaint; only null and undefined are rejected. Writing object instead usually isn't the answer either; in the type inference post we saw how an object annotation erases information. Write what's inside the object.

The second is repeating the same inline type everywhere. If { name: string; email?: string } appears in three functions, adding a property means changing three places. Giving types a name is the fix, and it's the topic of the next post.

The third is the illusion that an index signature gives you, that every key exists. As you saw in the stock["XX-99"] example, TypeScript assumes a missing key exists too. For objects like this, either turn on noUncheckedIndexedAccess or check the value you read.

A Small Experiment

★☆☆ Does it compile? const point: { x: number; y: number } = { x: 1, y: 2, z: 3 }; And what if you write const raw = { x: 1, y: 2, z: 3 }; and then const point: { x: number; y: number } = raw;?

★★☆ Write an object type representing a blog post: an id that doesn't change (number), a title (string), an optional subtitle (string), and a tags array that can't be modified. Try changing id and adding an element to tags.

★★★ Write a function that counts words: it takes an array of strings and returns a Record<string, number> holding how many times each word appears. Then compile the same code with noUncheckedIndexedAccess on and see which line has to change.

All the object types in this post were written right where they were used, and we saw that this has its limits. In the next post we'll put the two ways of naming types side by side, type aliases and interfaces: how they're written, where they differ, and which one you should choose when.