TypeScript Contravariance: Conditionals, Inference, and the Application of Generics

TypeScript Contravariance: Conditionals, Inference, and the Application of Generics

1 A type question

There is a testfunction called which accepts two parameters. The first parameter is the function fn, and the second parameter is limited optionsby fnthe parameter. At first glance, this question doesn’t seem complicated, right? Isn’t this a common need when doing business?

"Create a generic type Testto ensure that there is a constraint relationship between the two parameters, and that's it. Let's talk about it when I wake up." I thought to myself, and fell asleep again. Only that T extends unknown[]broke into my dream, erratic, If I were to leave, it hinted at my fate of overturning again [I felt it was easy when I read the questions, but my head was as big as an ox when I solved it].

Let’s take a look at the title first:

type InjectorFunction<P> = () => P;

interface Options<P> {
    
    
  injector: InjectorFunction<P>;
}

const fn1 = () => 1;
const fn2 = (p: number) => `number is: ${
      
      p}!`;
const fn3 = (p: string) => `hello ${
      
      p}!`;
const fn4 = (p?: string) => `hello ${
      
      p || 'fn4'}!`;

type Test<F extends (...args: any[]) => any = any> = (fn: F, options?: Options<Parameters<F>>) => void;

const test: Test = (fn, options) => {
    
    
  return fn(options?.injector?.());
}

// 定义 Test 函数的类型,使得下面类型成立
test(fn1);                                  // right
test(fn1, {
    
     injector: () => {
    
    } });          // error, dont need injector
test(fn2, {
    
     injector: () => 4 });           // right
test(fn3, {
    
     injector: () => 'world' });     // right
test(fn3);                                  // error, options.injector is required
test(fn4);                                  // right
test(fn4, {
    
     injector: () => 'test4' });     // right

Before you continue reading, let’s play in the typescript playground , guys. It can also be used to accompany this article.

2 Question rules and solutions

Reading the comments in the code, we can get the following problem description and requirements:

Consider a function test, which takes two parameters. The first parameter must be a function fn, while the second parameter optionsis constrained to fnhave a generic fnparameter type of .

  • If fnthere are no parameters, then testthere cannot be a second parameter options.
  • If fnthere is one parameter p, testthere must be a second parameter options.
  • If fnthe argument pto is optional, the second argument optionsis also optional.
  • optionsIs a generic type Options<T>, Tthe type is the type of fnthe parameter p.

After observing the first three rules, we initially derived a testfunction similar to the following structure, in which the part of inferring the number of parameters needs to be delayed:

type Test = (...arg: unknown[]) => unknown

We know that using generic types or conditional types can help achieve constraint relationships between parameters. In the Test type already defined in the question, type Test<F extends (...args: any[]) => any = any> = (fn: F, options?: Options<Parameters<F>>) => void;options are directly defined as optional, which does not comply with the first and second rules.

We need to create a Args<T>utility type called which is used to dynamically generate testthe parameters of the function. Although we currently use generics to describe these parameters, we can use pseudocode [FN, Opts]to tentatively represent the unfinished implementation. Specifically, we fncall the type of the parameter FN, optionsthe type of the parameter Opts.

type Test = <T>(...arg: Args<T>) => unknown

First of all, Tit must be an array. If it is not an array, then there is no need for it to exist. If it is, let's return an array composed of two parameters first. Now, you can use the nickname you gave me earlier! A little west!

type Args<T> = T extends unknown[] ? [FN, Opts] : never

Secondly, the first parameter must be fn, we need to determine its parameter shape. Let’s start with the simplest one fnwith no parameters.

type Args<T> = T extends unknown[] ?
    T[0] extends () => number ? [() => number]: [FN, Opts] : never

Next, we need to determine T[0]whether is a function with parameters. T[0]Really (arg: SomeType) => unknown? If so, we'll also SomeTypeadd to [FN, Opts]. Remember the fourth rule above? Small Optsis a generic, a FNgeneric with the same parameters.

In conditional type expressions, inferkeywords are used to declare a type variable to be inferred and used extendsin conditional statements. This allows TypeScript to infer the type at a specific location and use it in type determination and conditional branching.

Therefore, we can use this conditional statement T[0] extends (arg: infer P) => string to express T[0]that can be assigned to (arg: infer P) => string. In this conditional statement, we use infer Pto declare a type variable P, which is used to describe fnthe parameter type of and Options<T>the parameter type of the generic.

type Args<T> =
  T extends unknown[] ?
    T[0] extends () => number ? [() => number]:
    T[0] extends (arg: infer P) => string ? [(arg: P) => string, Options<P>] : [FN, Opts]
  : never

At this step, we also need to solve a problem, that is, how to determine whether the parameter is an optional type.

To get the parameters of a function, we can use TypeScript's built-in Parameterstypes.

Parameters<T>Type accepts a function type Tand returns a tuple of parameter types for that function type. By checking Parameters<T>the length and element type of the tuple, we can determine the number and type of parameters and process them accordingly as needed.

type GetParamsNum<T extends (...args: any) => any> = Parameters<T>['length'];

To determine what kind of parameter shape it is, that is, presence, absence, or Schrödinger's presence/absence (that is, the number of parameters can be 0, 1or, or 0 | 1), we can use the following code to distinguish these three situations: , 0, 1.0 | 1

type GetParamShape<T> =
  [T] extends [0] ? "无" :
  [T] extends [1] ? "有" : "薛定谔的有/无"

To sum up, let us further decompose this branch: T[0] extends (arg: infer P) => string, the Args type has been fully expanded, we can get the following conclusion:

  • When T[0]can be assigned to (arg: infer P) => string, we can infer that the parameter type Pis T[0]the parameter type of the function .
  • Through Parameters<T[0]>, we can get T[0]the parameter type tuple of the function.
  • By judgment [Parameters<T[0]>['length']] extends[1], we get that the function T[0]must have a parameter branch, thus returning the expected type [(arg: P) => string, Options<P>].
  • If the condition is not met, the expected type is returned [(arg?: P) => unknown, Options<P>?], arg is optional, and Options are also optional.

The complete definition of the Args type is as follows:

type Args<T> =
  T extends unknown[] ?
  T[0] extends () => number ? [() => number]:
  T[0] extends (arg: infer P) => string ? [Parameters<T[0]>['length']] extends[1] ? [(arg: P) => string, Options<P>] : 
  [(arg?: P) => unknown, Options<P>?]
  : never  : never

Now, based on the previous type Test = <T>(...arg: Args<T>) => unknown, let us testfurther transform the function.

type Test = <T>(...arg: Args<T>) => unknown

const test: Test = (...args) => {
    
    
  const [fn, options] = args
  return fn(options?.injector?.())
}

In this modified testfunction, we accept a parameter array args, which contains the function fnand optionsparameters. We use array destructuring assignment to extract these two parameters.

We have completed the redefinition of the type definition and the transformation of the function, now let's see if we can get the expected type inference and errors.

3 The first rollover

1.png

Every call reported an error. One solution is to specify generic parameters when calling, but this is very troublesome, and not surprisingly, it is disliked by the bosses. Then start Testfurther transformation of .

This revamp will further simplify Argsthe type and make it look more self-explanatory. It accepts a generic parameter T, which is an array type representing the function's parameter list. Depending on the number of parameters, we perform different type conversions:

  • If the argument list is empty, i.e. T extends [], it means the function has no arguments. In this case, test has no other parameters, ie [].
  • If the parameter list has only one element P, i.e. T extends [infer P], it means that the function has only one parameter. We convert the type of the parameter to Options<P>, that is, a tuple of type with Ptype , that is .Options[Options<P>]
  • For other cases, we define the entire parameter list as a Options<string>tuple of type optional , [Options<string>?]i.e.

Finally, we define a Testtype, which is a higher-order function type that accepts a function Tas the first argument, and a tuple type that is converted based on the function argument list Args<Parameters<T>>. This type indicates that the function may have multiple parameter lists, and different conversion types are applied depending on the number of parameters. Now, we can directly pass in the function fnand its parameters to call Testthe function, no longer need to specify the type every time it is called fn.

type Args<T extends unknown[]> =
  T extends [] ? [] :
  T extends [infer P] ? [Options<P>] : [Options<T[0]>?]

type Test = <T extends (...arg: any[]) => unknown>(...args: [T, ...Args<Parameters<T>>]) => unknown

Here we use anyand unknownto specify the generic Tas a function type with arbitrary parameters. Universal types should be avoided anybecause they bypass type checking and reduce type safety. However, here, we cannot substitute anythat unknowntype position affects contravariance and covariance , function parameters are usually in contravariant position, and subtypes (more specific types) cannot be assigned to supertypes (more general types). Instead, unknownis the parent type of all types.

Just look at the square and look forward to sharing other solutions, brothers. Waiting for you to come and play.

4 real rules

  • When fnthere are no parameters, optionsit is optional, but there is no injectorfield.
  • When fnthere are parameters and the parameters are required, options.injectorthey are also required, and injectorthe return type of is fnthe parameter type of .
  • When fnthere are parameters but the parameters are optional, optionsthey are optional injectorand optional, and a string is returned.
  • optionsThere may be other attributes, but what they are is not explicitly specified. Therefore, we can assume that the other attributes have only one weightattribute.

The expected error is as follows:

// 定义 Test 函数的类型,使得下面类型成立
test(fn1);                                  // right
test(fn1, {
    
     weight: 10 });                  // right
test(fn1, {
    
     injector: () => {
    
    } });          // error, dont need injector
test(fn2, {
    
     injector: () => 4 });           // right
test(fn3, {
    
     injector: () => 'world' });     // right
test(fn3);                                  // error, options.injector is required
test(fn3, {
    
     injector: () => 4 });           // error
test(fn4);                                  // right
test(fn4, {
    
     injector: () => 'test4' });     // right
test(fn4, {
    
     injector: () => undefined });   // error

In order to comply with the above rules, we Argshave made some branch modifications to the generic tool types:

  • If fnthe parameter list is empty, i.e. T extends [], the remaining parameter list is defined as a OtherOptstuple of optional type, [OtherOpts?]i.e.
  • If fnthe parameter list has only one element P, ie T extends [infer P]. We convert the parameter type to Options<P>specify options.injectorthat the return type is fnthe parameter type P.
  • For other cases, we define the entire argument list as a Options<string>tuple of type optional , [Options<string>?]i.e.

TestHigher-order function types remain unchanged.

interface OtherOpts  {
    
    
  weight: number;
}

type Args<T extends unknown[]> =
  T extends [] ? [OtherOpts?] :
  T extends [infer P] ? [Options<P>] : [Options<string>?]

Guess you like

Origin blog.csdn.net/weixin_55756734/article/details/132986223