Andrei Pfeiffer logo
Back to Articles

Hash Maps in TypeScript

TypeScript
6 min read

If you're not familiar with the term Hash Map, it refers to a simple key-value pair data structure, also known as Hash Table, Record, Dictionary, Associative Array, etc.

In this post, we'll explore different methods to implement Hash Maps with optional items in TypeScript and analyze the type safety when accessing the items.

NoteThe content of this post uses TypeScript 5.3, which is the latest version at the moment. If the behavior changes in future versions, I will update the content..

The use case we'll be exploring next is a data structure where the keys are strings, and the values are booleans.

We'll be using strict type checking, therefore we need to 1) define a type for the Hash Map, then 2) we'll initialize the data structure and 3) dynamically access its members.

// 1️⃣ define the type
type HashMap = /* TBD */;
// 2️⃣ initialize with data
const letters: HashMap = /* TDB */
// 3️⃣ dynamic member access
const A = letters["A"];

The goal is to analyze the type-safety of the dynamic member access using various methods.

NoteFor the sake of this example, I'm using string/boolean pairs, but the same behavior applies for any other types we might use.

Plain JavaScript Objects

Let's start with the simplest way to define Hash Maps in JavaScript, using Plain Old JavaScript Objects, aka POJOs. The most straightforward way to define the type is to use Index Signatures, by telling the compiler we'll deal with objects having strings as keys and booleans as values.

// 1️⃣ define the type
type HashMap = {
  [key in string]: boolean;
};

So, let's use the above type to initialize with some values and access a non-existent member.

// 2️⃣ initialize with data
const letters: HashMap = {
  "A": true,
}

// 3️⃣ dynamic member access
const A = letters["A"]; // ✅ boolean
const X = letters["X"]; // ❌ boolean

For some reason, the type system sees an inexistent member as being a boolean, even with the strict compiler option enabled.

I was expecting to infer the type as boolean | undefined, because there is no guarantee that the Hash Map will contain a particular member. This behavior imprints a false sense of safety.

Lack of type-safety

I want to go into more detail regarding my astonishment when I experienced the above behavior. My mental model regarding TypeScript's strict mode was that it models the behavior of JavaScript. I expect it to be more pessimistic rather than too optimistic.

The string type represents an unlimited set of values. Since the compiler doesn't enforce comprehensive initialization to make me set all the possible keys, it shouldn't be able to guarantee that the hash map contains any key that I access dynamically. Not to mention that we can mutate the hash map at any given time.

I expected the same behavior as accessing DOM elements, nullable in non-strict mode and non-nullable in strict mode:

const button = document.querySelector(".submit");
// "Element" in non-strict mode
// "Element | null" in strict mode

Explicit optional members

To fix this behavior and to enforce strict null checks in this case, we'll have to explicitly define the properties as optional:

type HashMap = {
  [key in string]?: boolean; // notice the "?"
};

const letters: HashMap = {
  "A": true,
}

const A = letters["A"]; // 👍 boolean | undefined
const X = letters["X"]; // ✅ boolean | undefined

Now the compiler correctly infers the members as being potentially undefined, thus enforcing strict null checks wherever we use the result. But now, even the members that are explicitly defined during initialization are seen as optional.

I guess a little pesimism is safer that too much optimism, so that's fine.

Using Record utility type

TypeScript also provides a Record utility type for more succinct type definitions.

type HashMap = Record<string, boolean>;

However, if we look at the implementation, it uses the same Index Signature as we used above. Therefore, the behavior is precisely the same.


To make the properties optional, we'll have to wrap it in a Partial utility type:

type HashMap = Partial<Record<string, boolean>>;

Using noUncheckedIndexedAccess compiler option

My buddy Titian highlighted that in version 4.1, released exactly 3 years ago in November 2020, TypeScript introduced a new compiler option called noUncheckedIndexedAccess to address exactly this sort of problems.

tsconfig.jsonJSON
{
  "compilerOptions": {
    "noUncheckedIndexedAccess": true
  }
}

As mentioned in the docs, setting it to true in tsconfig.json will add undefined to any un-declared field in the type.

type HashMap = {
  // notice the lack of optional specifier
  [key in string]: boolean;
};

const letters: HashMap = {
  "A": true,
}

const A = letters["A"]; // 👍 boolean | undefined
const X = letters["X"]; // ✅ boolean | undefined

The behavior is similar to the one using Explicit optional members.

However, noUncheckedIndexedAccess also affects Arrays member access, which is a bit too aggressive according to the community, adding extra undefined inference, even when it's not necessary.

NOT RECOMMENDEDYou might wonder Why isn't this enabled by default with strict mode? The reason is that, as Ryan Cavanaugh mentioned in his comment, the TypeScript team gets too many reports from the community that "noUncheckedIndexedAccess is not smart enough", probably causing too much frustration.

As a consequence, even the official TypeScript documentation doesn't label this option as recommended.

JavaScript Maps

A second approach to define Hash Maps is to use a JavaScript Map, a dedicated construct for defining key-value pairs. TypeScript supports Maps out of the box.

// 1️⃣ define the type
type HashMap = Map<string, boolean>;

In contrast to POJOs, Maps are more complex data structures, providing their own API to insert, retrieve, and delete items.

// 2️⃣ initialize with data
const letters: HashMap = new Map();
letters.set("A", true);

// 3️⃣ dynamic member access
const A = letters.get("A"); // 👍 boolean | undefined
const X = letters.get("X"); // ✅ boolean | undefined

When accessing an item from the Map, TypeScript correctly infers its type as potentially undefined even without the noUncheckedIndexedAccess option. TypeScript performs only static analysis. Thus, it cannot guarantee that the item will actually be in the Map when the code will be executed at runtime.

Bulk initialization

Notice that I've used .set() to add new members after instantiating a new Map(). The constructor can also receive initial values sa well, by passing an Array of key-value tuples:

const letters: HashMap = new Map([
  ["A", true],
  ["B", false],
]);

We can also pass a plain Object by using Object.entries(), since it returns an Array<[key, value]> which is exactly what the Map constructor needs:

const letters: HashMap = new Map(Object.entries({
  "A": true,
  "B": false,
}));

To conclude

As a non-expert in type systems, a JavaScript Map provides better type safety than POJOs, especially with data structures requiring dynamic access.

To get the same type-safe behavior with POJOs, we must remember to explicitly define the items as optional. Enforcing strict checks using noUncheckedIndexedAccess might be a bit too aggressive and potentially having unexpected behaviors.


Scroll to top