Home > front end >  TypeScript union types - error only on function invocation
TypeScript union types - error only on function invocation

Time:04-02

I've stumbled upon a weird behavior when using union types in TypeScript. When passing a reference of a function, without invoking it, there is no type error:

type A = boolean;
type B = string;
type C = number; 
type BC = B | C; 

interface Props { onChange(expr: A | BC): void } 

const SomeFunction = (_props: Props) => {}
const handler = (el: A | B) => { console.log(el) } 
 
SomeFunction({onChange: handler}) // no error here 
 
SomeFunction({onChange: e => handler(e)}) // <- TS Error: Argument of type 'boolean | BC' is not assignable to parameter of type 'string | boolean'.

The only explanation I could find is improved behavior for calling union types in TypeScript 3.3.

I'm curious about the reasons why there is no error when calling SomeFunction({onChange: handler}) - Is it because there is an overlap between union types?

link to typescript playground

CodePudding user response:

one solution for this would be to use strict unions:

type UnionKeys<T> = T extends T ? keyof T : never;
type StrictUnionHelper<T, TAll> = T extends any
  ? T & Partial<Record<Exclude<UnionKeys<TAll>, keyof T>, never>>
  : never;

type StrictUnion<T> = StrictUnionHelper<T, T>;

Then if you declare the Primitive type as type Primitive = StrictUnion<StringOrBool | Num> you would get the correct error for both of them. I think this is one workaround for the problem that @jcalz mentioned in the comments that the method syntax declaration is not supported this way. StrictUnion also understands the method syntax. This implementation is mentioned here.

CodePudding user response:

The difference is that handler is a non-method function, while onChange was declared in Props to be a method. And when you have the --strictFunctionTypes compiler option enabled (which is part of the --strict suite of compiler functionality), parameters of non-method functions are checked more strictly.

In order to be type safe, a function's parameters should be checked contravariantly, which is described in this q/a, but briefly, a function parameter can be safely narrowed but not widened. Since Expr | Primitive is wider than Expr | StringOrBool, it is not safe to treat handler as an onChange, as shown here when you add some functionality:

const SomeFunction = (_props: Props) => { _props.onChange(3) }

const handler = (el: Expr | StringOrBool) => {
    if ((typeof el !== "boolean") && (typeof el !== "object")) {
        console.log(el.toUpperCase()); // has to be a string, right?
    }
    console.log(el)
}

SomeFunction({ onChange: handler }) // runtime error: el.toUpperCase is not a function

The handler function assumes that a non-boolean non-object parameter must be a string, but SomeFunction calls _props.onChange() with a number. Oops!

So at this point it should be clear that, if all we care about is type safety, there should be an error in both invocations of SomeFunction. Indeed, if we rewrite Props to use non-method syntax for onChange:

interface Props {
    onChange: (expr: Expr | Primitive) => void // call expression, not method
}

then we do see both errors:

SomeFunction({ onChange: handler }) // error! 
SomeFunction({ onChange: e => handler(e) }) // error!

But methods (and indeed all functions without --strictFunctionTypes enabled) allow both the safe narrowing (contravariance) and the unsafe widening (covariance). That is, method parameters are checked bivariantly. Why? Well, it turns out that even though this is unsafe, it's quite useful. You can read this FAQ entry or this doc for details, but in short, it enables some common coding practices (like event handling). People like treating a Dog[] as if it were an Animal[], even though it is technically unsafe. If methods were treated safely, then the existence of the push() method of Animal[] would prevent anyone from using a Dog[] as an Animal[]. See this Q/A for more information.

Because we have both method syntax and arrow function syntax available to us, we can sort of have it both ways if we want; anytime you care about type safety of function parameters, stay away from method syntax.

Playground link to code

  • Related