Why does TypeScript Sometimes Fails to Type Check Extra Properties?

Benoit Lemoine
4 min readJan 3, 2018

All the code of this article has been tested with typescript 2.6.1

TypeScript compilation can sometimes yield unexpected results. Take the following code snippet, for example:

Why does userList compile and not user? It's not a bug: it has to do with structural typing, the paradigm TypeScript's type system relies on.

What is structural typing?

Most mainstream static typed languages (Java, C#, Scala, etc.) use a type system based on nominal typing — meaning that if two identical types are defined with different names, the type checker will return an error if you try to assign a variable of the first type to a variable of the second type. For example in java:

Nominal typing makes the relation between types explicit; a subtype must declare its parent class or interface:

TypeScript is using structural typing instead of nominal typing. It means that the compiler doesn’t care what name is given to the type, and will only check that type structures are compatible. The previous example, written in TypeScript would become:

In the above example, from the compiler’s point of view, User and User2 are the same thing, they’re aliases of { name: string }. Types are entirely defined by their attributes.

An important implication of that is that subtypes do not have to declare their parent type. For example, type UserWithAge = { name: string, age: number } is a subtype of type User = { name: string }, becauseUserWithAge has all the properties of User, and additional ones.

Because UserWithAge is a subtype of User, it should be logical that we can always assign an UserWithAge variable to another User variable:

Then why won’tconst user2: User = { name: 'Georges', age: 2 } 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 don’t want to have extra properties. From my personal experience, I would agree with that. So they added a specific check in the compiler for this case.

It even works for fully optional types, the so-called weak type:

So we now understand why const user:User = { name:'Georges', age: 37 } doesn't compile. But then, WHY does const userList: Array<User> = [1,2,3].map((age) => ({name:'Georges', age: age})) type check? It should be the same, we're declaring explicitly an object of type User with an extra property, thus it shouldn’t compile, right?

It’s due to the way the type inference of TypeScript works. In the expression above, TypeScript will try to determine whether 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 itself depends on the type of the function (age) => ({name:'Georges', age: age}). [1,2,3] is an Array<number> so, in the above callback, age is a number. The return type is {name:string, age:number} and NOT {name:string} AKA User.

So [1,2,3].map((age) => ({name:'Georges', age: age})) type checks just fine and returns an Array<{name: string, age:number}> which is compatible with Array<User>.

Another interesting point, is that even writing [1,2,3].map<User>((age) => ({name:'Georges', age: age})) won't change that the inner callback is typed as (_: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 tries to infer types. A way to work around that is to explicitly type the expression: const users: Array<User> = [1,2,3].map((age): User => ({name:'Georges', age: age})) will then fail to compile, because then TypeScript knows that the callback MUST return a User.

Another way is to use classes with private properties, because TypeScript states that two private properties, even structurally equals, can never be compatible:

Beware of the fact that if you’re using explicit subtype, it will compile fine:

In my opinion, the two above workarounds are far too much verbose, and I think the best way to solve the issue is… to ignore it. It’s not a problem, in fact, if we go back to our initial code:

The object with the extra property can answer correctly to any call we could have done to User, so it's a valid User, and there is no real type problem tied to having this extra property around: we can ignore it safely.

A look at Flow

Flow is a type checker for JavaScript. Flow and TypeScript’s syntaxes are very similar. A notable difference between the two is that Flow lacks extra properties check by default.

But Flow offers sealed types, which are types that cannot have extra properties:

It also mean that any structural subtype of a sealed type is NOT compatible with its parent:

TypeScript would probably benefit to implement something similar.

Conclusion

Understanding TypeScript’s structural type system is not always easy, but most of the time, if TypeScript says that something is correct it’s because it is — Cases of unsoundness do exist, but remain scarce. Don’t fight with the compiler: let it guide you in your development instead. For those interested in TypeScript’s type system, I encourage you to read:

--

--

Benoit Lemoine

I’m a full-stack developer, in love with functional programming and type systems. I’m working currently at Decathlon Canada, in Montreal QC.