Search code examples
typescript

Dynamically check if TypeScript object has key without custom type guard


I can use TypeScript's in keyword to check if an object has a key in a type safe way if write the key as a string literal:

function guardHasTest <Data extends object> (
  value: Data
): Data & Record<'test', unknown> {
  if (!('test' in value)) {
    throw new Error('Missing key')
  },
  return value
}

However, if I make the key a dynamic string, the same syntax does not narrow the type:

function guardHasKey <Data extends object, Key extends string> (
  value: Data,
  key: Key
): Data & Record<Key, unknown> {
  if (!(key in value)) {
    throw new Error('Missing key')
  }
  return value
  // Type 'Data' is not assignable to type 'Data & Record<Key, unknown>'.
  Type 'object' is not assignable to type 'Data & Record<Key, unknown>'.
    Type 'object' is not assignable to type 'Data'.
      'object' is assignable to the constraint of type 'Data', but 'Data' could be instantiated with a different subtype of constraint 'object'.
        Type 'Data' is not assignable to type 'Record<Key, unknown>'.
          Type 'object' is not assignable to type 'Record<Key, unknown>'.ts(2322)
}

I do not want to use custom type guards because their logic is not type safe:

export function isKeyOf<T, const Key extends string>(
  obj: T,
  key: Key
): obj is T & Record<Key, unknown> {
  return true // No error, even though the logic is not correct
}

How can I make a function dynamically check if an object has a key in a fully type safe way?


Solution

  • The reason guardHasTest() works is because it uses the support for unlisted property narrowing with the in operator as implemented in microsoft/TypeScript#50666 and released with TypeScript 4.9. In versions of TypeScript before 4.9, that would have given you the same error as in guardHasKey(). It's a relatively new feature.

    The PR at microsoft/TypeScript#50666 was written to address the feature request at microsoft/TypeScript#21732. It handles keys of a literal type like "test". It specifically does not handle the case of a generic key:

    I'm marking this PR as fixing #21732 even though it doesn't address the case of key in obj where key is of some generic type. In general, when key is of some non-finite type (like string), the only effect we can meaningfully reflect following a key in obj check is that obj[key] should be a valid expression (of type unknown), provided key and obj are both known not to have been modified since the check. This might be possible to do in the true branch of an if statement or a ternary operator, but it isn't possible in all control flow scenarios.

    That's the official word as to why it's not done; it was considered not possible to implement in narrowings for non-literal keys in a way that would work with all the requisite places where control flow analysis occurs. If you want to see something different here, you might file a feature request for it, but I wouldn't have high expectations for that to happen.


    That means it is currently impossible to have the compiler simply notice that guardHasKey() works as advertised. (Actually it's a little interesting to imagine what guardHasKey(obj, Math.random()<0.5 ? "a" : "b") should narrow to. Should it be typeof obj & {a: unknown; b: unknown} as implied by Record? Or should it be typeof obj & ({a: unknown} | {b: unknown}), or something else? This turns out to be the same sort of conundrum hindering a proposed fix for microsoft/TypeScript#13948.)

    Right now you could either just use a type assertion to force the return line to compile, or you could write a custom type guard function such as the one written inside microsoft/TypeScript#21732. Of course neither of these are guaranteed to be type safe by the compiler. But that's the point of such features, to let you express things you know that the compiler doesn't. So while you might prefer to avoid type guard functions for some kind of type purity ideal, know that TypeScript isn't meant to be 100% purely compiler-verified type safety, and custom type guard functions are one of the building blocks that lets you separate the unsafe parts (the implementation of that function) from the safe parts (the callers of that function).