DEV Community

loading...
Cover image for Indexing objects in TypeScript

Indexing objects in TypeScript

mapleleaf profile image MapleLeaf Updated on ・3 min read

This is a topic that comes up every now and again, so I figured it'd be useful to write a post about it.

Imagine you have code like this:

// an object defining how to display specific user statuses in the UI
const statusDisplays = {
  online: 'Online',
  offline: 'Offline',
  busy: 'Busy',
  dnd: 'Do Not Disturb',
}

// fetch the status of a user by their ID
const userStatus = getUserStatus(myUserID)

// get the displayed status text
const displayedStatus = statusDisplays[userStatus]
Enter fullscreen mode Exit fullscreen mode

However, with strict mode enabled, the last line gives an interesting-looking error:

Element implicitly has an 'any' type because type '{ online: string; offline: string; busy: string; dnd: string; }' has no index signature.

...which might look like Chinese to you if you don't really know what's going on. So let's try to break it down.

Index Signatures

In TypeScript, in order to get an index off of an object, that object's type has to include an index signature on it. Index signatures are often used to define objects used as dictionaries, like the one we have here. An index signature type looks like this:

type Dictionary = { [index: string]: string }
Enter fullscreen mode Exit fullscreen mode

Our object is inferred as the type { online: string; offline: string; busy: string; dnd: string; } and does not have an index signature included.

The reason for this is behavior to prevent runtime errors that come from indexing an object by unknown keys. Imagine the API we're using added a new status 'idle'. statusDisplays['idle'] would return undefined and error when we try to use it. Or worse, fail silently.

A straight-forward-ish solution

To solve that problem in JS, it's enough to check first if the key is valid, and TypeScript usually accomodates for these kinds of checks.

if (userStatus in statusDisplays) {
  statusDisplays[userStatus]
}
Enter fullscreen mode Exit fullscreen mode

Unfortunately, this still produces the same error message, for reasons discussed here and in other similar issues.

Here's a way of getting around that using a helper function:

// `keyof any` is short for "string | number | symbol"
// since an object key can be any of those types, our key can too
// in TS 3.0+, putting just "string" raises an error
function hasKey<O>(obj: O, key: keyof any): key is keyof O {
  return key in obj
}
Enter fullscreen mode Exit fullscreen mode
if (hasKey(statusDisplays, userStatus)) {
  statusDisplays[userStatus] // works fine!
}
Enter fullscreen mode Exit fullscreen mode

This uses a few of interesting features you might not be aware of. Namely generics, keyof, and user-defined type guards.

We use a generic to infer the shape of the object being passed in, to use it in the return type. Here, the O parameter of hasKey is inferred as { online: string; offline: string; busy: string; dnd: string; }.

keyof is a keyword in TypeScript which accepts a given object type and returns a union type of its keys. These are equivalent:

type StatusKey = keyof { online: string; offline: string; busy: string; dnd: string; }
type StatusKey = 'online' | 'offline' | 'busy' | 'dnd'
Enter fullscreen mode Exit fullscreen mode

Lastly, we use a type guard here to say that, if this function returns true, any further usage of key will be of the specified type. Otherwise, it's still just a string.

All of this works because TypeScript allows us to index any object as long as the index's type is a union of all the possible keys, so it knows that the key is valid. Maybe in the future, using key in obj will work on its own, but until then, the helper function works well enough.

If you have any questions or comments, specifically if I left anything out or if anything's unclear, feel free to leave them down below. Thanks for reading!

Discussion (12)

pic
Editor guide
Collapse
pierreyveslebrun profile image
Pierre Lebrun

Just sharing my personal trick, which is a bit off topic and hacky.

I usually prefer to handle such edge cases within the objet literal itself, by providing a default key.

Then I basically lie to the TypeScript compiler by omitting the extra default key in my type definitions.


interface Status {
  online: 'Online',
  offline: 'Offline',
  busy: 'Busy',
  dnd: 'Do Not Disturb',
}

function getDisplayedStatus(status: keyof Status) {
  const statusDisplays = {
    online: 'Online',
    offline: 'Offline',
    busy: 'Busy',
    dnd: 'Do Not Disturb',
    default: 'Unknown'
  }

  return statusDisplays[status] || status.default
}

  const displayedStatus = getDisplayedStatus(status as keyof Status)
Collapse
wdoug profile image
Will Douglas

I recently added some helper utilities to our codebase based on your advice for the hasKey function above. This function works great when you are dealing with object literals (or at least well-defined object types). When the object in question is not well defined (for example, the object is interpreted as the base type object or we don't know the specific keys on the object), then the hasKey function doesn't work as well.

For example, take this function:

function fn(o: object, k: string) {
  if (hasKey(o, k)) {
    o[k] = 3; // Error: Type '3' is not assignable to type 'never'.
  }
}

In the above code, the key k gets narrowed to the type never because keyof object is never (since the object type doesn't have any defined keys).

There is, however, a different way to handle this case that uses a type predicate to update the type for the object itself. I found it in this other blog post (fettblog.eu/typescript-hasownproperty), and I have copied a slightly modified version of the code below for reference:

function hasOwnProperty<O extends object, K extends PropertyKey>(
  obj: O,
  key: K,
): obj is O & Record<K, unknown> {
  return Object.prototype.hasOwnProperty.call(obj, key);
}

Side note that PropertyKey is an included TypeScript es5 lib global type definition that is equavalent to string | number | symbol.

With this helper, this code will work fine:

function fn(o: object, k: string) {
  if (hasKey(o, k)) {
    o[k] = 3; // This is fine
  }
}

Additionally, because of how TypeScript handles intersections with the unknown type, this also works well for cases where the object is more well defined.

const k = 'a';
const o = { a: 0 };
if (hasOwnProperty(o, k)) {
  const result = o[k];
  console.log(result); // typeof result is number
}

While the approach of hasKey for narrowing the key type itself probably still has its uses, I currently think that when it comes to indexing objects, the approach used for the hasOwnProperty utility is probably more broadly applicable and preferable.

Collapse
wdoug profile image
Will Douglas

If you want to be extra fancy and not add the intersection with Record<K, unknown> if the key is known to exist on the object type, you can also use conditional types. For example:

export function hasOwnProperty<O extends object, K extends PropertyKey>(
  obj: O,
  key: K,
): obj is ObjectWithKey<O, K> {
  return Object.prototype.hasOwnProperty.call(obj, key);
}

/**
 * This utility type takes an object type O and a key type K. If K is a known 
 * key on O, it evaluates to the original object O. Otherwise, it evaluates to 
 * the object O with an additional key K that has an `unknown` value.
 */
type ObjectWithKey<O extends object, K extends PropertyKey> = K extends keyof O
  ? O
  : O & Record<K, unknown>;
Collapse
wdoug profile image
Will Douglas

Actually, the conditional type doesn't work when either of the arguments is itself a generic type (playground example).

Collapse
newswim profile image
Dan Minshew

I get a kind of interesting error when using hasKey,

A type predicate's type must be assignable to its parameter's type.
  Type 'keyof O' is not assignable to type 'string'.
    Type 'string | number | symbol' is not assignable to type 'string'.
      Type 'number' is not assignable to type 'string'.

Any idea what's causing this?

Collapse
mapleleaf profile image
MapleLeaf Author • Edited

Turns out TS 3.0 broke this 🙃

I'm not quite sure how to fix it at the moment, but if someone else has a solution, I'm all ears. I know Lodash's types are pretty comprehensive, so I might start looking there myself

EDIT: I love it when I figure it out right after posting the comment. It needs to be string | number | symbol, since 3.0 expanded the kinds of types that can be used for object keys. We can shorten that to keyof any.

function hasKey<O>(obj: O, key: keyof any): key is keyof O {
  return key in obj
}

Updated the blog post. Thanks for the heads up 🙌

Collapse
newswim profile image
Dan Minshew • Edited

Nice! I ended up opting for the union type, just because any gives me anxiety . .

function hasKey<O>(obj: O, key: string | number | symbol): key is keyof O {
  return key in obj
}
Thread Thread
mapleleaf profile image
MapleLeaf Author

👍 Might even be more readable that way.

Collapse
methodbox profile image
Chris Shaffer

I appreciate the solution as I have a similar issue driving me nuts.

However, this is one aspect of TS I'm not really on board with yet and may never be: you had to write an entirely separate function just to handle the key indexing of a simple object.

This seems like a massive waste of time with little benefit in most cases.

I feel like there is a separation with what our goals are with TypeScript (as developers) and what TS is actually doing in this instance. TS is checking the type of the index value first, rather than resolving that index value and determining if it's valid for our use case.

Basically, I'm forced to check each of the index values whether I want or need to, when really I just need to make sure that value is valid for the argument I'm passing in - it seems the argument's expected type should be the source of truth, not the object's index value's type.

I understand that value still needs to be known to check it against the argument's type, but it would seem identifying that type should be able to be inferred in most cases.

Thanks for the clear explanation here, though!

Collapse
pierreyveslebrun profile image
Pierre Lebrun

I quite agree with you.
For this edge case the lack of type inference is a bit disappointing.

Collapse
link2twenty profile image
Andrew Bone • Edited

I recently discovered this solution.

declare global {
  interface ObjectConstructor {
    typedKeys<T>(obj: T): Array<keyof T>
  }
}
Object.typedKeys = Object.keys as any;
Enter fullscreen mode Exit fullscreen mode

This means later on in your code you can do something like this.

  for (let key of Object.typedKeys(object)) {
    object[key] 
  }
Enter fullscreen mode Exit fullscreen mode

Provided we know what type object is it'll work.

Looking through the code we can see if object is type IOptions then Object.typedKeys(object) will be Array<keyof IOptions> meaning an array of keys for IOptions. Because of this our key gets the type keyof IOptions or key for IOptions.

Collapse
mapleleaf profile image
MapleLeaf Author

That's a neat way to go about it! I'm not a fan of monkey-patching builtins, though