All the code of this article has been tested with typescript 2.6.1
TypeScript can sometimes be suprising on why something compile, and why something else don't. For example
https://gist.github.com/f983fd57c63addba9850949781b090b3
Why does userList
compile and not user
? It's not a bug, and it has to do with structural typing, the paradigm used by TypeScript's type system.
Most of the static typed languages (Java, C#, Scala, etc.) used nowadays are using a type system based on nominal typing. It means that if 2 identical types are defined with different names, the type checker will refuse to compile if you try to put a variable of the first type in a variable of the second type. For example in java:
https://gist.github.com/92a2b24d1950ae05387127e32b5c41de
Nominal typing makes explicit the relation between types; a subtype must declare the parent class
or interface
:
https://gist.github.com/3e59055741641f7d8ba5dd65485665a3
TypeScript is using structural typing instead of nominal typing. It means that the compiler doesn't care for the name given to the type, and will only check that structure of types are compatible. The previous example in TypeScript would be:
https://gist.github.com/3279cecdc02a0b92d25ad34efc65e592
In previous example, from the compiler point of view, User
and User2
are the same thing, they are alias of {name: string}
. Types are entirely defined by their attributes.
An important implication of that is that subtypes doesn't have to declare their parent type. For example,
type AgedUser = { name: string, age: number }
IS a subtype of type User = { name: string }
, AgedUser
has all the properties of User
, plus some others).
Because AgedUser
is a subtype of User
, it should be logical that we can always put an AgedUser
variable in another User
variable:
https://gist.github.com/fbac24e3f8f76d7ffff3491bf2e65c3b
Then why const u2: User = { name: 'Georges', age: 2 }
doesn't compile even though the code is obviously correct? What's the difference between this line and the two above?
It's time to introduce excess property checking.
The TypeScript team made the hypothesis that when you're declaring explicitly an object of a specific type, you probably wouldn't want to have extra properties. My personal experience would agree with that. So they added a specific check in compiler for this case.
It even works for fully optional types, the so-called weak type:
https://gist.github.com/cd41ed629dac36c9d8b172df19c568d8
So we now understand why const u:User = { name:'Georges', age: 37 }
doesn't compile.
But then, WHY is const users: Array<User> = [1,2,3].map((age) => ({name:'Georges', age: age}))
type checking? It should be the same, we're declaring explicitly an object of type User
with an extra property. It shouldn't work, right?
It's due to the way the type inference of TypeScript works. In the expression above, TypeScript will try to know if the result of the map
expression is compatible with Array<User>
. To do that, it needs to compute the type of [1,2,3].map((age) => ({name:'Georges', age: age}))
which himself depends of the type of the function (age) => ({name:'Georges', age: age})
. [1,2,3]
is an Array<number>
so in the callback above, age
is a number
. The return type is {name:string, age:number}
and NOT {name:string}
aka User
. So this is type checking just fine and we're obtaining an Array<{name: string, age:number}>
which is compatible with Array<User>
, so there is no problem.
Another interesting point, is that even writing [1,2,3].map<User>((age) => ({name:'Georges', age: age}))
doesn't change that the inner callback is typed as (age:number) => {name:string, age:number}
.
So, what can we do against this behavior?
As we've seen previously, this behavior arise from the way TypeScript try to infer types. A way to workaround that is to explicitly type the expression:
const users: Array<User> = [1,2,3].map((age): User => ({name:'Georges', age: age}))
won't compile, because then TypeScript knows that the callback MUST return a User
.
Another way to workaround is to use class
es with private properties, because TypeScript think that 2 private properties, even structurally equals, can never be compatible:
https://gist.github.com/a2e3b1e93e804e4cea7764fc46ad39f3
Beware that if you're using explicit subtype, it will compile fine:
https://gist.github.com/fccc30ea35cb9db4b8ca6bb545329943
In my opinion, the two above workarounds are far too much verbose, and I think the best way to workaround the problem is... to ignore it. It's not a problem in fact, if we go back to our initial code:
https://gist.github.com/543f0c92f0e144329b889ffe072ce212
The object with the extra property can answer correctly to any call we could have done to User
, so it's valid User
, and there is no real type problem to have this extra property around.
Flow is a type checker for JavaScript. The syntax of Flow and TypeScript are very similar. One of the difference between Flow and TypeScript is the extra properties check, which doesn't exist by default in Flow.
For example: https://gist.github.com/7845dd8c83e19339260027737750d116
But Flow offers sealed types, which are types that cannot have extra properties:
https://gist.github.com/840dd60d7c3e2a593d2b74023b7f3ec5
It also mean that any structural subtype of a sealed type is NOT compatible with its parent:
https://gist.github.com/a49456d8cb89eafdc36d18fbec042740
TypeScript would probably benefit to have something similar.
Understanding the structural type system of TypeScript is not always easy, but most of the time, if TypeScript say that something is valid it's because it is (there is some cases of unsoundness though, but they are very rare). Don't fight with compiler, and let it guide you in your development instead. For those interested in the TypeScript type system, I invite you to read:
- the documentation, obviously, and in particular the advanced types page;
- the FAQ, which is surprisingly rich with information.