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