So another 🤯 about TypeScript. We use plain objects as arbitrary maps all the time, but TypeScript deems that usage (correctly) too flexible.
const map = {};
map.foo = 'foo'; // Error: Property 'foo' does not exist on type '{}'.
Okay, then we need to type our object as a map:
const map: object = {};
map.foo = 'foo'; // Error: Property 'foo' does not exist on type '{}'.
So, the object
type apparently isn't what we think it is. We can be more explicit:
const map: { [key: string]: any } = {};
map.foo = 'foo'; // No problem!
For convenience, we can define a Dictionary
type, including with a generic type parameter
whose value is any
by default.
// Place in app-global type definition file, so only need to define this once per application.
interface Dictionary<T = any> {
[key: string]: T;
}
const map: Dictionary = {};
map.foo = 'foo' // No problem!
But is it safe?
const map: Dictionary = {};
map.bar.undefinedError // Unfortunately, no problem despite this being JavaScript error :(
So, we need to enhance our map type with the possibility of undefined. In order to guarantee
this, we must ensure that undefined
can't be set as a value, including as the generic value parameter.
// Helper union type of all but undefined
// https://github.com/microsoft/TypeScript/issues/7648#issuecomment-202637151
type notUndefined = string | number | boolean | symbol | object;
interface Dictionary<T extends notUndefined = notUndefined> {
[key: string]: T | undefined;
}
const map: Dictionary = {};
map.bar.undefinedError // Error: `bar` is possibly undefined!
Now what about Object.entries()
and Object.values()
? Since we know a Dictionary
can't have
a key that corresponds to an undefined
value, these methods should be undefined-safe.
interface User {
username: string
}
const map: Dictionary<User> = {};
Object.values(map).forEach((user) => console.log(user.username)); // Error: Object is possibly undefined
What if Object.entries()
and Object.values()
, built in methods, could be aware of the
Dictionary
interface and take appropriate action? Well, We can safely augment the built in types
in an entirely backwards-compatible manner 😵
// Place in app-global type definition file, so only need to define this once per application.
interface ObjectConstructor {
values<TDictionary>(
o: TDictionary extends Dictionary ? TDictionary : never
): TDictionary extends Dictionary<infer TElement> ? TElement[] : never;
entries<TDictionary>(
o: TDictionary
): [string, GetDictionaryValue<TDictionary>][];
}
// Later, in app code...
const map: Dictionary<User> = {};
Object.values(map).forEach((user) => console.log(user.username)); // No issues
map.ian.username // We still correctly have an error here: object is possibly undefined
The long story short, this file global.d.ts
installed at the app root enables an app-global
Dictionary
type that allows using plain JavaScript objects as undefined-safe arbitrary maps:
type notUndefined = string | number | boolean | symbol | object;
interface Dictionary<T extends notUndefined = notUndefined> {
[key: string]: T | undefined;
}
interface ObjectConstructor {
values<TDictionary>(
o: TDictionary extends Dictionary ? TDictionary : never
): TDictionary extends Dictionary<infer TElement> ? TElement[] : never;
entries<TDictionary>(
o: TDictionary
): [string, GetDictionaryValue<TDictionary>][];
}
Could you have possibly skipped over defining
GetDictionaryValue
?