TypeScript: checking Map keys and Array indices

[2025-06-21] dev, typescript
(Ad, please don’t block)

JavaScript has two common patterns:

  • Maps: We check the existence of a key via .has() before retrieving the associated value via .get().
  • Arrays: We check the length of an Array before performing an indexed access.

These patterns don’t work as well in TypeScript. This blog post explains why and presents alternatives.

Key checks for Maps  

The following is a common pattern in JavaScript:

if (someMap.has('key')) {
  const value = someMap.get('key');
  // (Do something with `value`)
}

However that pattern doesn’t work as well in TypeScript:

function translateHello(translations: Map<string, string>): void {
  if (translations.has('hello')) { // (A)
    const translation = translations.get('hello'); // (B)
    assertType<undefined | string>(translation); // (C)
    // ...
  }
}

We perform a key check in line A but that check does not affect the type of the value that we retrieve in line B: It is undefined | string (line C) even though we can be sure that we get back a string.

Let’s look at solutions for this problem next.

Solution: non-null assertion operator (postfix !)  

The non-null assertion operator lets us tell TypeScript: We are sure that the preceding value is neither undefined nor null. With that operator, translation has the desired type:

function translateHello(translations: Map<string, string>): void {
  if (translations.has('hello')) {
    const translation = translations.get('hello')!;
    assertType<string>(translation);
    // ...
  }
}

Solution: using a different pattern  

Another common solution is to simply switch to a different pattern:

function translateHello(translations: Map<string, string>): void {
  const translation = translations.get('hello');
  if (translation !== undefined) {
    assertType<string>(translation);
    // ...
  }
}

This pattern works well and has only one minor limitation: It doesn’t work if Map values can be undefined.

Solution: changing the type of .has()  

The following interface augments the existing type Map so that its method .has() works differently (credit: Stack Overflow user hackape):

interface Map<K, V> {
  has<Key extends K>(key: Key): this is { get(key: Key): V } & this
}

To activate the augmentation, you can either:

  • Put that type next to where you can to use the improved .get().
  • Create a declaration file such as globals.d.ts with that type and put it somewhere in your code base.

How does this code work?

  • The keyword is in the return type means that .has() is now a user-defined type guard.

  • If the type guard is invoked in an if condition then this has a new type in the true branch: The type intersection operator & is used to override .get() so that it returns V (vs. undefined | V) if the key has the type Key. this must be the second operand for the override to work.

Let’s see what types are inferred with this augmentation:

function translateHello(translations: Map<string, string>): void {
  assertType< // (A)
    Map<string, string>
  >(translations);
  if (translations.has('hello')) {
    assertType< // (B)
      {
        get(key: "hello"): string,
      } & Map<string, string>
    >(translations);
    
    const translation1 = translations.get('hello');
    assertType<string>(translation1); // (C)

    const translation2 = translations.get('abc');
    assertType<undefined | string>(translation1); // (D)
    // ...
  }
}
  • Initially the type of translations is Map<string, string> (line A).
  • Because we use a type guard in the if condition, translations has a different type in the true branch and .get() is overridden (line B).
  • Therefore, if the key is 'hello', .get() returns a value of type string (line C). Otherwise, it returns a value of type undefined | string (line D).

Limitation of the solution  

If the static type of the key is string then this solution doesn’t work:

function translate(translations: Map<string, string>, word: string): void {
  if (translations.has(word)) {
    assertType<
      {
        get(key: string): string,
      } & Map<string, string>
    >(translations);
    const translation = translations.get('abc');
    assertType<string>(translation); // (A)
  }
}

In line A, the result has the type string when it usually should have the type undefined | string. Alas, TypeScript doesn’t have enough information to provide better types.

We can introduce a type variable to somewhat improve this code:

function translate<S extends string>(translations: Map<string, string>, word: S): void {
  if (translations.has(word)) {
    assertType<
      {
        get(key: S): string,
      } & Map<string, string>
    >(translations);
    const translation1 = translations.get(word);
    assertType<string>(translation1);
    const translation2 = translations.get('abc');
    assertType<undefinedstring>(translation2); // (A)
  }
}

The type in line A is always correct and only too broad if word is 'abc'.

Index checks for Arrays  

Indexed accesses of Arrays are often guarded by length checks in JavaScript – e.g.:

function main(args) {
  if (args.length < 1) {
    throw new Error('Need at least one argument');
  }
  const filePath = args[0];
  // ···
}

With noUncheckedIndexedAccess: true in tsconfig.json, we get the following types:

function main(args: Array<string>): void {
  if (args.length < 1) {
    throw new Error('Need at least one argument');
  }
  const filePath = args[0];
  assertType<undefinedstring>(filePath);

  // ···
}

The issue is similar to the one we had with .has() and Maps. The tsconfig option noUncheckedIndexedAccess means that indexed access via square brackets behaves like the Map method .get() or the Array method .at().

Solution: ! operator  

One option is to once again use the non-null assertion operator:

function main(args: Array<string>): void {
  if (args.length < 1) {
    throw new Error('Need at least one argument');
  }
  const filePath = args[0]!;
  assertType<string>(filePath);
  // ···
}

Solution: checking for undefined  

Another option is to check for undefined:

function main(args: Array<string>): void {
  const filePath = args[0];
  if (filePath === undefined) {
    throw new Error('Need at least one argument');
  }
  assertType<string>(filePath);
  // ···
}

In a way, this solution is safer than a length check because Arrays can contain holes.

Solution: index checks via in  

Interestingly, using in as a type guard affects indexed reading:

function main(args: Array<string>): void {
  if (!(0 in args)) {
    throw new Error('Need at least one argument');
  }
  const filePath = args[0];
  assertType<string>(filePath); // (A)
  assertType<undefined | string>(args[1]); // (B)
  // ···
}

We checked index 0 – which is why the value at that index has the type string (line A). Values at other indices still have the type undefined | string (line B).

Supporting length checks at the type level  

We can define our own length check via a function isLengthAtLeast() that works as follows:

function func(arr: Array<string>) {
  if (isLengthAtLeast(arr, 2)) {
    assertType< // (A)
      Record<1, string> & Record<0, string> & string[]
    >(arr);
    const elem0 = arr[0]; // string
    const elem1 = arr[1]; // string
    const elem2 = arr[2]; // string | undefined
  }
}

isLengthAtLeast() is a user-defined type guard and changes the type of arr (line A): The Record operands of the type intersection operator & ensure that we get narrower types for the indices 1 and 0.

function isLengthAtLeast<
  A extends Array<unknown>, L extends number
>(arr: A, minLength: L): arr is MinLength<A, L> { // (A)
  return arr.length >= minLength;
}

The is in line A makes this function a type guard. It uses the following helper type:

type MinLength<
  A extends Array<unknown>,
  L extends number,
  Operands extends Array<unknown> = [],
> = L extends Operands['length']
  ? TupleToIntersection<Operands> & A
  : MinLength<A, L, [Record<Operands['length'], A[number]>, ...Operands]>
;

MinLength appends Record elements to the tuple Operands until it has the length L. Then it converts the tuple Operands to an intersection – via the following helper type:

type TupleToIntersection<T extends Array<unknown>> = 
  T extends [infer First, ...infer Rest]
    ? First & TupleToIntersection<Rest>
    : unknown // neutral element
;

TupleToIntersection recurses over the elements of the tuple T and converts them to operands of the intersection operator &:

type _ = [
  Assert<Equal<
    TupleToIntersection<[Record<'a', 1>, Record<'b', 2>]>,
    Record<'a', 1> & Record<'b', 2>
  >>,
];

Which approach to choose?  

As clever as the hacks for the better .has() and the Array length check are, I’d worry about code becoming less portable. Therefore, I usually stick with mainstream solutions:

  • Maps:
    • .has() and .get() with ! operator
    • .get() and undefined check
  • Arrays:
    • Length check and indexed access with ! operator
    • Indexed access and undefined check
    • in operator and indexed access

Resources