2

I am passing an object which may have an optional ket value like: var1?: number; I used arrayEqual to make sure all key exists in the object before using it.

However, I still get 'input.var1' is possibly 'undefined'. in const var1 = input.var1 + 3.

How can I solve it? Or should I check it before passing it to foo() (e.g. use arrayEqual to check before passing to foo(), so I can remove all ? in the inputT)?

interface inputT {
    var1?: number;
    var2?: number;
}

function arraysEqual(a1: string[], a2: string[]) {
    return JSON.stringify(a1) == JSON.stringify(a2);
  }

function foo(input: inputT): number {
    if (
        !arraysEqual(Object.keys(input), ["var1", "var2"])){
            return 0
        } 
    const var1 = input.var1 + 3
    return var1
}


foo({"var1":2})
TungTung
  • 163
  • 7
  • 2
    ① You really don't want to do the check by comparing arrays that way, since `{var2: 1, var1: 1}` matches your type but the array of keys won't match due to ordering. ② If you want to tell the compiler that some action behaves like a type guard you can write a [custom type guard function](https://www.typescriptlang.org/docs/handbook/2/narrowing.html#using-type-predicates) as shown [in this playground link](https://tsplay.dev/wO2BzW). Does that meet your needs? If so I'll write up an answer explaining; if not, what am I missing? – jcalz Aug 09 '23 at 12:50
  • Yes, it meets what I need, but it is possible to input ("var1", "var2") as an array since I have more than 10 keys to compare; array will be cleaner, I guess? please write an answer, and I will make it the correct one, thank you. @jcalz – TungTung Aug 10 '23 at 09:20
  • Are you aware of the spread operator? You can always spread an array into a variadic function, as shown [in this playground link](https://tsplay.dev/weLeBW). I don't see much of a difference one way or the other; do I need to go into that in my answer or can I just present it as written and you can use an array if you want in your actual code? – jcalz Aug 10 '23 at 12:29
  • Yes, I am just thinking about which way(`("var1", "var2")` or Array) is a better choice; you can present it as written. Thank you. @jcalz – TungTung Aug 11 '23 at 03:45
  • I'll do so when I get a chance, it might not be for a while since I am about to go to sleep – jcalz Aug 11 '23 at 03:53

2 Answers2

1

Instead of using arraysEqual, which cannot be statically analyzed, you can simply check if input.var1 or input.var2 is undefined.

Try this instead:

interface inputT {
  var1?: number;
  var2?: number;
}

function foo(input: inputT): number {
  if (input.var1 === undefined || input.var2 === undefined) {
    return 0;
  }
  const var1 = input.var1 + 3;
  return var1;
}

foo({ var1: 2 });

With this approach, TypeScript can be sure that input.var1 is defined when you access it.

As a side note, this is not a good way to check for equality of arrays. Also, refrain from using the double equal operator (==). Use === instead.

OGreeni
  • 572
  • 5
  • 17
  • 1
    In this case, using `(!input.var1 || !input.var2)` will give the wrong result if either `var1` or `var2` is 0 – Sly_cardinal Aug 09 '23 at 05:26
  • @Sly_cardinal corrected, thanks! – OGreeni Aug 09 '23 at 05:30
  • But in the real case, I had more than ten keys in `input`, should I use 10x`||`? @OGreeni – TungTung Aug 09 '23 at 06:20
  • @TungTung You could also provide default values for each of your parameters. i.e., `foo({var1 = 0, var2 = 0}: inputT)`. – OGreeni Aug 09 '23 at 06:45
  • But I just want to check if all the keys exist, if not, return something, if yes, process the calculation. @OGreeni – TungTung Aug 09 '23 at 07:40
  • Should I loop the ["var1", "var2"] array to check whether any of them is undefined, if yes, just return 0? @OGreeni – TungTung Aug 09 '23 at 09:51
  • @TungTung then I would advise to check each key individually. Since it is an object and not an array, you have a predetermined number of keys. TS would also be able to statically analyze your code this way. – OGreeni Aug 09 '23 at 21:06
  • 1
    @TungTung or use jcalz’s suggestion and write a type guard. – OGreeni Aug 09 '23 at 21:22
1

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

jcalz
  • 264,269
  • 27
  • 359
  • 360