TypeScript Contravariance: Conditionals, Inference, and the Application of Generics
1 A type question
There is a test
function called which accepts two parameters. The first parameter is the function fn
, and the second parameter is limited options
by fn
the parameter. At first glance, this question doesn’t seem complicated, right? Isn’t this a common need when doing business?
"Create a generic type Test
to 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 options
is constrained to fn
have a generic fn
parameter type of .
- If
fn
there are no parameters, thentest
there cannot be a second parameteroptions
. - If
fn
there is one parameterp
,test
there must be a second parameteroptions
. - If
fn
the argumentp
to is optional, the second argumentoptions
is also optional. options
Is a generic typeOptions<T>
,T
the type is the type offn
the parameterp
.
After observing the first three rules, we initially derived a test
function 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 test
the 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 fn
call the type of the parameter FN
, options
the type of the parameter Opts
.
type Test = <T>(...arg: Args<T>) => unknown
First of all, T
it 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 fn
with 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 SomeType
add to [FN, Opts]
. Remember the fourth rule above? Small Opts
is a generic, a FN
generic with the same parameters.
In conditional type expressions, infer
keywords are used to declare a type variable to be inferred and used extends
in 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 P
to declare a type variable P
, which is used to describe fn
the 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 Parameters
types.
Parameters<T>
Type accepts a function type T
and 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
, 1
or, 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 typeP
isT[0]
the parameter type of the function . - Through
Parameters<T[0]>
, we can getT[0]
the parameter type tuple of the function. - By judgment
[Parameters<T[0]>['length']] extends[1]
, we get that the functionT[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 test
further 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 test
function, we accept a parameter array args
, which contains the function fn
and options
parameters. 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
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 Test
further transformation of .
This revamp will further simplify Args
the 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 toOptions<P>
, that is, a tuple of type withP
type , 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 Test
type, which is a higher-order function type that accepts a function T
as 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 fn
and its parameters to call Test
the 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 any
and unknown
to specify the generic T
as a function type with arbitrary parameters. Universal types should be avoided any
because they bypass type checking and reduce type safety. However, here, we cannot substitute any
that unknown
type 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, unknown
is 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
fn
there are no parameters,options
it is optional, but there is noinjector
field. - When
fn
there are parameters and the parameters are required,options.injector
they are also required, andinjector
the return type of isfn
the parameter type of . - When
fn
there are parameters but the parameters are optional,options
they are optionalinjector
and optional, and a string is returned. options
There may be other attributes, but what they are is not explicitly specified. Therefore, we can assume that the other attributes have only oneweight
attribute.
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 Args
have made some branch modifications to the generic tool types:
- If
fn
the parameter list is empty, i.e.T extends []
, the remaining parameter list is defined as aOtherOpts
tuple of optional type,[OtherOpts?]
i.e. - If
fn
the parameter list has only one elementP
, ieT extends [infer P]
. We convert the parameter type toOptions<P>
specifyoptions.injector
that the return type isfn
the parameter typeP
. - For other cases, we define the entire argument list as a
Options<string>
tuple of type optional ,[Options<string>?]
i.e.
Test
Higher-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>?]