Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

How to undestand relations between types any, unknown, {} and between them and other types?

Trying to understand relations between types I have this code

type CheckIfExtends<A, B> = A extends B ? true : false;

type T1 = CheckIfExtends<number, unknown>; //true
type T2 = CheckIfExtends<number, {}>; //true
type T3 = CheckIfExtends<number, any>; //true
type T4 = CheckIfExtends<() => void, unknown>; //true
type T5 = CheckIfExtends<() => void, {}>; //true
type T6 = CheckIfExtends<() => void, any>; //true
type T7 = CheckIfExtends<unknown, any>; //true
type T8 = CheckIfExtends<any, unknown>; //true
type T9 = CheckIfExtends<{}, unknown>; //true
type T10 = CheckIfExtends<{}, any>; //true
type T11 = CheckIfExtends<any, {}>; //boolean
type T12 = CheckIfExtends<unknown, {}>; //false

Link to playground

Could someone explain this? What difference? How is it possible that any extends {} and any does not extend {} at the same time? If any extends unknown and unknown extends any then does it mean they are strongly equal? Is it a new flaw of Typescript on top of null and undefinded equity issue of JavaScript?

Actually,

type T = CheckIfExtends<any, number>; //boolean
like image 724
Andrei Kovalev Avatar asked Dec 02 '19 08:12

Andrei Kovalev


2 Answers

The distinction is essentially this:

  • the any type is deliberately unsound, in that it is assignable both to and from any other type (with the possible exception of never, depending on where you're using it). Unsoundness means that some basic rules for types are broken, such as transitivity of subtyping. Generally, if A is assignable to B, and B is assignable to C, then A is assignable to C. But any breaks this. For example: string is assignable to any, and any is assignable to number... but string is not assignable to number. This particular unsoundness is very useful, because it allows us to essentially "turn off" type checking in a part of code which is either hard or impossible to type correctly. But you need to be very careful thinking of any as a type; it's more of an "un-type".

  • the empty type, {}, is a type that can be treated like an object at runtime (that is, something you can read properties or methods from without a runtime error), but it has no known properties at compile time. That doesn't mean it has no properties; it just means that the compiler doesn't know about any of them. This implies that only null and undefined are not assignable to {} (null.foo or undefined.foo are runtime errors). Even primitive types like string can be treated as having properties and methods at runtime ("".length and "".toUpperCase() work, and even "".foo just returns undefined). And of course any actual object type will also be assignale to {}.

    On the other hand, the {} type is not assignable to very many types. If I have value of type {} as try to assign it to a variable of type {foo: string}, there will be a compiler error, as {} is not known to contain a foo property. You can assign {} to itself, or to a wider type like unknown, or to the "un-type" any.

    This makes {} very nearly a top type, which is a type to which all other types are assignable. It's essentially a top type with null and undefined removed from it.

  • the unknown type was introduced in TypeScript 3.0 and is the true top type; every type in TypeScript is assignable to unknown. Even null and undefined are assignable to unknown.

    Again, on the other hand, unknown is only assignable to itself and the "un-type" any. Even the {} type isn't wide enough for you to assign unknown to it. Conceptually you should be able to assign unknown to the union type {} | null | undefined, but this is intentionally not implemented to keep unknown as the "true" top type.


Most of your CheckIfExtends<A, B> results can be explained by the above. The exception is T11:

type T11 = CheckIfExtends<any, {}>; //boolean

Your CheckIfExtends<A, B> type definition is a distributive conditional type, which does some interesting things when A is a union type, in that it allows both branches of the conditional to be taken if the pieces of the union satisfy both branches. It also does the same distribution when A is any, except when B is any or unknown (so T8 behaves normaly). There's some discussion of this in microsoft/TypeScript#27418. Anyway, T11 takes both branches and you get true | false which is boolean. (From microsoft/TypeScript#27418, unknown in the A position does not distribute, so T7 and T12 behave normally as well).


Okay, hope that helps; good luck!

like image 169
jcalz Avatar answered Sep 30 '22 15:09

jcalz


I will start from what means extends in TypeScript. On the first look on it, it behaves strange, as for product types (like objects) behaves like 'is superset' and for unions as 'is subset', also totally differently it works for function types. It can look strange at first, but it is logical behavior, in other words most types have soundness property.

My rule of thumb to understand this concept is to read extends as assignable to. Then if x extends y, it means that x can be used whenever y is required.

Lets consider three different algebraic data types, if for them above holds true.

For product type

type A = {a: string}
type B = {a: string; b: number}
type BextendsA = B extends A ? true : false // evaluates to true 

Above is true because B can be used in every places where A is required, as B covers the whole structure A has. B is a superset of A. But what holds here is B is assignable to A.

For union type

type A = number | string
type B = number
type BextendsA = B extends A ? true : false // evaluates to true 

Totally differently it looks for union. B for product was a superset, for union B is a subset! Yes, different logic, B does not represent all values possible in A, but whenever A is required B can be used instead. So B is assignable to A.

For function types

type A = (a: number) => void
type B = () => void
type BextendsA = B extends A ? true : false // evaluates to true 

For function types, it looks even more weird as A looks like more specified function then B, so how B can extends A then? This goes from the assignability again, as whenever something needs A, we can assign B. This is very visible in example like Array.map. Consider:

[1,2].map(x => x + 1)

Array.map requires function which has three arguments - (el,index,arr) => any but can work with a function which has only one argument el => any. And again it holds the assignability, B is assignable to A.


Types like any, unknown, {} are unsound, it means that their behavior cannot be proven logically. Understanding how they behave in TS is more just understanding the specification and reasons about such decisions. But it cannot be logically explained, as unsound type behave against logic.

The places where TypeScript allows unsound behavior were carefully considered, and throughout this document we’ll explain where these happen and the motivating scenarios behind them.

like image 43
Maciej Sikora Avatar answered Sep 30 '22 14:09

Maciej Sikora