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
kis of a single literal type andobjis of a union of object types where some of the union members explicitly havekas a key, then atrueresult should narrowobjto just those members withkas a key, and afalseresult should narrowobjto just those members withoutkas a key. This is currently how theinoperator 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 theinoperator works today, so we might as well do the same thing forhas().if
kis of a single literal type andobjis not of a union type, and if that non-union type does not have an explicit property value at keyk, then atrueresult should narrowobjto have an explicitunknownproperty value at keyk. Afalseresult should not narrowobjat all. This is not currently theinoperator narrows in TypeScript, although there is a suggestion at microsoft/TypeScript#21732 to support this.if
kis of a wide property type such asstring,number,symbol, orPropertyKey, then we don't want to narrowobjat all for either atrueor afalseresult. It's possible one might want to narrowkin 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
kis of a union of literal types, then presumably we want to narrowobjin the same way as the first two situations: ifobjis a union then narrow downobjto just those union members with/without an explicit key matching any of the possible values ofk; ifobjis not a union then narrowobjinto something which is itself a union of object types with each possible member of the union ofkas 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()returningtruewould narrowobjto 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!
