Question
TypeScript Dictionary Initialization and Structural Type Checking Explained
Question
Given the following TypeScript code:
interface IPerson {
firstName: string;
lastName: string;
}
const persons: { [id: string]: IPerson } = {
p1: { firstName: "F1", lastName: "L1" },
p2: { firstName: "F2" }
};
Why is this initialization sometimes not rejected by TypeScript, even though the p2 object does not include the required lastName property from IPerson?
Short Answer
By the end of this page, you will understand how TypeScript checks object shapes inside dictionary-like objects, why missing properties may appear to slip through in some situations, and how index signatures, structural typing, and compiler settings affect type safety.
Concept
TypeScript uses structural typing, which means it checks whether a value has the required shape rather than whether it was created from a specific class or type name.
In this example, the dictionary type is:
{ [id: string]: IPerson }
This means:
- keys are strings
- every value must match
IPerson
And IPerson requires both:
firstName: stringlastName: string
So at first glance, this object should be rejected:
p2: { firstName: "F2" }
because lastName is missing.
In modern TypeScript, this should produce an error. If it does not, the usual reasons are:
- you are using an older TypeScript version
- the code is being checked less strictly by tooling
- the object passed through a type like
any - the environment is not actually type-checking this file as TypeScript
The core concept is that index signatures do not weaken the required structure of the values. If a dictionary says each value is an IPerson, every entry must satisfy IPerson.
Why this matters:
- dictionaries are common in APIs, caches, and lookup tables
- trusting incomplete objects can cause runtime bugs
- understanding how TypeScript validates nested object literals helps you write safer data models
Mental Model
Think of a dictionary type like a set of labeled lockers.
- The locker numbers are the keys, such as
p1andp2. - The rule says every locker must contain a complete person form.
- A complete person form requires both a first name and a last name.
If one locker contains a form with only a first name, it should fail inspection.
So the important idea is: the key can vary, but the value shape cannot.
The locker label is flexible. The contents are not.
Syntax and Examples
A dictionary in TypeScript is often written with an index signature:
interface IPerson {
firstName: string;
lastName: string;
}
const persons: { [id: string]: IPerson } = {
p1: { firstName: "Ada", lastName: "Lovelace" },
p2: { firstName: "Alan", lastName: "Turing" }
};
This is valid because each value matches IPerson.
You can also write the same idea using Record:
const persons: Record<string, IPerson> = {
p1: { firstName: "Ada", lastName: "Lovelace" },
p2: { firstName: "Alan", lastName: "Turing" }
};
Invalid example
const persons: Record<string, IPerson> = {
p1: { firstName: "Ada", lastName: "Lovelace" },
p2: { firstName: "Alan" }
};
TypeScript should report an error similar to:
Property 'lastName' is missing in type '{ firstName: string; }' but required in type 'IPerson'.
If missing properties are allowed
If you really want some fields to be optional, mark them as optional:
interface IPerson {
firstName: string;
lastName?: string;
}
const persons: Record<string, IPerson> = {
p1: { firstName: "Ada", lastName: "Lovelace" },
p2: { firstName: "Alan" }
};
Now p2 is valid because lastName is optional.
Better readability with a named type
interface PersonDictionary {
[id: string]: IPerson;
}
const persons: PersonDictionary = {
p1: { firstName: "Ada", lastName: "Lovelace" }
};
This is easier to reuse in larger codebases.
Step by Step Execution
Consider this example:
interface IPerson {
firstName: string;
lastName: string;
}
const persons: { [id: string]: IPerson } = {
p1: { firstName: "F1", lastName: "L1" },
p2: { firstName: "F2" }
};
Here is what TypeScript checks step by step:
-
It sees that
personsmust be an object whose string keys map toIPersonvalues. -
It looks at key
p1. -
The value is:
{ firstName: "F1", lastName: "L1" } -
That object has both required properties, so
p1is valid. -
It then looks at key
p2. -
The value is:
{ firstName: "F2" } -
TypeScript compares that object to
IPerson. -
firstNameexists, butlastNameis missing. -
Because
lastNameis required, this value does not satisfyIPerson. -
The assignment should fail.
Trace with a valid version
const persons: { [id: string]: IPerson } = {
p1: { firstName: "F1", lastName: "L1" },
p2: { firstName: "F2", lastName: "L2" }
};
Now both entries satisfy the expected type, so the whole object is valid.
Real World Use Cases
Dictionary types appear often in real projects.
API response maps
type UsersById = Record<string, IPerson>;
Used when an API returns users indexed by ID.
In-memory caches
const cache: Record<string, IPerson> = {};
A server may store already-fetched users in a lookup object.
State management
Frontend apps often normalize data by ID:
interface State {
persons: Record<string, IPerson>;
}
This makes updates faster than searching arrays.
Configuration objects
A build tool or app may map names to structured settings:
interface ConfigItem {
enabled: boolean;
path: string;
}
const configs: Record<string, ConfigItem> = {
main: { enabled: true, path: "/app" }
};
In all these cases, incomplete values can create bugs later when code assumes required fields exist.
Real Codebase Usage
In real codebases, developers usually combine dictionary types with a few common patterns.
Validation at boundaries
Data from APIs is often unknown at runtime, even if TypeScript types say otherwise.
function savePerson(person: IPerson) {
// safe to use person.lastName here
}
But before that, many teams validate incoming JSON.
Named aliases for readability
type PersonMap = Record<string, IPerson>;
This is easier to reuse than repeating { [id: string]: IPerson } everywhere.
Optional fields when data is genuinely partial
interface DraftPerson {
firstName: string;
lastName?: string;
}
Teams often separate DraftPerson from IPerson so incomplete data is modeled honestly.
Guard clauses
When data may be partial, developers check before use:
function getDisplayName(person: Partial<IPerson>): string {
if (!person.firstName || !person.lastName) {
return "Incomplete person";
}
return `${person.firstName} ${person.lastName}`;
}
Avoiding any
A common real-world source of missing errors is any:
const raw: any = {
p2: { firstName: "F2" }
};
const persons: Record<string, IPerson> = raw;
Because any disables checking, bad data can enter the system unnoticed.
Common Mistakes
1. Assuming index signatures make nested values loose
Broken assumption:
interface IPerson {
firstName: string;
lastName: string;
}
const persons: { [id: string]: IPerson } = {
p2: { firstName: "F2" }
};
The index signature only makes the keys flexible. The values still must be full IPerson objects.
2. Using any and losing type safety
const data: any = {
p2: { firstName: "F2" }
};
const persons: Record<string, IPerson> = data;
This may compile, but any turns off useful checks.
Avoid it: prefer unknown and validate before assigning.
3. Marking fields required when they are actually optional
interface IPerson {
firstName: string;
lastName: string;
}
If your app legitimately allows missing last names, the model should say so:
interface IPerson {
firstName: string;
lastName?: string;
}
4. Confusing compile-time checks with runtime validation
TypeScript does not inspect live JSON at runtime.
const json = '{"p2":{"firstName":"F2"}}';
const persons = JSON.parse(json) as Record<string, IPerson>;
The as assertion does not validate the data.
5. Overusing type assertions
Broken pattern:
const persons = {
p2: { firstName: "F2" }
} as Record<string, IPerson>;
This can silence errors incorrectly.
Avoid it: let TypeScript infer and check the object first.
Comparisons
| Concept | What it means | Good for | Risk |
|---|---|---|---|
{ [id: string]: IPerson } | Object with string keys and IPerson values | Lookup objects | Repeating inline type can be verbose |
Record<string, IPerson> | Utility type version of the same idea | Cleaner syntax | Same behavior, just different syntax |
IPerson[] | Array of people | Ordered lists | Lookup by ID is slower |
Partial<IPerson> | All IPerson fields become optional | Drafts, patches, forms | Too loose if used where full data is required |
any | Turns off type checking | Rare escape hatch | Unsafe and hides bugs |
Index signature vs Record
These are effectively equivalent here:
const a: { [id: string]: IPerson } = {};
const b: Record<string, IPerson> = {};
Required vs optional properties
interface RequiredPerson {
firstName: string;
lastName: string;
}
interface OptionalPerson {
firstName: string;
lastName?: string;
}
Use required properties when your program depends on them always existing. Use optional ones only when missing data is valid by design.
Cheat Sheet
interface IPerson {
firstName: string;
lastName: string;
}
const persons: Record<string, IPerson> = {
p1: { firstName: "A", lastName: "B" }
};
Rules
Record<string, IPerson>means every string key maps to anIPerson- every
IPersonvalue must include all required properties - index signatures allow flexible keys, not flexible value shapes
- optional properties must be marked with
? anycan hide errorsas SomeTypedoes not validate runtime data
Equivalent syntax
{ [id: string]: IPerson }
Record<string, IPerson>
Optional field syntax
interface IPerson {
firstName: string;
lastName?: string;
}
Unsafe patterns
const x: any = { p2: { firstName: "F2" } };
const persons: Record<string, IPerson> = x; // unsafe
Safe idea
- model complete data with required fields
- model partial data with a separate type like
Partial<IPerson>orDraftPerson
FAQ
Why should TypeScript reject an object missing a required property?
Because the value does not match the declared shape. If lastName is required in IPerson, every IPerson value must have it.
Is { [id: string]: IPerson } the same as Record<string, IPerson>?
Yes, for this use case they mean the same thing. Record is usually shorter and easier to read.
Why might this code compile in my project?
Possible reasons include an older TypeScript version, incorrect tooling setup, the use of any, or type assertions that silence checking.
Does as IPerson verify the object at runtime?
No. A type assertion only tells TypeScript what to assume. It does not validate the actual data.
How do I allow missing lastName values?
Mark the property as optional:
lastName?: string;
Should I use a dictionary or an array for people?
Use a dictionary when you usually access people by ID. Use an array when order matters or you mostly loop through all items.
What is structural typing in TypeScript?
It means TypeScript checks whether a value has the required properties and types, rather than checking a class name or explicit declaration history.
Mini Project
Description
Build a simple employee directory stored as a dictionary object. Each employee is looked up by ID, and each value must match a required TypeScript interface. This demonstrates how TypeScript checks nested object values inside a dictionary and how optional fields should be modeled intentionally.
Goal
Create a typed employee lookup object where each employee entry satisfies the required interface, then add a safe function to print employee names by ID.
Requirements
- Define an
IEmployeeinterface with requiredfirstName,lastName, androleproperties. - Create a dictionary of employees keyed by employee ID.
- Add at least two valid employee entries.
- Write a function that accepts the dictionary and an ID, then returns a formatted employee name.
- Ensure the code would fail type checking if an employee is missing a required property.
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.