JavaScript has two common patterns:
.has()
before retrieving the associated value via .get()
.These patterns don’t work as well in TypeScript. This blog post explains why and presents alternatives.
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.
!
) 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);
// ...
}
}
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
.
.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:
.get()
.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)
// ...
}
}
translations
is Map<string, string>
(line A).if
condition, translations
has a different type in the true
branch and .get()
is overridden (line B).'hello'
, .get()
returns a value of type string
(line C). Otherwise, it returns a value of type undefined | string
(line D).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<undefined | string>(translation2); // (A)
}
}
The type in line A is always correct and only too broad if word
is 'abc'
.
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<undefined | string>(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()
.
!
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);
// ···
}
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.
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).
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>
>>,
];
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:
.has()
and .get()
with !
operator.get()
and undefined
check!
operatorundefined
checkin
operator and indexed accessts-has-guards
: “This package provides type-guarded versions of the has
method for built-in types like Map
, FormData
, and URLSearchParams
so that you can narrow .get(key)
return type within the conditional type guard blocks.”
Chapter “Type guards and narrowing” in “Exploring TypeScript”