Question

TypeScript Dictionary Initialization and Structural Type Checking Explained

typescriptdictionaryinitialization

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: string
  • lastName: 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 p1 and p2.
  • 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.

Take Quiz

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:

  1. It sees that persons must be an object whose string keys map to IPerson values.

  2. It looks at key p1.

  3. The value is:

    { firstName: "F1", lastName: "L1" }
    
  4. That object has both required properties, so p1 is valid.

  5. It then looks at key p2.

  6. The value is:

    { firstName: "F2" }
    
  7. TypeScript compares that object to IPerson.

  8. firstName exists, but lastName is missing.

  9. Because lastName is required, this value does not satisfy IPerson.

  10. 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.

Take Quiz

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

ConceptWhat it meansGood forRisk
{ [id: string]: IPerson }Object with string keys and IPerson valuesLookup objectsRepeating inline type can be verbose
Record<string, IPerson>Utility type version of the same ideaCleaner syntaxSame behavior, just different syntax
IPerson[]Array of peopleOrdered listsLookup by ID is slower
Partial<IPerson>All IPerson fields become optionalDrafts, patches, formsToo loose if used where full data is required
anyTurns off type checkingRare escape hatchUnsafe 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.

Take Quiz

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 an IPerson
  • every IPerson value must include all required properties
  • index signatures allow flexible keys, not flexible value shapes
  • optional properties must be marked with ?
  • any can hide errors
  • as SomeType does 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> or DraftPerson

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.

Take Quiz

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 IEmployee interface with required firstName, lastName, and role properties.
  • 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.
Take Quiz

Keep learning

Related questions

View more