TypeScript doesn't understand that arraysEqual(Object.keys(input), ["var1", "var2"])
has any implication for the apparent type of input
. The narrowing abilities of TypeScript are confined to a fairly small set of idiomatic coding techniques that needed to be explicitly implemented in the compiler, and that check isn't among them. As soon as you check the results of Object.keys(input)
it's too late for the compiler to do anything, because the return type is just string[]
and has nothing to do with input
. See Why doesn't Object.keys return a keyof type in TypeScript? for why.
One approach you can take in situations like this is to write your own custom type guard function. You implement the function so it does the check you need, and give the function a call signature that expresses how the check will affect one of the parameters of the function. It's basically a way for you to tell the compiler how to narrow things, when it can't figure it out for itself.
Since you're trying to check whether all of the values in an array of keys are present in an object, we can call the check containsKeys
, and give it a call signature like:
declare function containsKeys<T extends Partial<Record<K, any>>, K extends keyof T>(
obj: T, ...keys: K[]
): obj is T & Required<Pick<T, K>>;
That's a generic function which accepts an object obj
of generic type T
and a (spread) array keys
of generic type K[]
, where T
and K
are constrained so that K
are all keys which are known to (possibly) be in T
. And the return value is obj is T & Required<Pick<T, K>>
(using both the Required
and Pick
utility types), meaning that a true
return value implies that the type of obj
can be narrowed from T
to a version of T
where all the properties with keys in K
are known to be present.
It can be implemented however you like, although the compiler will just believe you that any boolean
-returning code is acceptable. So we can take your code and just drop it in:
function containsKeys<T extends Partial<Record<K, any>>, K extends keyof T>(
obj: T, ...keys: K[]): obj is T & Required<Pick<T, K>> {
return JSON.stringify(Object.keys(obj)) == JSON.stringify(keys);
}
But that's not a good check. It relies on the order of the keys being the same in both cases, and that's not really something you can guarantee. Instead you should consider doing some order-independent check; perhaps:
function containsKeys<T extends Partial<Record<K, any>>, K extends keyof T>(
obj: T, ...keys: K[]): obj is T & Required<Pick<T, K>> {
return keys.every(k => k in obj);
}
And now we can test it out:
function foo(input: Input): number {
if (!containsKeys(input, "var1", "var2")) {
return 0
}
// input: Input & Required<Pick<Input, "var1" | "var2">>
// equivalent to { var1: number; var2: number; }
const var1 = input.var1 + 3
return var1
}
That works. After the initial block, input
has been narrowed from Input
to Input & Required<Pick<Input, "var1" | "var2">>
, a complicated-looking type equivalent to {var1: number; var2: number}
... that is, the same type as Input
with the keys required instead of optional. So input.var1
is known to be a number
and not number | undefined
.
Playground link to code