Skip to content
aviral gupta

// B5.2 · ~30 min · Beginner

Index signatures

After this lesson you can type objects whose keys are not known in advance, mix them with named properties, and handle missing keys.

Lesson 2 of 5 in B5 Object types

You will be able to

  • Type objects with unknown keys using string and number index signatures, or Record<string, T>
  • Fit named properties to an index signature, fix error TS2411 with a union, and make the signature readonly
  • Handle keys that are missing at run time, and explain what noUncheckedIndexedAccess changes
  1. Warm-up · Activity 1 of 7

    Warm-up from JavaScript: an object used as a dictionary. What does this print?

    const stock = {};
    stock["apples"] = 3;
    stock.pears = 2;
    console.log(stock.apples + stock["pears"], stock.plums);
  2. Predict · Activity 2 of 7

    Predict before you read on: item is annotated as a string. Which line does tsc reject?

    const prices = { apple: 0.5, pear: 0.75 };
    const item: string = "apple";
    console.log(prices[item]);
  3. Practice · Activity 3 of 7

    Fill in the key type, so that any name may be used as a key, and the program prints 254.

    interface Scores {
      [name: ____]: number;
    }
    
    const scores: Scores = { Ada: 91, Grace: 78 };
    scores.Linus = 85;
    console.log(scores.Ada + scores.Grace + scores.Linus);
    [name: ]: number;
  4. Practice · Activity 4 of 7

    Which index signature in place of ____ lets tsc accept the interface?

    interface NumberOrStringDictionary {
      ____
      length: number;
      name: string;
    }
  5. Practice · Activity 5 of 7

    The index signature is readonly. Which line does tsc reject?

    interface ReadonlyStringArray {
      readonly [index: number]: string;
    }
    
    const myArray: ReadonlyStringArray = ["Alice", "Bob"];
    console.log(myArray[0]);
    myArray[2] = "Mallory";
  6. Brain teaser · Activity 6 of 7

    Brain teaser. Nobody has a score for Zoe. What happens when you run npx tsc, then node main.ts?

    interface Scores {
      [name: string]: number;
    }
    
    const scores: Scores = { Ada: 91, Grace: 78 };
    const missing = scores["Zoe"];
    console.log(missing);
    console.log(missing.toFixed(1));
  7. Apply · Activity 7 of 7

    Mini-task. Write an interface CountryNames with a readonly string index signature whose values are country names, and a countries object with two or three entries such as DE: "Germany". Write countryName(code), which checks with in that the code exists and returns unknown (XX) when it does not. Call it with a known and an unknown code. Then try countries.IT = "Italy" and read the error. Check it in the editor, or with npx tsc and node main.ts.

    Check your work against this list

Build it yourself

Read the worked example, then write the exercises. Your code runs in your browser or on your computer and is never uploaded.

Worked example

A grade book

Scores maps any student name to a number. ClassInfo has two named properties next to its index signature, so the index type is a union that both fit. GradeLimits is readonly. Object.entries walks the keys, and scoreOf checks with in before it reads a key that may be missing. Run npx tsc, then node main.ts. Then add limits.D = 50; or a named property active: boolean to ClassInfo, and run tsc again.

main.ts

// Scores per student: the names are not known when the type is written.
interface Scores {
  [student: string]: number;
}

// A named property must fit the index type, so the index type is a union.
interface ClassInfo {
  [key: string]: string | number;
  name: string;
  size: number;
}

// readonly: the grade limits can be read, not changed.
interface GradeLimits {
  readonly [grade: string]: number;
}

const scores: Scores = { Ada: 91, Grace: 78 };
scores["Linus"] = 64;

const info: ClassInfo = { name: "7b", size: 3, room: "B12" };
const limits: GradeLimits = { A: 90, B: 75, C: 60 };

function gradeOf(score: number): string {
  for (const [grade, min] of Object.entries(limits)) {
    if (score >= min) return grade;
  }
  return "F";
}

for (const [student, score] of Object.entries(scores)) {
  console.log(student + ": " + score + " (" + gradeOf(score) + ")");
}

// A key nobody set is undefined at run time, whatever the type says: check first.
function scoreOf(student: string): string {
  if (!(student in scores)) return student + ": no score";
  return student + ": " + scores[student];
}
console.log(scoreOf("Zoe"));
console.log("class " + info.name + " in room " + info.room);

Run it with

npx tsc
node main.ts

Output

Ada: 91 (A)
Grace: 78 (B)
Linus: 64 (C)
Zoe: no score
class 7b in room B12
  • scores["Linus"] = 64 adds a key at run time; the index signature already allows it.
  • info.room is not a named property, so its type comes from the index signature: string | number.
  • limits.D = 50 would be error TS2542: Index signature in type 'GradeLimits' only permits reading.
  • Without the in check, scores[student] for Zoe would be undefined, although its type is number.
Change it and run it

Tab indents and Shift+Tab outdents. To leave the editor with the keyboard, press Esc, then Tab.

The first run downloads the TypeScript compiler (up to 2.1 MB) and keeps it cached. Your code stays on your device.

Exercises

Exercise 1 of 2

Count the votes

countVotes counts how often each option was chosen, but counts = {} has no keys, so tsc reports error TS7053: Element implicitly has an 'any' type because expression of type 'string' can't be used to index type '{}'. Export an interface VoteCounts with a string index signature whose values are numbers. Annotate counts with it, and give countVotes the return type VoteCounts. Run the tests: the first one is the type-check.

Tab indents and Shift+Tab outdents. To leave the editor with the keyboard, press Esc, then Tab.

The first run downloads the TypeScript compiler (up to 2.1 MB) and keeps it cached. Your code stays on your device.

Hints
  1. Hint 1

    An index signature goes inside the interface: [option: string]: number;

  2. Hint 2

    Write export interface VoteCounts { … } above the function, then const counts: VoteCounts = {};

  3. Hint 3

    The return type goes after the parameter list: countVotes(votes: string[]): VoteCounts {

Show a solution

One way to solve it. Yours can look different and still pass the checks.

export interface VoteCounts {
  [option: string]: number;
}

export function countVotes(votes: string[]): VoteCounts {
  const counts: VoteCounts = {};
  for (const vote of votes) {
    counts[vote] = (counts[vote] ?? 0) + 1;
  }
  return counts;
}
Run it on your computer

Install TypeScript 7.0 or newer. Save these files in one folder, open a terminal in that folder, and run the commands below.

main.ts

export function countVotes(votes: string[]) {
  const counts = {};
  for (const vote of votes) {
    counts[vote] = (counts[vote] ?? 0) + 1;
  }
  return counts;
}

main.test.ts

import {test} from 'node:test';
import assert from 'node:assert/strict';
import {countVotes} from './main.ts';

test('each option is counted', () => {
  const got = countVotes(['tea', 'coffee', 'tea']);
  assert.deepEqual(got, {tea: 2, coffee: 1}, `countVotes gave ${JSON.stringify(got)}`);
});

test('no votes give an empty object', () => {
  const got = countVotes([]);
  assert.deepEqual(got, {}, `countVotes([]) gave ${JSON.stringify(got)}`);
});

test('an option that got no vote is missing', () => {
  const got = countVotes(['tea']);
  assert.equal(got['water'], undefined, `water should be missing, but is ${got['water']}`);
});

package.json

{
  "type": "module"
}

tsconfig.json

{
  "compilerOptions": {
    "target": "esnext",
    "module": "nodenext",
    "lib": [
      "esnext",
      "dom"
    ],
    "types": [],
    "strict": true,
    "noEmit": true,
    "erasableSyntaxOnly": true,
    "verbatimModuleSyntax": true,
    "allowImportingTsExtensions": true
  },
  "include": [
    "**/*.ts"
  ],
  "exclude": [
    "**/*.test.ts"
  ]
}

npx tsc needs TypeScript in the folder: run npm install --save-dev typescript there once. tsc checks the types; Node.js runs main.ts by removing them.

Run the program:

npx tsc
node main.ts

Run the checks (needs learnrun.js in the same folder):

npx tsc
node --test
Download learnrun.js

Exercise 2 of 2

Read-only messages with a version

Messages holds texts by key, plus a version number. tsc reports error TS2411: Property 'version' of type 'number' is not assignable to 'string' index type 'string'., and the // @ts-expect-error in noEdits is unused, because messages can still be changed. Make the index type a union that version fits, and make the index signature readonly. Then fix translate: it returns the text in capitals, or the key in brackets, such as [missing], when there is no text for the key, also for version.

Tab indents and Shift+Tab outdents. To leave the editor with the keyboard, press Esc, then Tab.

The first run downloads the TypeScript compiler (up to 2.1 MB) and keeps it cached. Your code stays on your device.

Hints
  1. Hint 1

    The index type must allow both texts and the version: readonly [key: string]: string | number;

  2. Hint 2

    messages[key] is then string | number, and at run time it may be undefined. typeof text !== "string" catches a number and undefined at once.

  3. Hint 3

    const text = messages[key]; if (typeof text !== "string") return "[" + key + "]"; return text.toUpperCase();

Show a solution

One way to solve it. Yours can look different and still pass the checks.

export interface Messages {
  readonly [key: string]: string | number;
  version: number;
}

export const en: Messages = { version: 2, greeting: "Hello", farewell: "Goodbye" };

export function translate(messages: Messages, key: string): string {
  const text = messages[key];
  if (typeof text !== "string") return "[" + key + "]";
  return text.toUpperCase();
}

function noEdits(messages: Messages) {
  // @ts-expect-error: messages are read-only
  messages["greeting"] = "Hi";
}
Run it on your computer

Install TypeScript 7.0 or newer. Save these files in one folder, open a terminal in that folder, and run the commands below.

main.ts

export interface Messages {
  [key: string]: string;
  version: number;
}

export const en: Messages = { version: 2, greeting: "Hello", farewell: "Goodbye" };

export function translate(messages: Messages, key: string): string {
  return messages[key].toUpperCase();
}

function noEdits(messages: Messages) {
  // @ts-expect-error: messages are read-only
  messages["greeting"] = "Hi";
}

main.test.ts

import {test} from 'node:test';
import assert from 'node:assert/strict';
import {en, translate} from './main.ts';

test('a known key gives the text in capitals', () => {
  const got = translate(en, 'greeting');
  assert.equal(got, 'HELLO', `translate(en, 'greeting') gave ${JSON.stringify(got)}`);
});

test('a missing key gives the key in brackets', () => {
  const got = translate(en, 'missing');
  assert.equal(got, '[missing]', `translate(en, 'missing') gave ${JSON.stringify(got)}`);
});

test('version is not a text', () => {
  const got = translate(en, 'version');
  assert.equal(got, '[version]', `translate(en, 'version') gave ${JSON.stringify(got)}`);
});

package.json

{
  "type": "module"
}

tsconfig.json

{
  "compilerOptions": {
    "target": "esnext",
    "module": "nodenext",
    "lib": [
      "esnext",
      "dom"
    ],
    "types": [],
    "strict": true,
    "noEmit": true,
    "erasableSyntaxOnly": true,
    "verbatimModuleSyntax": true,
    "allowImportingTsExtensions": true
  },
  "include": [
    "**/*.ts"
  ],
  "exclude": [
    "**/*.test.ts"
  ]
}

npx tsc needs TypeScript in the folder: run npm install --save-dev typescript there once. tsc checks the types; Node.js runs main.ts by removing them.

Run the program:

npx tsc
node main.ts

Run the checks (needs learnrun.js in the same folder):

npx tsc
node --test
Download learnrun.js

Common mistakes

A named property that does not fit the index type

interface Stock {
  [product: string]: number;
  updated: string;
}

What tsc or Node.js prints

main.ts(3,3): error TS2411: Property 'updated' of type 'string' is not assignable to 'string' index type 'number'.

Why, and the fix

The index signature says every key gives a number, and updated is a key too: stock["updated"] must be a number. Either widen the index type, [product: string]: number | string, which makes every product read as number | string, or keep the counts in their own property: { updated: string; counts: { [product: string]: number } }.

Indexing a fixed object with any string

const prices = { apple: 0.5, pear: 0.75 };
const item: string = "apple";
console.log(prices[item]);

What tsc or Node.js prints

main.ts(3,13): error TS7053: Element implicitly has an 'any' type because expression of type 'string' can't be used to index type '{ apple: number; pear: number; }'.

Why, and the fix

prices has two known keys, and item may be any string, so tsc cannot say what prices[item] is. If the keys are open, say so: const prices: { [item: string]: number } = { … }, and check for missing keys. If item is always apple or pear, give it that type: const item: "apple" | "pear" = "apple".

Writing through a readonly index signature

interface Limits {
  readonly [grade: string]: number;
}

const limits: Limits = { A: 90, B: 75 };
console.log(limits.A);
limits.C = 60;

What tsc or Node.js prints

main.ts(7,1): error TS2542: Index signature in type 'Limits' only permits reading.

Why, and the fix

readonly applies to every key the signature covers, including keys not there yet: limits.C = 60 adds one, and that is a write. Give C its value when the object is created, { A: 90, B: 75, C: 60 }, or create a new object: const more: Limits = { ...limits, C: 60 }.

TypeScript in the browser: the TypeScript 6.0.3 compiler, Apache-2.0, then your browser’s own engine. Licence and source

Exit ticket

5 questions, no hints. Score 80% or more to complete the lesson.

Finish every activity above to unlock the exit ticket.

Report a problem

Spotted something wrong or unclear? Say what, and it will be checked and fixed.

#

At least 20 characters.

Only if you want a reply.

Key ideas

An index signature describes keys you do not know yet

Sometimes you know the shape of the values but not the names of the keys: scores per student, votes per option. interface Scores { [name: string]: number } says that any string key gives a number. scores.Linus = 85 and scores["Ada"] are both fine, and a value of the wrong type is error TS2322. Without an index signature, reading prices[item] with a string variable is error TS7053: Element implicitly has an 'any' type …. A number index signature, [index: number]: string, describes array-like objects. Keys may be string, number, symbol, template string patterns, or unions of these; [key: boolean] is error TS1268. Record<string, number>, which comes back with the utility types, means the same as { [key: string]: number }.

Named properties must fit the index type

A string index covers every key, named ones included, so every named property must have the index type: { [index: string]: number; length: number; name: string } is error TS2411: Property 'name' of type 'string' is not assignable to 'string' index type 'number'. An optional property counts as number | undefined, so bonus?: number fails too. The fix is a union: [index: string]: number | string. Then length and name fit, but every other key reads as number | string. With both a number and a string index, the number one must fit the string one (TS2413): 100 and "100" are the same key. readonly [index: number]: string forbids writing: error TS2542: Index signature … only permits reading.

A missing key is undefined, whatever the type says

The type says every key gives a number, but at run time a key nobody set gives undefined. const missing = scores["Zoe"] has the type number, tsc is silent, and missing.toFixed(1) stops with a TypeError. An annotation does not help: in const s: number | undefined = scores[name], the assignment narrows s to number again (B3.3). Check at run time instead, with if (name in scores) or s === undefined, or put it into the signature: [name: string]: number | undefined. The option noUncheckedIndexedAccess "will add undefined to any un-declared field in the type", so missing.toFixed(1) becomes error TS18048. strict does not include it, and the course tsconfig does not set it.

Sources

Last reviewed October 4, 2026