Skip to content

Instantly share code, notes, and snippets.

Show Gist options
  • Save aztack/22aed2efcd8d58c3c28b7446224c66ee to your computer and use it in GitHub Desktop.
Save aztack/22aed2efcd8d58c3c28b7446224c66ee to your computer and use it in GitHub Desktop.
Distributive Conditional Types & Co-variant, Contro-variant

https://www.typescriptlang.org/docs/handbook/advanced-types.html#type-inference-in-conditional-types

Type inference in conditional types Within the extends clause of a conditional type, it is now possible to have infer declarations that introduce a type variable to be inferred. Such inferred type variables may be referenced in the true branch of the conditional type. It is possible to have multiple infer locations for the same type variable.

For example, the following extracts the return type of a function type:

type ReturnType<T> = T extends (...args: any[]) => infer R ? R : any;

Conditional types can be nested to form a sequence of pattern matches that are evaluated in order:

type Unpacked<T> = T extends (infer U)[]
  ? U
  : T extends (...args: any[]) => infer U
  ? U
  : T extends Promise<infer U>
  ? U
  : T;

type T0 = Unpacked<string>;
     
type T0 = string
type T1 = Unpacked<string[]>;
     
type T1 = string
type T2 = Unpacked<() => string>;
     
type T2 = string
type T3 = Unpacked<Promise<string>>;
     
type T3 = string
type T4 = Unpacked<Promise<string>[]>;
     
type T4 = Promise<string>
type T5 = Unpacked<Unpacked<Promise<string>[]>>;
     
type T5 = string

The following example demonstrates how multiple candidates for the same type variable in co-variant positions causes a union type to be inferred:

type Foo<T> = T extends { a: infer U; b: infer U } ? U : never;

type T1 = Foo<{ a: string; b: string }>;
     
type T1 = string
type T2 = Foo<{ a: string; b: number }>;
     
type T2 = string | number

Likewise, multiple candidates for the same type variable in contra-variant positions causes an intersection type to be inferred:

type Bar<T> = T extends { a: (x: infer U) => void; b: (x: infer U) => void }
  ? U
  : never;

type T1 = Bar<{ a: (x: string) => void; b: (x: string) => void }>;
     
type T1 = string
type T2 = Bar<{ a: (x: string) => void; b: (x: number) => void }>;
     
type T2 = never

When inferring from a type with multiple call signatures (such as the type of an overloaded function), inferences are made from the last signature (which, presumably, is the most permissive catch-all case). It is not possible to perform overload resolution based on a list of argument types.

declare function foo(x: string): number;
declare function foo(x: number): string;
declare function foo(x: string | number): string | number;

type T1 = ReturnType<typeof foo>;
     
type T1 = string | number

It is not possible to use infer declarations in constraint clauses for regular type parameters:

type ReturnedType<T extends (...args: any[]) => infer R> = R;

'infer' declarations are only permitted in the 'extends' clause of a conditional type. Cannot find name 'R'.

type AnyFunction = (...args: any[]) => any;
type ReturnType<T extends AnyFunction> = T extends (...args: any[]) => infer R
  ? R
  : any;
// https://www.typescriptlang.org/docs/handbook/advanced-types.html#distributive-conditional-types
type KEYS = keyof any; // KEYS = string | number | symbol
type IS_KEY1<K> = K extends KEYS ? 'true' : 'false'; // distributive
type IS_KEY2<K> = [K] extends [KEYS] ? 'true' : 'false'; // subset of
type IS_KEY<K> = IS_KEY1<K>;
type TABLE = [
[IS_KEY<string>, IS_KEY<number>, IS_KEY<symbol>],
[IS_KEY<string|number>, IS_KEY<string|symbol>, IS_KEY<number|symbol>],
[IS_KEY<any>, IS_KEY<unknown>, IS_KEY<never>],
[IS_KEY<KEYS|boolean>]
];
type ArrayOf1<T> = T[];
type NumberOrString_Array = ArrayOf1<number|string>;
const ns1: NumberOrString_Array = ['hello', 42];
type ArrayOf2<T> = T extends any ? T[] : never;
type NumberArray_Or_StringArray = ArrayOf2<number|string>;
const ns2: NumberArray_Or_StringArray = ['a','b'];
const ns3: NumberArray_Or_StringArray = [1,2,3];
type EXTENDS<T1, T2> = T1 extends T2 ? true : false;
type SUBSET<T1, T2> = [T1] extends [T2] ? true : false;
type X = [
EXTENDS<any, any>, // true
EXTENDS<never, never>, // never
EXTENDS<unknown, unknown>, // true
EXTENDS<any, never>, // boolean
EXTENDS<never, any>, // never,never distribute into nothing
EXTENDS<any, unknown>,// true
EXTENDS<unknown, any>,// true
EXTENDS<never, unknown>, // never, never distribute into nothing
EXTENDS<unknown, never> // false
];
// never = empty set
// unknonw = top set
// any = both top-set and bottom-set
type Y = [
SUBSET<any, any>, // true → any, never, unknow is subset of itself
SUBSET<never, never>, // true ↗
SUBSET<unknown, unknown>,// true ↗
SUBSET<any, never>, // false
SUBSET<never, any>, // true, never is subset of any set
SUBSET<any, unknown>, // true ↘ any == unknown
SUBSET<unknown, any>, // true ↗
SUBSET<never, unknown>, // true, never is subset of any set
SUBSET<unknown, never> // false, nothing is subset of never except never itself
];
type UnionToIntersection<U> =
(U extends any ? (k: U) => void : never) extends ((k: infer I) => void) ? I : never
type Ctor<T> = { new(...args: any[]): T};
declare function mixin<T extends Ctor<any>[]>(...traits: T):
Ctor<UnionToIntersection<InstanceType<T[number]>>>
class Flyable {
fly() { }
}
class Walkable {
walk() {}
}
class Mixed extends mixin(Flyable, Walkable) {
test() {
this.fly() // ok
this.walk() // ok
}
}

// https://gist.github.com/bozhanglab49/172b6eb03d9a8580a5faab49288f0dd0

In TypeScript's handbook about inference of Conditional Types, it has this:

Next, for each type variable introduced by an infer (more later) declaration within U collect a set of candidate types by inferring from T to U (using the same inference algorithm as type inference for generic functions). For a given infer type variable V, if any candidates were inferred from co-variant positions, the type inferred for V is a union of those candidates. Otherwise, if any candidates were inferred from contra-variant positions, the type inferred for V is an intersection of those candidates. Otherwise, the type inferred for V is never.

It throws out co-variant and contra-variant positions.

So, what are co-variant and contra-variant positions or in other words, what are co-variance and contra-variance?

Here is the definition but it's a bit verbose, so let me give my two cents here (please correct me):

In a nutshell, co-variance and contra-variance are about the relationship between types with the former being related in same direction while the latter in the opposite. Let's say we have type T and T' related and S and S' related in the same way; S is assignable to T (T = S); if S' is also assignable to T' (T' = S'), then the relationship between T and T' is called co-variance; if T' is assignable to S', then it is contra-variance.

In Java:

List<? extends Number> list = new ArrayList<Integer>();

Here ArrayList is assignable to List in the same direction as Integer is assignable to Number, so it's co-variance.

List<? super Integer> list = new ArrayList<Number>();

Here ArrayList is assignable to List in the opposite direction as Integer is assignable to Number, so it's contra-variance.

In TypeScript:

const numbers: number[] = [1, 2];
const numberOrStrings: Array<number | string> = numbers;

Here number[] is assignable to Array<number | string> in the same direction as number is assignable to number | string, so it's co-variance.

const double1 = (a: number | string): string => typeof a === 'number' ? a + a + '' : a + a;
const double2: (a: number) => string = double1;

Here double1 is assignable to double2 in the opposite direction as number is assignable to number | string, so it's contra-variance.

The takeaway is in TypeScript, functions are types and we are able to assign one function to another. Consequently, function and its parameters create contra-variance which is why TypeScript complains if we switch the parameter types from above example:

const double1 = (a: number): string => a + a + '';
const double2: (a: number | string) => string = double1;
// ERROR: double1 is not assignable to double2.

It should be no sweat to make out why TypeScript gives this error. The subtype function (double1) should be able to handle more broadly than the supertype (double2). Here since double2 accepts string while double1 is not able to handle string, it eliminates type safety TypeScript provides, hence the error.

Now let's go to examples TypeScript provides for the two positions in Conditional Types:

Co-variant

type Foo<T> = T extends { a: infer U; b: infer U } ? U : never;

type T1 = Foo<{ a: string; b: string }>;
//   ^ = type T1 = string
type T2 = Foo<{ a: string; b: number }>;
//   ^ = type T2 = string | number

In order to make { a: string; b: number } assignable to { a: infer U; b: infer U }, we have to make both number and string assignable to U since { a: infer U; b: infer U } is co-variant of U; hence U is inferred as string | number.

Contra-variant

type Bar<T> = T extends { a: (x: infer U) => void; b: (x: infer U) => void }
  ? U
  : never;

type T1 = Bar<{ a: (x: string) => void; b: (x: string) => void }>;
//   ^ = type T1 = string
type T2 = Bar<{ a: (x: string) => void; b: (x: number) => void }>;
//   ^ = type T2 = never

In order to make { a: (x: string) => void; b: (x: number) => void } assignable to { a: (x: infer U) => void; b: (x: infer U) => void }, we have to make U assignable to both number and string since { a: (x: infer U) => void; b: (x: infer U) => void } is contra-variant of U; hence U is inferred as string & number.

Happy Learning :)


image

https://github.com/Microsoft/TypeScript/wiki/FAQ#why-are-function-parameters-bivariant

Why are function parameters bivariant?

I wrote some code like this and expected an error:

function trainDog(d: Dog) { ... }
function cloneAnimal(source: Animal, done: (result: Animal) => void): void { ... }
let c = new Cat();

// Runtime error here occurs because we end up invoking 'trainDog' with a 'Cat'
cloneAnimal(c, trainDog);
```typescript

This is an unsoundness resulting from the lack of explicit covariant/contravariant annotations in the type system. Because of this omission, TypeScript must be more permissive when asked whether (x: Dog) => void is assignable to (x: Animal) => void.

To understand why, consider two questions: Is Dog[] a subtype of Animal[]? Should Dog[] be a subtype of Animal[] in TypeScript?

The second question (should Dog[] be a subtype of Animal[]?) is easier to analyze. What if the answer was "no"?

function checkIfAnimalsAreAwake(arr: Animal[]) { ... }

let myPets: Dog[] = [spot, fido];

// Error? Can't substitute Dog[] for Animal[]?
checkIfAnimalsAreAwake(myPets);

This would be incredibly annoying. The code here is 100% correct provided that checkIfAnimalsAreAwake doesn't modify the array. There's not a good reason to reject this program on the basis that Dog[] can't be used in place of Animal[] - clearly a group of Dogs is a group of Animals here

Back to the first question. When the type system decides whether or not Dog[] is a subtype of Animal[], it does the following computation (written here as if the compiler took no optimizations), among many others:

  • Is Dog[] assignable to Animal[]?
  • Is each member of Dog[] assignable to Animal[]?
    • Is Dog[].push assignable to Animal[].push?
      • Is the type (x: Dog) => number assignable to (x: Animal) => number?
        • Is the first parameter type in (x: Dog) => number assignable to or from first parameter type in (x: Animal) => number?
          • Is Dog assignable to or from Animal?
            • Yes.

As you can see here, the type system must ask "Is the type (x: Dog) => number assignable to (x: Animal) => number?", which is the same question the type system needed to ask for the original question. If TypeScript forced contravariance on parameters (requiring Animal being assignable to Dog), then Dog[] would not be assignable to Animal[].

In summary, in the TypeScript type system, the question of whether a more-specific-type-accepting function should be assignable to a function accepting a less-specific type provides a prerequisite answer to whether an array of that more specific type should be assignable to an array of a less specific type. Having the latter not be the case would not be an acceptable type system in the vast majority of cases, so we have to take a correctness trade-off for the specific case of function argument types.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment