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:
stringnumbersymbol- 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.
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
onepresent? Yes. - Is
twopresent? 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.
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:
Optionis 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.THREEis 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
| Concept | Best for | Allows exact key set? | Example |
|---|---|---|---|
| Index signature | Open-ended dictionaries | No | { [key: string]: number } |
| Mapped type | Known finite keys | Yes | { [K in Option]: Value } |
Record<K, V> | Shorthand for mapped types | Yes | Record<Option, Value> |
Partial<Record<K, V>> | Optional known keys | Partially | Partial<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 existOptionalMap: some options may be missing
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
stringindex signature = any string keyOptionmapped 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>;
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
Keep learning
Related questions
@Directive vs @Component in Angular: Differences, Use Cases, and When to Use Each
Learn the difference between @Directive and @Component in Angular, including use cases, examples, and when to choose each.
Accessing Input Value from EventTarget in TypeScript
Learn why EventTarget has no value property in TypeScript and safely read values from HTML input events in Angular applications.
Angular (change) vs (ngModelChange): What’s the Difference?
Learn the difference between Angular (change) and (ngModelChange), when each fires, and which one to use in forms and inputs.