Could TypeScript benefit from built-in runtime type checks?
Published on

TypeScript is an amazing language, it's like JavaScript on steroids. You have the best of the 2 worlds: the flexibility of JavaScript and static type checking. This makes it feel almost like a real professional language, such as C#.
However, the language has a weakness: lack of mechanisms for runtime type checks.
What type checks I'm talking about?
I personally don't insist on adding global mandatory dynamic type check flag since it would probably bring a lot
of
performance overheads and a substantial complexity for the compiler, even if the compiler could optimize the
code
by skipping type checks as much as possible. But I would
like to have one particular
feature: instanceof SomeInterface
. Yes, checking if the value is an "instance" of some interface.
Idiomatic "instanceof SomeInterface"?
Many TypeScript codebases heavily utilize interfaces, especially for data structures (models, DTOs, etc). The problem with interfaces is that during runtime you can't idiomatically and easily check if some value is an instance of some interface. Such thing is possible, for example, in PHP, C# and Dart. This is somewhat understandable, because interfaces are not classes, they are just structure definitions that are only used and validated during the compile time:
interface SomeInterface {
prop1: string;
prop2: string;
}
const a: SomeInterface = {
prop1: "First",
prop2: "Second",
}
// Will not compile at all because 'SomeInterface' only refers to a type, but is being used as a value here
if (a instanceof SomeInterface) {
// do something
}
However, even in vanilla JavaScript you can have an idiomatic
way to validate the object structure by overriding the default behavior of the operator
instanceof
:
class User {
name = "";
age = 0;
static [Symbol.hasInstance](instance) {
return typeof instance?.name === "string" && typeof instance?.age === "number";
}
}
const incompleteUser = {
name: "Bob",
};
const completeUser = {
name: "Bob",
age: 20,
};
console.log(null instanceof User); // false, we also handle null with ?. operator
console.log(incompleteUser instanceof User); // false, because age is undefined
console.log(completeUser instanceof User); // true, despite that it's not a real instance of User
So, my suggestion is that when the TypeScript compiler generates the JS code it can generate class
SomeInterface
with static method [Symbol.hasInstance](instance)
that will have the
logic for the structure check. If the interface is a generic it can generate a separate class for each used
combination of generic params. So every idiomatic TS instanceof
check will be an idiomatic JS
instanceof
check. The class SomeInterface
can still be prohibited to be used as a
class
and can still be exposed only as an interface in the Typescript code.
I understand the downsides of this approach, it will be slow for huge or / and deeply structured interfaces. But hey, it's not the same story as Records & Tuples, since it will not affect the code that doesn't use this feature. Also it can be confusing when debugging the code.
What about optional global runtime type checks?
This one seems a much taller order than the instanceof
check. As I stated, I'm not insisting on
that
but this feature will bring the language to a new level. For example, Dart language prevents any illegal type
castings while supporting both static and dynamic typing.
That being said, I'll not get surprised if this one will be rejected. It has several issues:
- Performance overheads, as mentioned earlier.
-
Runtime checks are more complicated than one might think.
Just an example:
class A { } class B extends A { constructor () { super(); this.prop1 = ''; } prop1: string; } const arrayOfBs: B[] = []; function pushA(arr: A[]) { arr.push(new A); } // This will push an object of type of A to an array of Bs, // no compilation error will occur. // You can now imagine how complicated the type // checks would be when working with type-specialized objects. pushA(arrayOfBs);
- The generated JS code will not map well with the TS code. Also JS's most existing APIs are
designed to work with dynamic types. This means we have to either patch those APIs, generate specialized
functions or do the type checks before
calling the API.
Patching might be impossible in some cases and can reduce the performance. Also TypeScript would have to
keep up with the new features in browsers and NodeJS in order to maintain the patches.
Generating specialized functions will increase the bundle size.
Checking before calling the API is shown to be problematic in the last example of successfully pushing an
object of type
of
A
to an array ofB
s. None of these options seem worth pursuing.
Conclusion
TypeScript could benefit from limited runtime type checks mechanisms, such as
instanceof SomeInterface
. Global
runtime checks seem impractical due to the way JavaScript is designed.
However, despite the mentioned shortcomings, TypeScript remains one of the best languages for large and complex frontend/backend projects.