Structural Typing in TypeScript
TypeScript is fast-rising language with a strict type system. In strictly-typed programming one of the fundamental differentiators between languages is whether it is structurally or nominally typed. Typescript is (for the most part) a structurally typed language — and that distinction has significant implications for developers.
In structurally-typed languages, values are considered to be of equivalent types if all of their component features are of the same type. In this TypeScript example you can see that a variable declared as the Person type is assignable to a variable of the Employee type without any casting, coercion, or even knowing ahead of time that both of these types, as named, existed at all. That last point really cuts to the heart of what is going on here. The names of these types (“Person” and “Employee”) were irrelevant to the compiler because fundamentally they are just aliases for the exact same type. The structure is the type.
Like many other languages, TypeScript adds a wrinkle on top of this by allowing subtypes to be considered equivalent enough to the declared base type to use them for variable assignment and for function parameters. In TypeScript’s structural-type system a type is considered to be a subtype if its component features are equal-to or a superset of those specified by the base type. No explicit inheritance or extension is required.
You can imagine the sort of power and flexibility this gives us as developers. Functions can represent different sets of “behaviors” that we want to enable for data that matches only a certain shape. And complex pieces of data can be put together and recombined in an ad-hoc manner and yet still, if it matches those shapes, we can safely use that behavior. This allows us to write code today that can be compatible with data structures we have not even thought of yet.
This flexibility makes apparent one of the biggest weaknesses of structural typing: What happens when the structure alone is not enough to guarantee correctness? We all intuitively know that despite being brown and having 4 legs a horse is not a suitable replacement for a dinner table. If all we care about are the types and arrangement of the properties we need, how can we be sure we are excluding horses from our dining rooms?
One approach might be to add runtime checks to ensure that the data adheres to more rigorous conditions than our types can specify. While this might get us the desired program behavior, it abandons the promise of compile-time correctness we thought our type system was getting us in the first place. It also introduces an entirely new class of runtime “error” cases that we would otherwise not have to support.
A second approach might be to over-specify the structure of the data you want in order to weed out the undesirable types of data from being considered valid. Surely you could exclude horses if you ask what kind of wood your expected table is made of, right? Something about this approach just does not sit right. Here we are using what is an otherwise irrelevant feature of the data as a proxy for properly discriminating between two different types. Furthermore, this approach is futile in a case where we do not fully trust the source of our data. How can we be certain our user is not manipulating the structure of otherwise bad data in order to shoehorn it into the rigidity of our over-specification? How can we prevent a trojan horse?
This example may seem a bit contrived, so let us paint a more realistic scenario than horses and tables. Consider a situation where we have a data structure representing a user in our application. If we are able to deem this user “authorized” that unlocks a greater set of behaviors than one deemed “unauthorized”. It might make sense to guard each of these privileged behaviors by performing the check internally to their implementation. However, consider the case where we need to exercise many of these behaviors and the authorization check is not especially performant. What we really want is a way to specify in the type system that these behaviors require a user that has already passed the authorization check. This way we can authorize once and use whatever behaviors we need. This cannot be something as simple as expecting a field that says authorized: true
— anyone can come up with a data structure that says that. We want something that tells us with certainty that this specific piece of data was authorized by our specific check.
What we are really looking for is the ability to introduce runtime checks that can act as “gatekeepers” to certain types. We want to be able to write functions that say “Rearrange your data however you like, I will not accept your parameter unless it has been validated by the function of my choosing.”
The truth of the matter is that using structural typing alone cannot get us all the way there. But that does not mean we are out of options! In my next post I will explore using some of the more advanced features of TypeScript in order to get more of the safety you would see in a nominally typed language while retaining the power and flexibility we granted by the structural type system.