DEV Community

Cover image for Indexing objects in TypeScript

Indexing objects in TypeScript

MapleLeaf on January 31, 2018

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...
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)
Enter fullscreen mode Exit fullscreen mode
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'.
  }
}
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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
  }
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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 • 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

👍 Might even be more readable that way.

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

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

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
 
donzcalabz profile image
Adonis Calabia

Try this bro hope it helps

export type statusType = 'online' | 'offline' | 'busy' | 'dnd';

const statusDisplays = {
  online: 'Online',
  offline: 'Offline',
  busy: 'Busy',
  dnd: 'Do Not Disturb',
};

interface User {
  id: string;
  status: statusType;
}

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

// get the displayed status text
const displayedStatus = statusDisplays[userStatus.status];

Enter fullscreen mode Exit fullscreen mode