typescripttype-inference

My type predicate function does not work as I would expect. Need help understanding why


Consider the following type predicate function to check if an object contains a specific key:

export function objectHasKey<T extends object>(
  obj: T,
  key: PropertyKey,
  includeKeysFromPrototype: boolean = false,
): key is keyof T {
  return includeKeysFromPrototype ? key in obj : Object.prototype.hasOwnProperty.call(obj, key);
}

Now, consider the following dummy code to illustrate my confusion:

let entry: unknown = {
  someProperty: '123'
}

// (1) EXPECTED -> TS2339: Property 'someProperty' does not exist on type 'unknown'
console.log(entry.someProperty)

if (typeof entry === 'object' && objectHasKey(entry, 'someProperty')) {
  // (2) UNEXPECTED -> TS2339: Property 'someProperty' does not exist on type 'object'
  console.log(entry.someProperty)
}

I do not understand why TypeScript raises an error at (2), even though I have explicitly checked that someProperty is a key of entry using my type predicate function.


Solution

  • Your function

    declare function objectHasKey<T extends object>(
      obj: T, key: PropertyKey, includeKeysFromPrototype: boolean
    ): key is keyof T
    

    returns the type predicate key is keyof T, which means that when you call objectHasKey(obj, key) and it returns true, the apparent type of the key input will be narrowed to keyof T (equivalent to keyof typeof obj). The apparent type of the obj input will not be changed at all. So if obj is of type object and key is a string literal like "prop", then a true result means that sting literal "prop" will be narrowed from "prop" to keyof object which is the never type. This is not your intent. Even if it were your intent, type predicates only serve to narrow the actual input. If that input is a string literal like "prop", any resulting change will be inaccessible because you never reuse a string literal again. In order for that to work you'd need to use a variable or a property like const k = "prop" and then objectHasKey(obj, k).

    But anyway, this isn't your intent. Your code assumes that after you call objecthasKey(obj, key) and it returns true, the apparent type of the obj input will change so that it is known to have key as a key. That means you need to change your type predicate to be of the form obj is instead of key is. Here's one way to do that (and there are multiple ways):

    declare function objectHasKey<K extends PropertyKey>(
      obj: object,
      key: K,
      includeKeysFromPrototype: boolean = false,
    ): obj is Record<K, any>;
    

    This function is generic in the type K of key. A true result narrows obj from object to Record<K, any> (using the Record utility type). Now your code works as desired:

    if (entry && typeof entry === 'object' && objectHasKey(entry, 'someProperty')) {
      console.log(entry.someProperty) // okay
    }
    

    Playground link to code