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]
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 looks like complete nonsense 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 }
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]
}
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:
// `PropertyKey` 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: PropertyKey): key is keyof O {
return key in obj
}
if (hasKey(statusDisplays, userStatus)) {
statusDisplays[userStatus] // works fine!
}
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'
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!
Top comments (13)
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.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 typeobject
or we don't know the specific keys on the object), then thehasKey
function doesn't work as well.For example, take this function:
In the above code, the key
k
gets narrowed to the typenever
becausekeyof object
isnever
(since theobject
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:
Side note that
PropertyKey
is an included TypeScript es5 lib global type definition that is equavalent tostring | number | symbol
.With this helper, this code will work 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.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 thehasOwnProperty
utility is probably more broadly applicable and preferable.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:Actually, the conditional type doesn't work when either of the arguments is itself a generic type (playground example).
I get a kind of interesting error when using
hasKey
,Any idea what's causing this?
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 tokeyof any
.Updated the blog post. Thanks for the heads up 🙌
Nice! I ended up opting for the union type, just because
any
gives me anxiety . .👍 Might even be more readable that way.
I recently discovered this solution.
This means later on in your code you can do something like this.
Provided we know what type
object
is it'll work.Looking through the code we can see if
object
is typeIOptions
thenObject.typedKeys(object)
will beArray<keyof IOptions>
meaning an array of keys forIOptions
. Because of this ourkey
gets the typekeyof IOptions
or key forIOptions
.That's a neat way to go about it! I'm not a fan of monkey-patching builtins, though
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!
I quite agree with you.
For this edge case the lack of type inference is a bit disappointing.
Try this bro hope it helps