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.
CodePudding user response:
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 andobj
is of a union of object types where some of the union members explicitly havek
as a key, then atrue
result should narrowobj
to just those members withk
as a key, and afalse
result should narrowobj
to just those members withoutk
as a key. This is currently how thein
operator narrows an object viak in obj
. It is not sound, since structural subtyping means that an object of type{x: string}
may well have more keys than justx
, 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 thein
operator works today, so we might as well do the same thing forhas()
.if
k
is of a single literal type andobj
is not of a union type, and if that non-union type does not have an explicit property value at keyk
, then atrue
result should narrowobj
to have an explicitunknown
property value at keyk
. Afalse
result should not narrowobj
at all. This is not currently thein
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 asstring
,number
,symbol
, orPropertyKey
, then we don't want to narrowobj
at all for either atrue
or afalse
result. It's possible one might want to narrowk
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 ofhas()
will just beboolean
.if
k
is of a union of literal types, then presumably we want to narrowobj
in the same way as the first two situations: ifobj
is a union then narrow downobj
to just those union members with/without an explicit key matching any of the possible values ofk
; ifobj
is not a union then narrowobj
into something which is itself a union of object types with each possible member of the union ofk
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 ofhas()
returningtrue
would narrowobj
to something having all the keys from the union ofk
, 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!