Why your Typescript compiles when you thought you were safe
These are not the types you are looking for
TypeScript's compiler will let you write code that looks illegal - but compiles just fine.
This is the story of one such piece of code, and the epiphany it led me to: TypeScript doesn't use your type definitions to decide if a type is compatible, it uses the JavaScript that could represent that type.
Let's walk through what that means.
The code
I'm writing code to make defining GraphQL resolvers a type safe experience (earlier developer feedback for the win). You don't need to know the details of GQL to follow this example though; all you need to know is that I have a type for defining the configuration of a resolver, and once certain information is supplied, I know the config is valid.
Let's have a look at some code:
type ConfigValid = "valid" | "invalid" class Config<T extends ConfigValid> { private myConfig: string | null = null private constructor() {} public static make(): Config<"invalid"> { return new Config<"invalid">() } // I want this function to only accept valid configurations, and I want to // check if they are valid *at compile time* public static build(config: Config<"valid">) { // do stuff! console.log(config.myConfig) } }
Pop quiz: Config.make
always returns a Config<"invalid">
, and Config.build
only takes a Config<"valid">
. Will the code Config.build(Config.make())
compile or not?
Given I'm asking, you've probably guessed that it does compile, breaking both my intuition... and my code.
Why?
What actually is TypeScript anyway?
Don't get whiplash, I'm going somewhere with this.
What is TypeScript?
Let's hit the TypeScript website. It starts with "TypeScript is JavaScript with syntax for types", and then continues with "TypeScript is a strongly typed programming language that builds on JavaScript, giving you better tooling at any scale".
To my way of thinking, that first quote looks accurate. The second is a lie.
Okay, okay: "strongly typed" has "no precise technical definition" so you can argue that it's half true; I wouldn't agree, given the code above, but you can argue it. But what I'm really calling a lie is the statement that TypeScript is a programming language.
I would instead argue that TypeScript is an inline theorem prover for JavaScript. Because anything that does something in your code is really JavaScript - after all, TypeScript compiles to JavaScript, and all your lovely types are erased. While all of the TypeScript in your code (anything that isn't valid JavaScript) is just there trying to prove that your code is correct.
TypeScript has been designed to make demonstrating correctness as easy as possible when dealing with existing (untyped) JavaScript. (Hint: as easy as possible doesn't mean easy...)
Erm... what's this got to do with the code above?
We're not there yet. Stage 2 in our journey is structural typing.
Most strongly typed programming languages use "nominal" typing. Roughly, it's the "name" of the type that matters and if you give two types two different names (not aliases, actual different names), the compiler will keep track of which one you use where and treat them as different things - even if they hold exactly the same data.
So in, for example, F#, the following two types are not the same, and a function that accepts one will not accept the other:
type FirstRecord = { name: string } type SecondRecord = { name: string } let withFirstRecord (record: FirstRecord) = record.name
Trying to send a record of SecondRecord
to withFirstRecord
would be a compile time error. Now, in F# there's an alternative; the function below will accept any type with a member called name
:
let inline withName arg = (^a : (member name : string) arg)
Notice a few things here:
- That syntax is horrific; turns out this is a deliberate choice to discourage overuse (see the rest of the bullets for why)
- I had to add the
inline
keyword to get it to compile. This literally means that in each place this method is used, the compiler will inline a version that uses the type inferred in that location in the code base. This can be good or bad. - You can probably imagine that the error messages from this type of code become explosively unreadable if you nest several layers of functions using this technique, and the constraints start to grow. F# can no longer tell you "you need to give my a
FirstRecord
"; instead it has to resort to "here's a list of constraints, find me something that meets them all."
This is structural typing, checking types based on the type of data that they hold. And here we wrap back around to TypeScript, which always uses structural typing.
The question is: what are we comparing to see if things are structurally compatible? And this is where my intuition was broken.
In F#, we're comparing the type definition to the constraints. But in TypeScript, we're comparing the JavaScript representation of the type to the constraints because TypeScript exists to make JavaScript safer, not to be a programming language in its own right.
The mystery resolved
Back to our code:
type ConfigValid = "valid" | "invalid" class Config<T extends ConfigValid> { private myConfig: string | null = null private constructor() {} public static make(): Config<"invalid"> { return new Config<"invalid">() } // I want this function to only accept valid configurations, and I want to // check if they are valid *at compile time* public static build(config: Config<"valid">) { // do stuff! console.log(config.myConfig) } }
What is the difference between the JavaScript representations of Config<"invalid">
and Config<"valid">
?
Answer: nothing.
The generic parameter on the type is not used or stored at runtime (i.e. in JavaScript) on Config
, and therefore it gets completely erased when we compile to JavaScript. Suddenly, it becomes no surprise that the compiler is perfectly happy to allow the use of Config<"invalid">
anywhere we specify Config<"valid">
- by TypeScript's standards they are structurally equivalent.
But: the safety?!
Okay, so the code above doesn't work. But now we know what the problem actually is, so... let's fix it!
type ConfigValid = "valid" | "invalid" class Config<T extends ConfigValid> { private myConfig: string | null = null private _isValid: T private constructor(isValid: T) { this._isValid = isValid } public static make(): Config<"invalid"> { return new Config<"invalid">("invalid") } // I want this function to only accept valid configurations, and I want to // check if they are valid *at compile time* public static build(config: Config<"valid">) { // do stuff! console.log(config.myConfig) } }
You see that _isValid
field? Now we're storing a value in that field, and that value will exist at runtime in the compiled JavaScript. Now TypeScript cares about it, and now we can call Config.build
safe in the knowledge it will only take a valid configuration instance.
That's a wrap
I hope you've enjoyed this little journey into making making illegal states unrepresentable, and if you think you could enjoy this kind of thing (or even using the results to just build stuff!) I'm currently working with Blissfully and we're currently hiring (it says backend developers, but we're also hiring for our Elm frontend where making illegal states unrepresentable is even easier...).
If you feel a burning need to comment on this post, or suggest a correction, you can submit suggestions for changes (GitHub account required). Just hit the "edit this file button" and go from there.
With special thanks to Matthew Griffith and Aaron White for reading, pushing for and suggesting a stronger title and introductory paragraph.