Reputation: 6906
Is it possible to create a TypeScript type guard function that determines if a given key is in a given (generic) object — very similar to key in obj
, but as a functional type guard (required for reasons unrelated to this question).
For example, something like this:
export function has<T extends { [index: string]: any; [index: number]: any }>(
obj: T,
property: string | symbol | number
): property is keyof T {
return Object.prototype.hasOwnProperty.call(obj, property)
}
// Then in user land somewhere:
interface Foo {
bar: string
}
interface Fuzz {
buzz: string
}
function doWork(thing: Foo | Fuzz) {
if (has(thing, 'bar')) {
alert(thing.bar) // ideally we've type narrowed to know thing contains foo
}
}
The above code does not work how I would expect (alert(thing.foo)
) does not know foo exists — obviously my type guard declaration property is keyof T
doesn't do what I'm expecting.
You could type guard the results to only be Foo
or Fuzz
— but I specifically want to type guard that a particular key exists on a generic.
Upvotes: 3
Views: 1726
Reputation: 327849
We want has(obj, k)
to act as a user-defined type guard function. The use cases we are trying to support with has(obj, k)
are:
if k
is of a single literal type and obj
is of a union of object types where some of the union members explicitly have k
as a key, then a true
result should narrow obj
to just those members with k
as a key, and a false
result should narrow obj
to just those members without k
as a key. This is currently how the in
operator narrows an object via k in obj
. It is not sound, since structural subtyping means that an object of type {x: string}
may well have more keys than just x
, so you can't safely eliminate {x: string}
from the list of possibilities when you check for a key of, say, y
. But this is how the in
operator works today, so we might as well do the same thing for has()
.
if k
is of a single literal type and obj
is not of a union type, and if that non-union type does not have an explicit property value at key k
, then a true
result should narrow obj
to have an explicit unknown
property value at key k
. A false
result should not narrow obj
at all. This is not currently the in
operator narrows in TypeScript, although there is a suggestion at microsoft/TypeScript#21732 to support this.
if k
is of a wide property type such as string
, number
, symbol
, or PropertyKey
, then we don't want to narrow obj
at all for either a true
or a false
result. It's possible one might want to narrow k
in such situations, but this has not been explicitly called out as a use case, so I will not pursue that. For now I will say that in such a situation, the return value of has()
will just be boolean
.
if k
is of a union of literal types, then presumably we want to narrow obj
in the same way as the first two situations: if obj
is a union then narrow down obj
to just those union members with/without an explicit key matching any of the possible values of k
; if obj
is not a union then narrow obj
into something which is itself a union of object types with each possible member of the union of k
as one member of the result. This was not explicitly stated as a requirement, but it's better to do this than anything else I can think of (the simplest implementation of has()
returning true
would narrow obj
to something having all the keys from the union of k
, which is considerably worse).
With those use cases in mind, here's a potential implementation of has()
:
export function has<T extends object, K extends PropertyKey>(
obj: T,
property: RequireLiteral<K>
): obj is T & { [P in K]: { [Q in P]: unknown } }[K];
export function has(obj: any, property: PropertyKey): boolean;
export function has(obj: any, property: PropertyKey) {
return Object.prototype.hasOwnProperty.call(obj, property)
}
type RequireLiteral<K extends PropertyKey> =
string extends K ? never :
number extends K ? never :
symbol extends K ? never :
K
This is an overloaded function where the first call signature is only invoked in situations where property
is of a literal type or a union of literal types. The RequireLiteral<K>
type function will return K
if so, otherwise it will return never
. In any case, the return type predicate type narrows obj
to the intersection of its original type, and a type with an unknown
property at each key in K
. That {[P in K]:{[Q in P]:unknown}}[K]
type might be easier described by example: if K
is "a"
, then it is {a: unknown}
; if K
is "a" | "b"
, then it is {a: unknown} | {b: unknown}
. This call signature should result in all the behavior we want to support where property
is not a wide type.
The second call signature is invoked only when property
is a wide type like string
or PropertyKey
. If so, the function does not act as a type guard.
We can verify that the stated examples work as desired:
function doWork(thing: Foo | Fuzz) {
if (has(thing, 'bar')) {
// Foo
thing.bar
} else {
// Fuzz
thing.buzz
}
}
const x: { [index: string]: any } = {
baz: 123
}
if (!has(x, 'buzz')) {
/* { [index: string]: any; } */
x.buzz = 123
} else {
/* { [index: string]: any; } & { buzz: unknown; } */
x.buzz
}
const y: { [index: string]: any } = {}
const key: PropertyKey = 'a'
if (!has(y, key)) {
// { [index: string]: any; }
y[key] = 123
} else {
// { [index: string]: any; }
y
}
Looks good!
Upvotes: 3