Type checking with TypeRunner
Matt Angelosanto
Posted on February 28, 2023
Written by Christian Kozalla✏️
TypeRunner is a high-performance TypeScript compiler that enables type checking without the need for tsc
or a JavaScript engine at all. It speeds up type checking immensely by compiling TypeScript source code to bytecode and running it in a custom virtual machine.
In this way, TypeRunner makes the TypeScript language powerful for declaring type information and then uses the definitions to perform type checking in other languages - totally independent of Node.js, Deno, or a JavaScript engine. TypeRunner enables the portability of type information written in TypeScript to other environments.
At the time of writing, TypeRunner is merely a proof-of-concept and supports only a few basic type expressions (i.e., primitives, object literals, generic function types, template literals, type aliases, arrays, tuples, intersection, union, and rest parameters). Interfaces or classes are not yet supported. The package’s author, Marc J. Schmidt, advises in the repo that development will continue when there is sufficient funding from the community.
In this article, we’ll dive into the concepts behind TypeRunner, demonstrate using TypeRunner to type check a simple project and compare its capabilities to that of Deno and tsc
). We’ll also take a look at an advanced feature and its current limitations.
Let’s get started!
Jump ahead:
- Understanding the performance issue
- Introducing TypeRunner
- Use cases for TypeRunner
- TypeRunner in action
- Handling complex types
- Current limitations
- Advantages of TypeRunner
Understanding the performance issue
Over the past few years, widely adopted web-development tooling written in JavaScript has gotten some stiff competition from tools written in more performant languages like Go and Rust. Some prominent examples are esbuild, written in Go, and swc or Turbopack, both of which are written in Rust.
Now, with tsc
running JavaScript for type checking under the hood, developers working on large code bases experience delayed feedback from the compiler while editing code.
Imagine changing the type of a function parameter and waiting several seconds for the red squiggly error lines to show up! That's not quite the DX we're looking for!
Due to low performance of the TypeScript language server in large code bases, companies and developers are searching for a solution. A developer recently expressed this need very clearly in a GitHub issue on the TypeRunner repo:
“I would pay for, and I'm sure thousands of developers would likewise pay for, a faster VS Code language server even if it didn't have 100% parity with TypeScript.”
Introducing TypeRunner
The types defined in a TypeScript project assist the developer during development but are erased before runtime. Yet, types provide value to software at runtime, too.
For example, TypeScript interfaces could be used for request/response validation of HTTP endpoints. Or, suppose we define a holistic data model with TypeScript interfaces and declare these types as a single source of truth for many different consumers, such as microservices or frontend apps.
With TypeRunner, consumers of the data model defined in TypeScript need not necessarily be written in TypeScript. TypeRunner can add type checking independent of the language because it does not rely on tsc
or a JavaScript engine.
TypeRunner extracts the type information of TypeScript code and compiles it to bytecode, which is executed in a custom virtual machine for type checking.
TypeRunner was designed to meet two primary goals:
- Make type information of TypeScript available in other languages
- Improve the speed of type checking
N.B., TypeRunner doesn't perform type checking at runtime, but its author is also the developer of Deepkit Type a library that makes types available at runtime in order to deserialize JSON into classes (and vice-versa), validate objects against interfaces or write type-guards based on interfaces. If you're looking for runtime usage of TypeScript's type information, I highly recommend you check it out
Portability of types
Data models are often declared in a domain-specific language (DSL), often with a narrow set of use cases and limited tooling. Services based on the same data model must validate their code and features against the types and relations declared in the model. APIs of different services need to obey certain contracts in order to work properly together. Robust systems must recover from errors at runtime due to invalid data.
Now, suppose we were able to declare a data model of a system with TypeScript interfaces, types, and their relations toward each other. Well, we can do that as of today! But in order to use that information we must always rely on JavaScript and the TypeScript compiler. After all, that type of information could be useful for services that are written in a completely different language but are dependent on the same data model.
However, with TypeRunner, the declared TypeScript data model could be compiled to bytecode, ported anywhere, and used for type checking via a CLI or as a library (for example, in a CI/CD pipeline).
Use cases for TypeRunner
There are several real world use cases for TypeRunner. Here are a few:
- Type checking in non-JavaScript environments
- Replacing DSLs used for data modeling with TypeScript interfaces and types
- Improving type checking performance
TypeRunner in action
As of this writing, TypeRunner only supports a subset of TypeScript's type system, but we can still put it to good use!
To demonstrate TypeRunner in action, let’s start by defining a simple model of user data.
Before building TypeRunner, we’ll first define the model.
This is because the Docker container, where we'll see TypeRunner perform the type checking, does not provide a terminal-based text editor.
The model (in this case, our TypeScript source code) will be added to the container as a volume.
Cloning the TypeRunner repo
The TypeRunner repo contains the C++ source code and instructions to build a Docker image. As a heads up, you’ll need to clone the repo recursively, which will only work on the command line if you’ve set up GitHub SSH keys:
git clone git@github.com:marcj/TypeRunner.git
cd TypeRunner
git submodule update --init --recursive
Building the Docker image
If you haven't installed it on your system, you can get Docker here.
We'll build TypeRunner into a Docker image and run one-off containers with our TypeScript source files mounted in order to do type checking:
docker build -t typerunner .
Here we're tagging the image, typerunner,
which we’ll refer to when running containers off the image.
N.B., if we run the container in interactive mode, we can inspect the test files which Marc J. Schmidt, author of TypeRunner, used to do benchmarking
docker run -it typerunner
cat tests/model.ts
Using TypeRunner for type checking (type checking without tsc
)
TypeRunner allows you to do type checking where no TypeScript compiler, also referred to as tsc
, is available. Once the C++ source code is compiled in our environment set up with Docker, any TypeScript code can be type checked by mounting it into the Docker container.
Let’s create a file, main.ts
, and write a basic HTTP handler in TypeScript. However, we won’t see VS Code or any other text editor that comes inbuilt with the TypeScript language server. Instead, we’ll let TypeRunner do the heavy lifting! ;-)
// main.ts
type Phone = {
country: "US" | "FR" | "UK" | "DE";
phoneArea: number;
phoneNumber: number;
};
type User = {
id: string;
name: string;
age: number;
contact: Phone;
};
const lindasPhone: Phone = {
country: "FS",
phoneArea: 123,
phoneNumber: 4567,
};
const users: User[] = [
{
id: "abc",
name: "Linda",
age: 23,
contact: lindasPhone,
},
{
id: "def",
name: "Wendy",
age: 32,
contact: {
country: "UK",
phoneArea: 123,
phoneNumber: "4567",
},
},
];
This code obviously has some type errors. This is international, so we can see how type checking tools like TypeRunner, Deno, and tsc
can help detect and fix those errors.
I encourage you to experiment with TypeRunner and add some additional type errors in order to gain hands-on experience!
To type check the sample code with TypeRunner, run the following command:
# Type check main.ts with TypeRunner
docker run -v $(pwd)/main.ts:/typerunner/main_test.ts typerunner build/typescript_main main_test.ts
N.B., build/typescript_main
is the TypeRunner executable that performs the type checking; main_test.ts
is how we named our mounted source file main.ts
Now, let’s check our main.ts
file with Deno and compare the output.
Using Deno for type checking
Deno can be used for type checking TypeScript files via the command line:
deno check main.ts
# does the same as
npx tsc --noEmit main.ts
Deno uses tsc
for type checking under the hood, so TypeRunner has identical performance benefits over both Deno and tsc
.
While running deno check main.ts
, Deno finds two type errors in our code and returns a structured overview of the errors denoted by with squiggly lines and line numbers. Essentially, running deno check
is the same as running npx tsc –noEmit
N.B., if type checking is not explicitly enabled via the --check
flag, Deno will run your source code without type checking, regardless of potential type errors
# No type checking: This only compiles with tsc and runs in Deno
deno run main.ts
# Demand type checking
deno run --check main.ts
Output comparison: TypeRunner vs. Deno and tsc
TypeRunner outputs the results from type checking to stdout
, so you’ll see the red squiggly lines (indicating errors) right in the terminal:
# [.... other logs ….]
main_test.ts:188:199 - error TS0000: Type '{"country": "FS""phoneArea": 123"phoneNumber": 4567}' is not assignable to type '{"country": "US" | "FR" | "UK" | "DE""phoneArea": number"phoneNumber": number}'
»const lindasPhone: Phone =
» ~~~~~~~~~~~
main_test.ts:277:282 - error TS0000: Type '[{"id": "abc""name": "Linda""age": 23"contact": {"country": "US" | "FR" | "UK" | "DE""phoneArea": number"phoneNumber": number}}, {"id": "def""name": "Wendy""age": 32"contact": {"country": "UK""phoneArea": 123"phoneNumber": "4567"}}]' is not assignable to type 'Array<{"id": string"name": string"age": number"contact": {"country": "US" | "FR" | "UK" | "DE""phoneArea": number"phoneNumber": number}}>'
»const users: User[] = [ // no squiggly lines from Deno/tsc
» ~~~~~
Found 2 errors in main_test.ts
For comparison, here’s the output of type checking with Deno (tsc
):
# Output from Deno / tsc
error: TS2322 [ERROR]: Type '"FS"' is not assignable to type '"US" | "FR" | "UK" | "DE"'.
country: "FS",
~~~~~~~
at file:///home/christian/projects/typerunner-demo-app/main.ts:15:3
The expected type comes from property 'country' which is declared here on type 'Phone'
country: "US" | "FR" | "UK" | "DE";
~~~~~~~
at file:///home/christian/projects/typerunner-demo-app/main.ts:2:3
TS2322 [ERROR]: Type 'string' is not assignable to type 'number'.
phoneNumber: "4567", // no squiggly lines from TypeRunner
~~~~~~~~~~~
at file:///home/christian/projects/typerunner-demo-app/main.ts:34:7
The expected type comes from property 'phoneNumber' which is declared here on type 'Phone'
phoneNumber: number;
~~~~~~~~~~~
at file:///home/christian/projects/typerunner-demo-app/main.ts:4:3
Found 2 errors.
The major differences are the formatting and the location of the squiggly lines (indicating errors) in the code.
For example, look at our array of users. It has a wrong type in the second object, nested in the contact.phoneNumber
property. Deno flags this type error right where the incorrect type (it’s a string instead of a number) is set, whereas TypeRunner marks the users
array as faulty.
Handling complex types
The TypeRunner README shows off some complex types that TypeRunner is able to check:
type StringToNum<T extends string, A extends 0[] = []> = `${A['length']}` extends T ? A['length'] : StringToNum<T, [...A, 0]>;
const var1: StringToNum<'999'> = 999;
This particular StringToNum
type might not be practically relevant, but it nicely demonstrates how complex types can be constructed and type checked by TypeRunner.
Current limitations
While playing around with TypeRunner, I stumbled across some current limitations relating to expressions in the source code that TypeRunner cannot handle at the time of writing.
Support for import statements
TypeRunner does not currently support handling import statements, so we cannot use the HTTP handler and server from the Deno standard library, as I had intended:
// TypeRunner cannot process import statements at the moment
import { serve } from "https://deno.land/std@0.146.0/http/server.ts";
import { type Handler } from "https://deno.land/std@0.146.0/http/server.ts";
Usage of Deno's global namespace
It’s not currently possible to spin up an HTTP server from the Deno global namespace, Deno
, with TypeRunner:
// TypeRunner cannot process references to the Deno global namespace
const conn = Deno.listen({ port: 80 });
// TypeRunner's output
// terminate called after throwing an instance of 'std::runtime_error'
// what(): Property access to Never not supported
Advantages of TypeRunner
TypeRunner offers huge performance benefits over tools like tsc
, because it compiles TypeScript to bytecode and processes it in a custom virtual machine and is written in C++.
As a statically typed, general-purpose compiled language, C++ provides performance gains over interpreted languages like JavaScript. C++ performance is on par with younger compiled languages like Rust. The author of TypeRunner explicitly choose C++ over Rust out of personal preference:
“Why C++ and not Rust? Because I know C++ much better than Rust. The market for good C++ developers is much bigger. TypeScript code also maps surprisingly well to C++, so porting the scanner, parser, and AST structure is actually rather easy, which allows back-porting features from TypeScript tsc to TypeRunner much easier. I also find Rust ugly.”
TypeRunner improves performance further by efficiently caching the parsed AST and the compiled bytecode (i.e., a warm run). If you have a large project consisting of several hundreds of files and only one or two change between each type checking run, then only the files with changes need to go through all three stages of TypeRunner processing:
- Parsing the AST
- Compiling to bytecode
- Executing the bytecode in a custom VM
Conclusion
In this article, we explored the concepts, features, benefits, and limitations of TypeRunner. We also demonstrated its use in action and compared its performance to Deno and tsc
.
At the time of writing, the TypeRunner project is merely a proof-of-concept, so it lacks a streamlined user experience and does not support every aspect of the TypeScript language.
I’m betting on future contributions from the open source community to enhance TypeRunner’s usability, so build on top of it and get the word out! I can’t wait to see TypeRunner gain traction and see its adoption skyrocket!
Conclusion
In this post, we explored how we can use generics, and created reusable functions inside our codebase. We implemented generics to create a function, class, interface, method, multiple interfaces, and default generics.
LogRocket: Full visibility into your web and mobile apps
LogRocket is a frontend application monitoring solution that lets you replay problems as if they happened in your own browser. Instead of guessing why errors happen, or asking users for screenshots and log dumps, LogRocket lets you replay the session to quickly understand what went wrong. It works perfectly with any app, regardless of framework, and has plugins to log additional context from Redux, Vuex, and @ngrx/store.
In addition to logging Redux actions and state, LogRocket records console logs, JavaScript errors, stacktraces, network requests/responses with headers + bodies, browser metadata, and custom logs. It also instruments the DOM to record the HTML and CSS on the page, recreating pixel-perfect videos of even the most complex single-page and mobile apps.
Posted on February 28, 2023
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.
Related
November 25, 2024