Home > Blockchain >  TypeGuard function for a key in a generic object
TypeGuard function for a key in a generic object

Time:09-16

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.

Playground

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


Playground link to code

  • Related