Hey, article's author here! Happy to answer any questions, or poke at this general problem with anyone who is interested. Understanding the type checker and its performance is my current personal focus and I find it helpful to bat around ideas with others.
Hey thanks so much for writing this up. This is a great post! I've only had time to skim the article, so my apologies if you covered this and I missed it: have you investigated whether specifying expression/function return type affects performance? I work on something of a large codebase, and I wonder if whether we annotated our returns, type checking would be faster.
Yeah! Making explicit return types, especially of public functions, is a good practice to follow. I'd say the main reason isn't performance, though, but rather to ensure you have a stable public API for your module.
How much it actually speeds up the type checker depends on how hard it is for the type checker to infer the return type. And that depends on the return expression, but I don't think there is a single hard and fast rule here. But, if you already have a named type for the return value of the expression, I would absolutely annotate it explicitly when possible. Sometimes the inferred type won't be really the type you intend, and there might just be a more clear type you want to use for communication/documentation purposes.
We accidentally had a regression slip into our TS once that made it take over 7 seconds to typecheck a file, and that was surprisingly painful to diagnose. It meant our CI builds were slower, our local builds were slower, and the language server (in VS code, sublime, etc) would just randomly go unresponsive while editing. If there were tooling to track deltas in that per-file we would have noticed it immediately.
We had little strings that encoded call signatures, like "iiff" for int int float float. Someone cooked up a way to have typescript validate the strings at build time, but the way it does typechecks against string literals like that seems to cause every string literal in your entire compilation to get validated in advance to figure out whether it could possibly be that specific type. Each possible value - let's say we had 'f', 'i', and 'l' originally and then we added a fourth option for 'd' - caused the typecheck time to magnify by increasing the possible options. IIRC.
Sometimes I wish something akin to Dart (but probably not Dart) had taken off instead of the TypeScript approach. I.e. a JS based language that broke a few things to get types but largely ran on the same VM and could still easily be transpiled in the meantime. Avoid the whole "separate syntax on top of the way the underlying syntax behaves" set of logic.
I suppose WASM enables layered languages like AssemblyScript comes close in many ways but it's also a bit too separated from the primary webpage use case.
The TypeScript approach has given us one of the most interesting programming languages to appear in recent times, a language that is completely committed to structural typing.
The Dart approach just gave us yet another unimaginative nominally-typed language.
TypeScript is complex, but it’s also incredibly cool if you’re into compilers. Engineers do their best work when there are limitations imposed, in this case the need to add types to JS.
I learned typescript for a project I spent 9-10 months on. It wasn't that hard to learn but... ugh. On many levels. There are problems where it's typechecking is helpful (enough) for, but the degradation in readability was quite noticeable, and the fact that its abstractions only work at compile time was an endless thorn in the side.
It's a shame that Reason fragmented into Reason and ReScript. I remember there being a ton of excitement around the project when Jordan Walke (creator of React) announced that he was working on a JS successor.
excellent article!
my approach is to to break down larger bits into smaller monorepo packages with turbo repo where each package builds itself and the task graph is managed by turbo.
the drawback is that watching across local packages doesn’t work out of the box.
These days, TypeScript is effectively nothing more than a high-powered linter. The performance of this linter is so bad that we need to structure our code in a specific way so that we can still afford to run the linter.
Of the performance tips at the end, the interface vs. intersection type one is the suggestion I find the most annoying. That’s because it’s the most common pattern, and using interfaces is conceptually a lot less clean. It’s terrible that a linter effectively forces you into writing worse code.
I really wish the TypeScript team got their act together and fixed the performance of their linter somehow. Finding clever optimizations, porting to Go/Rust, whatever is necessary. (3rd-party reimplementations won’t do: they’ll never catch up with a corporate-funded moving target.)
> These days, TypeScript is effectively nothing more than a high-powered linter.
That's a bad take - typescript enables tooling like refactoring/navigation/completion that goes far above a linter. Development tools are just better with typescript vs JS.
I was refuting the part about typescript (language and the implementation/language server) being a glorified lint - the value proposition is way higher than that.
Even other dynamic languages started moving in this direction due to the benefits of having type annotations brings to tooling/development experience overall.
I never understood this. It takes maybe 10 minutes to set up TypeScript in a project if you’ve done it before.
Compared to all the other times involved (time to learn typescript, time saved debugging and writing tests for type errors) the setup time is a total non-issue.
There is a new type checker called Ezno that is written in Rust and is a lot faster [1].
I have been tracking PRs like [2] that change the definitions to better be optimised by V8. But the effects are only ~30% and not the 50x that might be achievable by native.
How fast does tsc process that input though? I would be very surprised if you can get to 50x faster - that's Python territory and JavaScript isn't that slow. 10x maybe?
Complaining that you have to tune it for performance is like complaining that your runtime code isn't automatically maximally performant without a little tuning.
Complicated types are effectively little Prolog programs, doing a bunch of very useful and helpful checks to make sure that your code does what you expect it to.
I do wish that Typescript would offer some tools to make it more ergonomic to write performant unifying code (I kind of despise conditional types, especially when you then use it to create the partially valid types by resolving to never). But I think it would also be very helpful to get people to understand that your types are their own little program that run and have performance characteristics. It's not magic!
There's been some handwaving about performance not being due to it running in JS (because at the end of the day unification is unification is unification and it takes time), but looking at the Typescript codebase in general and poking at it, I can't help but wonder how much of even the heavier stuff is "death by 1000 cuts" on that front.
This really was solidified by going through the course at https://type-level-typescript.com since it involves learning the type-level language of TypeScript and solve little puzzles. Doesn't really address performance much, but I think having a working-level understanding of what the type-checker is doing when it's "solving" your TypeScript type-level programs is an important prerequisite for having some intuition about type checker performance.
I use fp.ts quite a lot! I think it's _extremely unfortunate_ that the API docs don't include top-level examples for basically anything, though. So when people hit a problem that fp-ts or io-ts can solve, I have to basically write a disclaimer about the slight impenetrability before suggesting it.
I really think that TS itself should offer syntax more or less matching what that lib does at the type level, but this is a bit of a maximalist request.
It’s been a while since I looked deeply at them, but IIRC the primary difference is that io-ts returns Result types. It’s trivial to wrap that to produce a value or throw if that feels more comfortable.
It’s possible there are other aspects of the APIs that differ in meaningful ways, but last I checked virtually all of the libraries with similar functionality (and there are many) have roughly the same concepts until your schemas themselves get deeply complex.
I’ve worked on several TS projects that don’t type check but still “compile” (emit non-TS JavaScript). To me that’s the difference between a linter and a compiler, and I wish those projects had stopped compiling when they could no longer type check.
You can use something like JSDoc and achieve basically the same thing, but it's very likely that your developer experience will be way worse as you sort of point out. If you're a VSC enjoyer your tooling will be absolutely horrible compared to the Typescript tooling. We use Typescript as our general JavaScript "language" but most of our internal libraries are written in actual JavaScript for performance reasons. They key difference is those libraries are worked on by far fewer people.
My gut reactions would be to still do whatever performance-related weirdness was absolutely required in a Typescript codebase and either alter the .tsconfig to allow for that project's required style, or to explicitly ts-ignore and type cast the output of the hand-tuned performance code while still maintaining type checking surrounding it so I could still easily produce typedefs for consumers. Even keeping TSC as a type checker while dropping it as a compiler would have been on my list of options before eschewing TSC entirely.
I'm not here to challenge your decisions, but there's a real dearth of information on this topic and knowing when something applies to your situation is hard. Someone writing a web server has different problems and concerns than someone writing React or Vue websites, or someone processing IO on a microcontroller. Can you go into some details about your situational how and why? I'm curious to hear more about the nature of these pure-JS-for-performance libs and what was measured as non-performant in the TSC output?
You're correct in most scenarios but TSC can be an unwieldy beast when you deal with large amounts of unstructured data. We work with inverter data from solar plants and let's just say that the engineers who build these things aren't using any sort of data standards. TSC falls short (for us) on things like reflection and type transformation. Which means that we have to actually check if TSC does it the way we want it. Maybe you could configure TSC to always do it the performant way, but then you'll have to work with that, and probably still check if it actually worked. For some things we use C (and a few Rust) services with a local cache of data, but there is so much data that we can't do this for everything. In the perfect world, we would handle this on-site (as the inverter manuals will also tell you to do) but that's not what the bosses have prioritized. We've tried getting some standard solutions, that have so far all come up short. As a result we wasted so much money that we could've had a full team of engineers put up an ARM device to deal with data on every solar inverter and still had literal millions of Euro left over... but corporate will corporate.
It's not like we aren't still doing TS. We're achieving this through the use of JSDoc (and possibly ECMAScript type annotations in the future). Though a little non-performance related added advantage is that when our developers click on "go to implementation", they'll get to the actual code. It has advantages and disadvantages as I said. You'll really, really, need to know the inner workings of JS to outdo the big transpilers, and you're not getting a lot of help working with it (especially not if you're using VSC). I'm not sure you would want to do this in JS at all for the most part, it's just that we are primarily TS developers. I'm the only one who knows C well enough to use it reliably, as an example. Which isn't exactly "maintainable" for the business.
So as I said in at the beginning, I think your gut reaction is correct 99% of the time, but it is an alternative the GP I was replying to asked for.
With WASM starting to become a thing we are no longer limited to just JavaScript and things that compile to or transpile to JavaScript.
It’s early days there but with the JS ecosystem being the mess that it is I’m actively interested in finding alternatives to at least evaluate.
One approach I’m enjoying so far is Dart which has two relevant compilers (I.e Dart to JS and Dart to WASM) but they have the advantage that you can just use Dart like normal which is a clear 10x improvement over writing either JS or TS and you only have to worry about the specific layers where you need to interop with JS code and you can wrap that up in really nice ways.
Doesn’t the type checker have to run in JavaScript to fit into VS code? A C# implementation would be much faster, no need to go native with C++/Rust (not sure speed in Go would really compete).
The big issue is dealing with a structural highly expressive type system, the language of implementation is only going to be a constant slow down (but that constant can be large).
It depends on which typescript toolset you use, but generally speaking you're probably riding on mix of C++ and Javascript if you're using VSC. VSC itself uses C++ in it's core components since that is how electron works. Similarly the language server and tooling for both Typescript and Node are build with C++. If you're fancy and use Bun you're running on Zig. Eslint itself runs on Javascript, but the parser it uses feeds it something called an abstract syntax tree, and different parsers will do this differently.
So the relatively simple answer would be no, it would not be faster with C# (or Go which would likely have a similar speed to C#).
C# should be faster than JavaScript, at least, given all the optimization Microsoft puts into the CLR. But it might not be super portable. I’ve never seen the typescript type checker before, but I wouldn’t be surprised if it were in typescript, which is common as these things go.
Hey, article's author here! Happy to answer any questions, or poke at this general problem with anyone who is interested. Understanding the type checker and its performance is my current personal focus and I find it helpful to bat around ideas with others.
Hey thanks so much for writing this up. This is a great post! I've only had time to skim the article, so my apologies if you covered this and I missed it: have you investigated whether specifying expression/function return type affects performance? I work on something of a large codebase, and I wonder if whether we annotated our returns, type checking would be faster.
Yeah! Making explicit return types, especially of public functions, is a good practice to follow. I'd say the main reason isn't performance, though, but rather to ensure you have a stable public API for your module.
How much it actually speeds up the type checker depends on how hard it is for the type checker to infer the return type. And that depends on the return expression, but I don't think there is a single hard and fast rule here. But, if you already have a named type for the return value of the expression, I would absolutely annotate it explicitly when possible. Sometimes the inferred type won't be really the type you intend, and there might just be a more clear type you want to use for communication/documentation purposes.
I really wish to find ts tooling that would show me deltas in ts checker memory usage and tie them to diffs in ci
We accidentally had a regression slip into our TS once that made it take over 7 seconds to typecheck a file, and that was surprisingly painful to diagnose. It meant our CI builds were slower, our local builds were slower, and the language server (in VS code, sublime, etc) would just randomly go unresponsive while editing. If there were tooling to track deltas in that per-file we would have noticed it immediately.
Can you share what was the problem, just in case we have it too?
We had little strings that encoded call signatures, like "iiff" for int int float float. Someone cooked up a way to have typescript validate the strings at build time, but the way it does typechecks against string literals like that seems to cause every string literal in your entire compilation to get validated in advance to figure out whether it could possibly be that specific type. Each possible value - let's say we had 'f', 'i', and 'l' originally and then we added a fourth option for 'd' - caused the typecheck time to magnify by increasing the possible options. IIRC.
I'd bet it had to do with complex templates.
I've had typescript break on me when using libraries like elysia that go full send on their templates.
Sometimes I wish something akin to Dart (but probably not Dart) had taken off instead of the TypeScript approach. I.e. a JS based language that broke a few things to get types but largely ran on the same VM and could still easily be transpiled in the meantime. Avoid the whole "separate syntax on top of the way the underlying syntax behaves" set of logic.
I suppose WASM enables layered languages like AssemblyScript comes close in many ways but it's also a bit too separated from the primary webpage use case.
The TypeScript approach has given us one of the most interesting programming languages to appear in recent times, a language that is completely committed to structural typing.
The Dart approach just gave us yet another unimaginative nominally-typed language.
TypeScript is complex, but it’s also incredibly cool if you’re into compilers. Engineers do their best work when there are limitations imposed, in this case the need to add types to JS.
I learned typescript for a project I spent 9-10 months on. It wasn't that hard to learn but... ugh. On many levels. There are problems where it's typechecking is helpful (enough) for, but the degradation in readability was quite noticeable, and the fact that its abstractions only work at compile time was an endless thorn in the side.
Not a fan.
Zod (and similar libraries) solve that last issue: https://zod.dev/
You can, for example, declare your type and check at runtime if some object matches the type.
In an alternate universe ReScript was this.
It's a shame that Reason fragmented into Reason and ReScript. I remember there being a ton of excitement around the project when Jordan Walke (creator of React) announced that he was working on a JS successor.
excellent article! my approach is to to break down larger bits into smaller monorepo packages with turbo repo where each package builds itself and the task graph is managed by turbo. the drawback is that watching across local packages doesn’t work out of the box.
I've come to believe that sufficiently advanced type inference is indistinguishable from an interpreter
...which has the implication that what TypeScript is actually giving us is a REPL. Our code is increasingly "evaluated" by our IDE, in our hover-overs
I think this is a major reason people like TypeScript so much
TypeScript's type system is turing complete. You might find this interesting: https://github.com/type-challenges/type-challenges
These days, TypeScript is effectively nothing more than a high-powered linter. The performance of this linter is so bad that we need to structure our code in a specific way so that we can still afford to run the linter.
Of the performance tips at the end, the interface vs. intersection type one is the suggestion I find the most annoying. That’s because it’s the most common pattern, and using interfaces is conceptually a lot less clean. It’s terrible that a linter effectively forces you into writing worse code.
I really wish the TypeScript team got their act together and fixed the performance of their linter somehow. Finding clever optimizations, porting to Go/Rust, whatever is necessary. (3rd-party reimplementations won’t do: they’ll never catch up with a corporate-funded moving target.)
> These days, TypeScript is effectively nothing more than a high-powered linter.
That's a bad take - typescript enables tooling like refactoring/navigation/completion that goes far above a linter. Development tools are just better with typescript vs JS.
That's ignoring all the negative effects of using typescript.
I was refuting the part about typescript (language and the implementation/language server) being a glorified lint - the value proposition is way higher than that.
Even other dynamic languages started moving in this direction due to the benefits of having type annotations brings to tooling/development experience overall.
The only thing you’ve listed was your preference for readability
True, I did not enumerate them. Adds another step to development and generally adds complexity would be other perceived downsides.
I'll note that most of the upsides are also subjective.
Deno/bun support TS natively, and Node is also adding support.
https://github.com/nodejs/node/pull/53725
> adds another step to development
I never understood this. It takes maybe 10 minutes to set up TypeScript in a project if you’ve done it before.
Compared to all the other times involved (time to learn typescript, time saved debugging and writing tests for type errors) the setup time is a total non-issue.
There is a new type checker called Ezno that is written in Rust and is a lot faster [1].
I have been tracking PRs like [2] that change the definitions to better be optimised by V8. But the effects are only ~30% and not the 50x that might be achievable by native.
[1]: https://github.com/kaleidawave/ezno/actions/runs/10299707325 [2]: https://github.com/microsoft/TypeScript/pull/58928
How fast does tsc process that input though? I would be very surprised if you can get to 50x faster - that's Python territory and JavaScript isn't that slow. 10x maybe?
At this point TS is a turing complete language.
Complaining that you have to tune it for performance is like complaining that your runtime code isn't automatically maximally performant without a little tuning.
TypeScript has been turing complete for a very long time. You might find this interesting: https://github.com/type-challenges/type-challenges
Complicated types are effectively little Prolog programs, doing a bunch of very useful and helpful checks to make sure that your code does what you expect it to.
I do wish that Typescript would offer some tools to make it more ergonomic to write performant unifying code (I kind of despise conditional types, especially when you then use it to create the partially valid types by resolving to never). But I think it would also be very helpful to get people to understand that your types are their own little program that run and have performance characteristics. It's not magic!
There's been some handwaving about performance not being due to it running in JS (because at the end of the day unification is unification is unification and it takes time), but looking at the Typescript codebase in general and poking at it, I can't help but wonder how much of even the heavier stuff is "death by 1000 cuts" on that front.
This really was solidified by going through the course at https://type-level-typescript.com since it involves learning the type-level language of TypeScript and solve little puzzles. Doesn't really address performance much, but I think having a working-level understanding of what the type-checker is doing when it's "solving" your TypeScript type-level programs is an important prerequisite for having some intuition about type checker performance.
> I do wish that Typescript would offer some tools to make it more ergonomic to write performant unifying code (I kind of despise conditional types
Maybe give https://gcanti.github.io/fp-ts/ a go?
I use fp.ts quite a lot! I think it's _extremely unfortunate_ that the API docs don't include top-level examples for basically anything, though. So when people hit a problem that fp-ts or io-ts can solve, I have to basically write a disclaimer about the slight impenetrability before suggesting it.
I really think that TS itself should offer syntax more or less matching what that lib does at the type level, but this is a bit of a maximalist request.
effect-ts is nowadays the de facto successor of fp-ts and has better docs.
As for fp-ts all APIs have examples/tests that show their usage.
Thanks for the effect-ts suggestion! Looks pretty compelling...
IMO Zod is much easier to understand than io-ts, the counterpart to fp-ts: https://zod.dev/
It’s been a while since I looked deeply at them, but IIRC the primary difference is that io-ts returns Result types. It’s trivial to wrap that to produce a value or throw if that feels more comfortable.
It’s possible there are other aspects of the APIs that differ in meaningful ways, but last I checked virtually all of the libraries with similar functionality (and there are many) have roughly the same concepts until your schemas themselves get deeply complex.
Zod can also return result types: T.safeParse(x)
It’s not a very full featured Result, but you can wrap it with your own type.
With such a broad definition of linter, wouldn't the type systems of all statically typed languages just be high-powered linters?
I’ve worked on several TS projects that don’t type check but still “compile” (emit non-TS JavaScript). To me that’s the difference between a linter and a compiler, and I wish those projects had stopped compiling when they could no longer type check.
Good news! There is a configuration option for that: https://www.typescriptlang.org/tsconfig/#noEmitOnError
No, in most static languages, the type system influences code generation.
What's the alternative?
Anything you name has either less support or different cons.
You can use something like JSDoc and achieve basically the same thing, but it's very likely that your developer experience will be way worse as you sort of point out. If you're a VSC enjoyer your tooling will be absolutely horrible compared to the Typescript tooling. We use Typescript as our general JavaScript "language" but most of our internal libraries are written in actual JavaScript for performance reasons. They key difference is those libraries are worked on by far fewer people.
My gut reactions would be to still do whatever performance-related weirdness was absolutely required in a Typescript codebase and either alter the .tsconfig to allow for that project's required style, or to explicitly ts-ignore and type cast the output of the hand-tuned performance code while still maintaining type checking surrounding it so I could still easily produce typedefs for consumers. Even keeping TSC as a type checker while dropping it as a compiler would have been on my list of options before eschewing TSC entirely.
I'm not here to challenge your decisions, but there's a real dearth of information on this topic and knowing when something applies to your situation is hard. Someone writing a web server has different problems and concerns than someone writing React or Vue websites, or someone processing IO on a microcontroller. Can you go into some details about your situational how and why? I'm curious to hear more about the nature of these pure-JS-for-performance libs and what was measured as non-performant in the TSC output?
You're correct in most scenarios but TSC can be an unwieldy beast when you deal with large amounts of unstructured data. We work with inverter data from solar plants and let's just say that the engineers who build these things aren't using any sort of data standards. TSC falls short (for us) on things like reflection and type transformation. Which means that we have to actually check if TSC does it the way we want it. Maybe you could configure TSC to always do it the performant way, but then you'll have to work with that, and probably still check if it actually worked. For some things we use C (and a few Rust) services with a local cache of data, but there is so much data that we can't do this for everything. In the perfect world, we would handle this on-site (as the inverter manuals will also tell you to do) but that's not what the bosses have prioritized. We've tried getting some standard solutions, that have so far all come up short. As a result we wasted so much money that we could've had a full team of engineers put up an ARM device to deal with data on every solar inverter and still had literal millions of Euro left over... but corporate will corporate.
It's not like we aren't still doing TS. We're achieving this through the use of JSDoc (and possibly ECMAScript type annotations in the future). Though a little non-performance related added advantage is that when our developers click on "go to implementation", they'll get to the actual code. It has advantages and disadvantages as I said. You'll really, really, need to know the inner workings of JS to outdo the big transpilers, and you're not getting a lot of help working with it (especially not if you're using VSC). I'm not sure you would want to do this in JS at all for the most part, it's just that we are primarily TS developers. I'm the only one who knows C well enough to use it reliably, as an example. Which isn't exactly "maintainable" for the business.
So as I said in at the beginning, I think your gut reaction is correct 99% of the time, but it is an alternative the GP I was replying to asked for.
Thanks for following up. That helps shed some light on what you"re running into.
With WASM starting to become a thing we are no longer limited to just JavaScript and things that compile to or transpile to JavaScript.
It’s early days there but with the JS ecosystem being the mess that it is I’m actively interested in finding alternatives to at least evaluate.
One approach I’m enjoying so far is Dart which has two relevant compilers (I.e Dart to JS and Dart to WASM) but they have the advantage that you can just use Dart like normal which is a clear 10x improvement over writing either JS or TS and you only have to worry about the specific layers where you need to interop with JS code and you can wrap that up in really nice ways.
For example here’s an example of Dart interacting with browser APIs: https://github.com/dart-lang/web/blob/main/example/example.d...
Technically, it is more than a linter, as enums don't have a direct representation in JS.
If I had to guess working with parsing xml is more complicated than traditional code.
Can’t find the links but one of the reasons I’ve seen people move away from xml has been due to the speed in parsing when compared to json or csv.
Doesn’t the type checker have to run in JavaScript to fit into VS code? A C# implementation would be much faster, no need to go native with C++/Rust (not sure speed in Go would really compete).
The big issue is dealing with a structural highly expressive type system, the language of implementation is only going to be a constant slow down (but that constant can be large).
It depends on which typescript toolset you use, but generally speaking you're probably riding on mix of C++ and Javascript if you're using VSC. VSC itself uses C++ in it's core components since that is how electron works. Similarly the language server and tooling for both Typescript and Node are build with C++. If you're fancy and use Bun you're running on Zig. Eslint itself runs on Javascript, but the parser it uses feeds it something called an abstract syntax tree, and different parsers will do this differently.
So the relatively simple answer would be no, it would not be faster with C# (or Go which would likely have a similar speed to C#).
C# should be faster than JavaScript, at least, given all the optimization Microsoft puts into the CLR. But it might not be super portable. I’ve never seen the typescript type checker before, but I wouldn’t be surprised if it were in typescript, which is common as these things go.
> But it might not be super portable.
.net is available on all systems where developers work with their code.
But the popular tooling for it (e.g. Visual Studio), isn't.
Visual Studio Code is available for Linux and MacOS. As is Rider.
Don't a lot of the linters run externally via language servers? I'm pretty sure the rust linters are rust-native for example.