Question

TypeScript Index Signatures vs Mapped Types with Enums

typescriptjavascript

Question

I am trying to define an object type where every value from an enum is used as a key:

enum Option {
  ONE = 'one',
  TWO = 'two',
  THREE = 'three'
}

interface OptionRequirement {
  someBool: boolean;
  someString: string;
}

interface OptionRequirements {
  [key: Option]: OptionRequirement;
}

This feels like it should work, but TypeScript gives this error:

An index signature parameter type cannot be a union type. Consider using a mapped object type instead.

Why does this happen, and what is the correct way to type an object whose keys must be all values of the Option enum?

Short Answer

By the end of this page, you will understand why TypeScript index signatures only work with broad key types like string, number, or symbol, and why enum-based key sets must use mapped types instead. You will also learn how to use Record, how mapped types enforce exact keys, and how to avoid common mistakes when typing objects with enum or union keys.

Concept

In TypeScript, an index signature describes objects whose keys are not known exactly ahead of time, but follow a broad category such as:

[key: string]: ValueType

That means any string key is allowed.

Your enum:

enum Option {
  ONE = 'one',
  TWO = 'two',
  THREE = 'three'
}

represents a specific set of allowed keys, not all strings. TypeScript treats this as a finite union of literal values like:

'one' | 'two' | 'three'

An index signature cannot use that kind of finite union. Index signatures are only for general key categories such as:

  • string
  • number
  • symbol
  • template literal pattern types in some cases

If you want to say:

  • these exact keys are allowed
  • each key maps to the same value shape

then you should use a mapped type.

Example:

type OptionRequirements = {
  [K in Option]: OptionRequirement;
};

This tells TypeScript to iterate over each member of Option and create a property for it.

This matters in real programming because many applications use a fixed set of keys:

  • feature flags
  • configuration settings
  • status handlers
  • API field mappings
  • validation rules

Mapped types let you model those exact key sets safely, while index signatures are better for open-ended dictionaries.

Mental Model

Think of an index signature as a rule for a giant mailbox wall:

  • [key: string]: ... means any mailbox label made of text is allowed

Think of a mapped type as a checklist:

  • only these three mailboxes must exist: one, two, three

So:

  • index signature = open-ended dictionary
  • mapped type = exact set of known keys

Your enum is a checklist, not an open-ended dictionary. That is why TypeScript asks you to use a mapped type.

Take Quiz

Syntax and Examples

Correct syntax with a mapped type

enum Option {
  ONE = 'one',
  TWO = 'two',
  THREE = 'three'
}

interface OptionRequirement {
  someBool: boolean;
  someString: string;
}

type OptionRequirements = {
  [K in Option]: OptionRequirement;
};

This creates a type equivalent to:

type OptionRequirements = {
  one: OptionRequirement;
  two: OptionRequirement;
  three: OptionRequirement;
};

Example object

const requirements: OptionRequirements = {
  [Option.ONE]: { someBool: true, someString: 'First' },
  [Option.TWO]: { someBool: false, someString: 'Second' },
  [Option.THREE]: { someBool: true, someString: 'Third' }
};

This works because all enum values are present.

Using Record

A common shorthand is Record:

type OptionRequirements = Record<Option, OptionRequirement>;

This means the same thing:

  • keys are Option
  • values are OptionRequirement

Optional keys

If not every option must exist, make the properties optional:

type PartialOptionRequirements = Partial<Record<Option, OptionRequirement>>;

Now objects may contain some enum keys instead of all of them.

Why the original version fails

interface OptionRequirements {
  [key: Option]: OptionRequirement;
}

This tries to use Option as an index signature parameter type. But index signatures are not for limited unions like 'one' | 'two' | 'three'; they are for broad key domains like string.

Step by Step Execution

Consider this code:

enum Option {
  ONE = 'one',
  TWO = 'two'
}

interface OptionRequirement {
  someBool: boolean;
  someString: string;
}

type OptionRequirements = Record<Option, OptionRequirement>;

const config: OptionRequirements = {
  [Option.ONE]: { someBool: true, someString: 'A' },
  [Option.TWO]: { someBool: false, someString: 'B' }
};

Step 1: TypeScript reads the enum

Option.ONE  // 'one'
Option.TWO  // 'two'

The enum values form a small allowed key set.

Step 2: Record<Option, OptionRequirement> is expanded

TypeScript interprets it like this:

{
  one: OptionRequirement;
  two: OptionRequirement;
}

Step 3: The object literal is checked

const config = {
  [Option.ONE]: { someBool: true, someString: 'A' },
  [Option.TWO]: { someBool: false, someString: 'B' }
};

This becomes roughly:

const config = {
  one: { someBool: true, someString: 'A' },
  two: { someBool: false, someString: 'B' }
};

Step 4: TypeScript verifies required keys

It checks:

  • Is one present? Yes.
  • Is two present? Yes.
  • Does each value match OptionRequirement? Yes.

Step 5: Final result

The object is accepted because it exactly matches the mapped structure.

If one key is missing:

const badConfig: OptionRequirements = {
  [Option.ONE]: { someBool: true, someString: 'A' }
};

TypeScript reports an error because two is required.

Real World Use Cases

This pattern is common whenever you have a fixed list of allowed keys.

Feature flags

enum Feature {
  DARK_MODE = 'darkMode',
  BETA_SEARCH = 'betaSearch'
}

type FeatureFlags = Record<Feature, boolean>;

Validation rules by field

type Field = 'email' | 'password';

type ValidationRules = Record<Field, string[]>;

API status handlers

type Status = 'success' | 'error' | 'loading';

type Handlers = Record<Status, () => void>;

Environment-specific config

type Environment = 'development' | 'staging' | 'production';

type ConfigByEnv = Record<Environment, { apiUrl: string }>;

In each case, the key set is known in advance, so mapped types are a better fit than index signatures.

Take Quiz

Real Codebase Usage

In real codebases, developers usually use this concept in a few common ways.

1. Record for lookup tables

const labels: Record<Option, string> = {
  [Option.ONE]: 'First option',
  [Option.TWO]: 'Second option',
  [Option.THREE]: 'Third option'
};

This is common for labels, routes, constants, and UI text.

2. Configuration objects with exact coverage

When every enum value must be handled, Record helps prevent missing cases.

const optionConfig: Record<Option, OptionRequirement> = {
  [Option.ONE]: { someBool: true, someString: 'A' },
  [Option.TWO]: { someBool: false, someString: 'B' },
  [Option.THREE]: { someBool: true, someString: 'C' }
};

If a new enum member is added later, TypeScript forces you to update the object.

3. Partial mappings for optional support

const cachedValues: Partial<Record<Option, string>> = {
  [Option.ONE]: 'cached'
};

Useful when not every key is guaranteed to exist yet.

4. Guarding access

When values may be optional, developers often check before use:

const value = cachedValues[Option.TWO];
if (value !== undefined) {
  console.log(value.toUpperCase());
}

5. Replacing long switch blocks

Instead of branching repeatedly, codebases often use a typed map:

const handlers: Record<Option, () => void> = {
  [Option.ONE]: () => console.log('Handle one'),
  [Option.TWO]: () => console.log('Handle two'),
  [Option.THREE]: () => console.log('Handle three')
};

handlers[Option.ONE]();

This can be cleaner than multiple if or switch statements when the action depends only on a known key.

Common Mistakes

1. Using an index signature for a limited set of keys

Broken code:

interface OptionRequirements {
  [key: Option]: OptionRequirement;
}

Why it fails:

  • Option is a finite set of keys
  • index signatures expect a broad key category like string

Fix:

type OptionRequirements = Record<Option, OptionRequirement>;

2. Using string when you want exact keys

Broken idea:

interface OptionRequirements {
  [key: string]: OptionRequirement;
}

Problem:

  • this allows any string key
  • you lose protection against invalid keys

Example:

const x: OptionRequirements = {
  wrongKey: { someBool: true, someString: 'Oops' }
};

This may compile even though wrongKey is not a valid option.

3. Forgetting required enum members

Broken code:

type OptionRequirements = Record<Option, OptionRequirement>;

const bad: OptionRequirements = {
  [Option.ONE]: { someBool: true, someString: 'A' },
  [Option.TWO]: { someBool: false, someString: 'B' }
};

Problem:

  • Option.THREE is missing

Fix:

Add the missing key, or use Partial<Record<Option, OptionRequirement>> if keys are optional.

4. Confusing enum names with enum values

enum Option {
  ONE = 'one'
}
  • enum member name: ONE
  • enum member value: 'one'

When used as object keys here, the actual property is 'one', not 'ONE'.

5. Trying to use a mapped type directly inside an interface

Depending on context and TypeScript version, this often causes confusion. A simple approach is to use a type alias:

type OptionRequirements = {
  [K in Option]: OptionRequirement;
};

This is the most direct and readable way.

Comparisons

ConceptBest forAllows exact key set?Example
Index signatureOpen-ended dictionariesNo{ [key: string]: number }
Mapped typeKnown finite keysYes{ [K in Option]: Value }
Record<K, V>Shorthand for mapped typesYesRecord<Option, Value>
Partial<Record<K, V>>Optional known keysPartiallyPartial<Record<Option, Value>>

Index signature vs mapped type

// Open-ended: any string key
interface Dictionary {
  [key: string]: number;
}

// Exact keys: only enum values
type ExactMap = {
  [K in Option]: number;
};

Mapped type vs Record

These are equivalent in many cases:

type A = {
  [K in Option]: OptionRequirement;
};

type B = Record<Option, OptionRequirement>;

Use:

  • mapped type when you want to be explicit or customize modifiers
  • Record when you want a short, readable utility type

Required vs optional mappings

type RequiredMap = Record<Option, OptionRequirement>;
type OptionalMap = Partial<Record<Option, OptionRequirement>>;
  • RequiredMap: every option must exist
  • OptionalMap: some options may be missing
Take Quiz

Cheat Sheet

// Enum keys
enum Option {
  ONE = 'one',
  TWO = 'two',
  THREE = 'three'
}

interface OptionRequirement {
  someBool: boolean;
  someString: string;
}

Use this for exact enum-based keys

type OptionRequirements = Record<Option, OptionRequirement>;

or

type OptionRequirements = {
  [K in Option]: OptionRequirement;
};

Do not use this

interface OptionRequirements {
  [key: Option]: OptionRequirement;
}

Use Partial if keys are optional

type PartialOptionRequirements = Partial<Record<Option, OptionRequirement>>;

Key rule

  • string index signature = any string key
  • Option mapped type = only enum values as keys

Good defaults

  • exact known keys: Record<K, V>
  • optional known keys: Partial<Record<K, V>>
  • unknown dynamic keys: index signature

Common pattern

const data: Record<Option, OptionRequirement> = {
  [Option.ONE]: { someBool: true, someString: 'A' },
  [Option.TWO]: { someBool: false, someString: 'B' },
  [Option.THREE]: { someBool: true, someString: 'C' }
};

FAQ

Why can't I use an enum in an index signature?

Because index signatures are for broad key types like string or number, not for a limited union of known values.

What should I use instead of [key: Option]?

Use a mapped type or Record<Option, ValueType>.

Is Record<Option, T> the same as [K in Option]: T?

Yes, for this use case they are effectively the same.

When should I use an index signature?

Use it when keys are not known ahead of time and any string or number key may appear.

How do I make enum-based keys optional?

Use:

Partial<Record<Option, T>>

Will TypeScript force me to include every enum value?

Yes, if you use Record<Option, T> without Partial.

Can I use string unions instead of enums?

Yes. This also works well:

type Option = 'one' | 'two' | 'three';
type OptionRequirements = Record<Option, OptionRequirement>;
Take Quiz

Mini Project

Description

Build a small TypeScript configuration map for notification channels. This demonstrates how to use a fixed set of enum values as object keys, and how TypeScript ensures every channel has a configuration entry.

Goal

Create a type-safe notification settings object where every notification channel defined in an enum has a configuration record.

Requirements

Requirement 1 Requirement 2 Requirement 3

Take Quiz

Keep learning

Related questions

View more