Skip to content

Instantly share code, notes, and snippets.

@blemoine

blemoine/blog.md Secret

Created December 27, 2017 11:23
Show Gist options
  • Save blemoine/b7a879b9199702a3d7af15421fcd25f4 to your computer and use it in GitHub Desktop.
Save blemoine/b7a879b9199702a3d7af15421fcd25f4 to your computer and use it in GitHub Desktop.

Why sometimes TypeScript doesn't type check extra properties?

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.

What is structural typing?

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.

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?

What can be done?

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 classes 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.

A look at Flow

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.

Conclusion

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:

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