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.
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.
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.
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.
{
"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.
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.
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.