Skip to content

Instantly share code, notes, and snippets.

@ianmstew
Last active September 7, 2023 18:58
Show Gist options
  • Save ianmstew/2b60f54fc605f81bf53a46d6b6bc9868 to your computer and use it in GitHub Desktop.
Save ianmstew/2b60f54fc605f81bf53a46d6b6bc9868 to your computer and use it in GitHub Desktop.
Plain objects as maps in TypeScript

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>][];
}
@bzhmaddog
Copy link

Cannot find name 'GetDictionaryValue'.ts(2304)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment