Dr.Rastafarai
Dr.Rastafarai

Reputation: 95

Why is there an error with type never in a function call?

tell me why he swears at never?

I can't figure out where it comes from? I see that the problem is in the agreePrivacy method, which simply checks that the value is true

If I remove this method, the problem will disappear.

if i remove Omit and sendMarketingEmails field from interface ISignupRequest same error... It seemed to me that everything is simple and clear for a typescript...

Playground

interface ISignupRequest {
  login: string;
  email: string;
  password: string;
  agreePrivacy: boolean;
  sendMarketingEmails: boolean;
}

interface IUserRules {
  login: (login: string) => boolean;
  password: (password: string) => boolean;
  email: (email: string) => boolean;
  agreePrivacy: (agree: boolean) => boolean;
}

const userRules: IUserRules = {
  login: (login) => !!login.length,
  password: (password) => !!password.length,
  email: (email) => !!email.length,
  agreePrivacy: (agree) => agree,
};

const validateUser = (
  user: Omit<ISignupRequest, "sendMarketingEmails">,
): Array<any> =>
  (Object.keys(user) as Array<keyof typeof user>)
    .filter((key) => !userRules[key](user[key]))
    .map((key) => ({
      path: key,
      message: `${key} is invalid`,
    }));

Error in (user[key])

Argument of type 'string | boolean' is not assignable to parameter of type 'never'. Type 'string' is not assignable to type 'never'.

Upvotes: 1

Views: 489

Answers (2)

jcalz
jcalz

Reputation: 328292

First, rather than worry about Omitting a single key, let's define IBaseSignupRequest as

interface IBaseSignupRequest {
  login: string;
  email: string;
  password: string;
  agreePrivacy: boolean;
}

and then extend it to make ISignupRequest:

interface ISignupRequest extends IBaseSignupRequest {
  sendMarketingEmails: boolean;
}

This doesn't really change anything, but it does make it easier to talk about the code.


The main problem here is that the compiler does not realize that the type of the userRules[key] function is correlated to the type of the user[key] argument in such a way that the latter is always an acceptable input for the former. Let's see what it thinks, by expanding it out into individual variables:

(key: keyof IBaseSignupRequest) => {
  const fn = userRules[key];
  // const fn: ((arg: string) => boolean) | ((arg: string) => boolean) |
  //  ((arg: string) => boolean) | ((arg: boolean) => boolean)
  const arg = user[key]
  // const arg: string | boolean
  return !fn(arg) // error!      
  // Argument of type 'string | boolean' is not assignable to parameter of type 'never'
}

So userRules[key] is a union of function types, some of which accept string and some of which accept boolean, and user[key] is a union of value types, some of which are string and some of which are boolean. These types are correct, but they are not specific enough. The compiler treats this code as if it were:

(key1: keyof IBaseSignupRequest, key2: keyof IBaseSignupRequest) => {
  const fn = userRules[key1];
  // const fn: ((arg: string) => boolean) | ((arg: string) => boolean) |
  //  ((arg: string) => boolean) | ((arg: boolean) => boolean)
  const arg = user[key2]
  // const arg: string | boolean
  return !fn(arg) // error!      
  // Argument of type 'string | boolean' is not assignable to parameter of type 'never'
}

The types are exactly the same here. key1 and key2 are both of the same type as key. And now the compiler's warning should hopefully make more sense. You can't pick some random function from userRules and give it some random value from user. What if you called userRules["login"](user["agreePrivacy"])? The only way you could call a random function from userRules safely is if you passed it a value that would work no matter what. That turns out to be the intersection of the parameter types for the functions, as described in the release notes for TS3.3 where calling unions of functions were first supported. The intersection string & boolean is reduced to never, which means the compiler thinks it is impossible to safely call userRules[key] no matter what. 😢

But of course, fn and arg aren't random elements of userRules and user; they weren't indexed by key1 and key2 of the same type but possibly different values; they were indexed by key, the same variable.
The compiler doesn't track variable identity, though, only types. So it doesn't see that fn and arg are correlated: While separately they are of union types, together the pair [fn, arg] is of a type like [(arg: string) => void, string] | [(arg: boolean) => void, boolean].

A while ago I opened microsoft/TypeScript#30581 about the general lack of support for safely supporting such correlated union types. Until TypeScript 4.6, there was no type-safe solution without writing redundant code.


With TypeScript 4.6, the fix at microsoft/TypeScript#47109 allows people to write code like your filter() callback in a type safe way, as long as they're willing to refactor into a form the compiler can follow. It looks like this:

First, you should express IUserRules in a way that depends explicitly on IBaseSignupRequest:

type IUserRules = {
  [P in keyof IBaseSignupRequest]: (arg: IBaseSignupRequest[P]) => boolean
}

Even though this is structurally equivalent to the previous version, now the compiler "expects" a correlation between IUserRules and IBaseSignupRequest.

And next, you should express the callback function as being generic in K extends keyof IBaseSignupRequest. When you do this, everything starts working:

<K extends keyof IBaseSignupRequest>(key: K) => {
  const fn = userRules[key];
  // const fn: IUserRules[K]
  const arg = user[key]
  // const arg:  IBaseSignupRequest[K]
  return !fn(arg) // okay
}

As you can see, both fn and arg stay generic in terms of K, and the compiler knows that the type of fn, IUserRules[K] is identical to (arg: IBaseSignupRequest[K]) => void. And so fn will accept a value of the type of arg.

So that works. You can of course collapse it back to a simple arrow function with a returned expression:

<K extends keyof IBaseSignupRequest>(key: K) => !userRules[key](user[key]))
  

Playground link to code

Upvotes: 1

sno2
sno2

Reputation: 4183

This is a really interesting issue. It seems that TypeScript does not allow calling the function if any of the parameter types do not match at the same places to prevent unsafe code. A minimal reproduction of this behavior is the following code:

declare const myFunc: ((a: boolean) => void) | ((b: string) => void);

myFunc(true); // Argument of type 'boolean' is not assignable to parameter of type 'never'.

I am fairly sure the reason for this is because when TypeScript type checks parameters, it does the & to join the types. However, when it tries to join the boolean & string, you will get never because those primitive types are impossible to have at the same time. Overall, I think you're probably just best with typing the parameter with any. However, I have came up with a partial solution by creating a new function type from a union of function types that has a rest parameter as a type union of all of the original function parameters and then a union for the return type of all of the functions. Sadly, though, you lose type association on the return type by doing this as the parameter list is no longer joined with the return type. Here's the code:

type UnsafeFuncUnion<T extends (...args: any[]) => any> = (...args: Parameters<T>) => ReturnType<T>;

declare const myFunc: UnsafeFuncUnion<((a: boolean) => void) | ((b: string) => string)>;

// no errors -- note the return type is `string | void`
myFunc(true);
myFunc("Hello");

And here is your code adjusted to use this method:

type UnsafeFuncUnion<T extends (...args: any[]) => any> = (...args: Parameters<T>) => ReturnType<T>;

interface ISignupRequest {
  login: string;
  email: string;
  password: string;
  agreePrivacy: boolean;
  sendMarketingEmails: boolean;
}

interface IUserRules {
  login: (login: string) => boolean;
  password: (password: string) => boolean;
  email: (email: string) => boolean;
  agreePrivacy: (agree: boolean) => boolean;
}

const userRules: IUserRules = {
  login: (login) => !!login.length,
  password: (password) => !!password.length,
  email: (email) => !!email.length,
  agreePrivacy: (agree) => agree,
};

const validateUser = (
  user: Omit<ISignupRequest, "sendMarketingEmails">,
): Array<any> =>
  (Object.keys(user) as Array<keyof typeof user>)
    .filter((key) => !(userRules[key] as UnsafeFuncUnion<IUserRules[keyof IUserRules]>)(user[key]))
    .map((key) => ({
      path: key,
      message: `${key} is invalid`,
    }));

Upvotes: 0

Related Questions