IMO it’s great when libraries are fully typed: it’s like documentation you experience at the moment of use. I think what the author is really dealing with at “when the library types are so difficult to understand and use, I often end up resorting to casting things as any, losing more type safety than I gained” is more the API design being unwieldy rather than the typing itself. You can fully-type a terrible API just as well as a great one and the terrible API will still be a pain to use.
I think they’re talking about something slightly different, and they allude to it by saying the useful complex types on the happy path become less useful when something goes wrong.
What I believe they’re encountering is that type errors—as in mistaken use of APIs, which are otherwise good when used correctly—become harder to understand with such complex types. This is a frequent challenge with TypeScript mapped and conditional types, and it’s absolutely just as likely with good APIs as bad ones. It’s possible to improve on the error case experience, but that requires being aware of/sensitive to the problem, and then knowing how to apply somewhat unintuitive types to address it.
For instance, take this common form of conditional type:
type CollectionValue<T> = T extends Collection<infer U>
? U
: never;
The never case can cause a lot of confusion, especially at a distance where CollectionValue may be invoked indirectly. It can often be a lot easier to understand why a type error occurs by producing another incompatible type in the same position:
type CollectionValue<T> = T extends Collection<infer U>
? U
: 'Expected a Collection';
(I’ve used somewhat simplistic examples, because I’m typing this on my phone. But hopefully the idea is clear enough for this discussion!)
What about the (incredibly unlikely, i'll admit) scenario where somebody attempts to pass the literal 'Expected a Collection' as an instance of this type? What's the best way to insert a warning, but also guarantee the type is unsatisfiable?
It’s very situational. If you can predict the shape of error cases, anything that doesn’t match that shape will do. If you can’t, you can fabricate a nominal type in one way or another (such as the symbol suggestion made by a sibling commenter, or by a class with a private member). The broad strokes solution though is to use a type that:
1. Won’t be assignable to the invalid thing.
2. Conveys some human-meaningful information about what was expected/wrong and what would resolve it.
I haven’t written a complicated library so I’m just guessing, but something as general as a form builder is probably so jam-packed with edge cases and configurability that I’d guess the types need to be that complex in order to be accurate.
I think it’s an inevitable trade off: the safer and more specific you want your type inference to be, the more inscrutable your generics become. The more accurately they describe complicated types, the less value they serve as quick-reference documentation.
Which makes me think the types are not the problem, it’s the lack of quick reference documentation. If a complicated type had a little blurb that said “btw here are 5 example ways you can format these args”, you wouldn’t need to understand the types at first glance. You’d just rely on them for safety and autocomplete
The error messages in TypeScript can be difficult to understand. I often scroll to the very bottom of the error message then read upward line-by-line until I find the exact type mismatch. Even with super complex types, this has never failed me; or at least I can't recall ever being confused by the types in popular libraries like React Hook Form and Tanstack Table.
Another thing I find strange in the article is the following statement.
I often end up resorting to casting things as any [...]
Every TypeScript codebase I have worked with typically includes a linter (Biome or ESLint) where explicit use of `any` is prohibited. Additionally, when reviewing code, I also require the writer to justify their usage of `as` over `satisfies`, since `as` creates soundness holes in the type system.
Lastly, I wish the author had written a bit more about type generation as an alternative. For instance, React Router -- when used as a framework -- automatically generates types in order to implement things like type-safe links. In the React Native world, there is a library called "React Navigation" that can also provide type-safe links without needing to spawn a separate process (and file watcher) that generates type declarations. In my personal experience, I highly prefer the approach of React Navigation, because the LSP server won't have temporary hiccups when data is stale (i.e. the time between regeneration and the LSP server's update cycle).
At the end of the day, the complexity of types stems directly from modelling a highly dynamic language. Opting for "simpler" or "dumber" types doesn't remove this complexity; it just shifts errors from compile-time to runtime. The whole reason I use TypeScript is to avoid doing that.
The examples of bad, overly complex types are indeed unpleasant and unwieldy: colossal, highly nested types with long, cryptic lists of type parameters.
I think this speaks of lack of abstraction, not excess of it.
If your type has 17 type parameters, you likely did not abstract away some part of it that can be efficiently split out. If your type signature has 9 levels of parameter type nesting, you likely forgot to factor out quite a bit of intermediate types which could have their own descriptive names, useful elsewhere.
Unfortunately many languages have poor support for named type definitions and higher order types, unlike e.g. Haskell. That would definitely help to avoid these problems.
TS doesn't support higher order types. You can't return a generic and pass its parameters later. It's basically only a single level of parametrization.
```
type MyGeneric<TParam> = ...;
type HigherOrderGeneric<TParam> = TParam extends string ? MyGeneric : never;
type Hey = HigherOrderGeneric<string><number>;
```
There are libraries that try to achieve this through some hacks, tho their ergonomics are really bad.
Does anybody have a good example of an 'alternative' that handle complex static types more gracefully? The Go language discourages Generics in favor of empty interface which feels similar to what the author is arguing... but I also find myself not always loving Go because of that. (I heavily lean on things like .map() in TS).
Trying to think of alternatives, I can only think of Haskell and C++ which are their own flavors of pain. In both C# and Java I've fallen into Hyper Typing pits (often of my own creation eee).
So what else exists as examples of statically typed languages to pull inspiration from? Elm? Typed Racket? Hax? (Not Scala even though it's neat lol)
Anybody have any tips to explore this domain in more depth? Example libraries that are easy to debug in both the happy and unhappy cases?
I've seen Go codebases where serialization was used as a way to generics... ugly.
The generic code would take a string of the serialized struct, which could be passed through (the code was operating on an outer structure) then be deserialized at the other end, preserving the type information. Maybe it could have been handled by some kind of typecasting plus an enum containing the name of the type (don't remember the specifics of Go right now), but the devs had halfway convinced themselves that the serialization served another purpose.
Lately I’ve been exploring zig and outside of painfully underdeveloped documentation I haven’t had so much fun with types (of comptime) for the long time.
I find it much more ergonomic than Rust and less energy draining than OCaml.
Then there’s pattern matching, but IMO Elixir is heading in the wrong direction. Erlang has accumulated dust over the decades. Clojure is very interesting choice because it can do both „comptime” (i.e. macros) and pattern matching.
Elm's a great example of a language that's fully statically typed, but where the language doesn't result in long complex types.
In my mind Rust is one of the nicest, most ergonomic type systems. People say it's highly complex, but I think that's really because its type system also includes reference and lifetime annotation.
As a culture, I think Rust developers do a great job of designing well for a simpler type signature.
lean4 unifies types and values, so that types feels so natural. you stop thinking types at all as you think about types in rust or csharp. and haskell does not have that feeling too.
Yes. It might sound counter-intuitive or even ironical but the next logical step is to make the type system more, not less expressive and to add completely dependent types. Every language already has them to the extent of Array<T, N> (or T[] & { length: N }, or a recursively defined Tuple<T, N>), but having true flow- and value-dependent types would allow for more concise and expressive code.
Many of the highlighted problems come from TypeScript allowing such complex type definitions in the first place, which in turn stems from JavaScript allowing such an open and untyped (and yet convenient) compositional model.
The expressiveness of JavaScript is a curse upon every library author who (in vain) may try to design an interface with a simpler subset of types, but is undone by the temptation and pressure to use the flexibility of the language beneath.
The author's instincts are right though - target a simpler subset of TypeScript, combine code generation with simpler helper libraries to ease the understandability of the underlying code, and where a simpler JavaScript idiom beckons, use runtime safety checks and simpler types like `unknown`.
Might I add - this isn't limited to languages like TypeScript which simply try and make underlying untyped languages (like JavaScript) safer. It affects well-designed strongly-and-statically-typed-from-the-ground-up languages which have expressive type-constructs (Rust, anything ML-based, etc.) used to reduce repetition and code-generation steps.
This is true to some degree, but something about javascript/TS seem to lead to just incomprehensible typings. Imo any types where you start having to look at the keys of some map is going to lead to pain, but most other languages don't let you do that.
In this context untyped means that it is dynamically typed and doesn't have static type hints. It's a fairly new word but I've seen it used enough and the meaning is obvious enough that I don't think you need to pedantically correct it.
I find that these complex types often reflect the underlying complex code. This usually happens when you interact with something that is explicitly written for javascript and takes advantage of its dynamic nature. (usually the base javascript api)
window.addEventListener(event, callback), dispatchEvent(event) and removeEventListener(callback) is a good example. In a dynamic language, this api is at least unsurprising. It's easy to understand.
In a typed language, although strategies could vary, one would probably not write the api like that if you prefer to have simpler types.
Something like this would make more sense in a typed language:
I love the term "Hyper Typing" and I hope it becomes commonplace. I've long been searching for a phrase with a similar to "premature optimization" (with a similarly mildly-negative connotation) but for overengineered type safety and I think this is it.
The poor support for types in VS Code has always been a blocker for me adopting it vs. Jetbrains, where types and the intellisense are much easier to jump between.
I've felt the same, but I blame error messages and language ergonomics rather than the practice itself. Basically, everything you said, but with optimism that future languages and language implementations make 'hyper typing' a good practice. Recent languages have shown that there's a lot of room for improvement on error messages in complex programs. Hopefully that extends to complex types before too long.
Fully agree. TS shouldn't throw internal types into the user's face. The errors UX is abyssymal.
The fact that the most popular editor - VSC - doesn't preserve line breaks makes reading TS errors even worse. I highly recommend the pretty TS errors extension, otherwise you're only hurting yourself working with TS.
I don't use typing for correctness. I use it for documentation. That's why I prefer JSDoc these days. I only type top-level variables/functions to get hints from my editor. Everything inside a function body remains untyped unless necessary. It’s the benefit of using Typescript without being forced to write dumb code just to satisfy the compiler.
I've been frustrated about this for a while, but I haven't had a good term to describe it, so thanks for that! The biggest annoyance I find from the increasingly complex types is the impact on tooling. My IDE slows to a crawl trying to process recursive types. tsc takes 4 minutes because of some needlessly complicated library types. Just pump the brakes on the types and write some damn unit tests.
I started using swift with a lot of enthusiasm for the type system, but at times it was a huge time suck. There were lots of obscure interface types that read like BipartisanPoliticallyCorrectSequence<T> that made writing my own generic utilities challenging. Documentation for that stuff was very poor and the source code was often totally inscrutable due to the implementation of core types in c++ and the overall complexity of the compiler.
I recently saw Chris Lattner talk about Mojo and he made passing reference to Swift trying to do too much with the type system. It’s telling that a guy with his experience is trying something more like zig’s comptime approach to types after two decades of generics.
Having worked on large codebases with many developers of varying levels of experience I have noticed that bugs that can be written very often will be written - a sort of programming specific version of Murphy's law. So I try to make the ones that seem the most likely impossible. Sometimes I go too far.
> So I try to make the ones that seem the most likely impossible.
Yeah this is a very key point to reflect on. Is this stricter type actually catching a bug that's easy to make? Or is it just giving you more satisfaction of more precise typing? It takes time and practice to make that distinction.
Similar things can also be said about automated tests. I've written too many of them that end up being written for a mistake that never gets made.
Really nice write-up, thanks. The issues you raise with complex typing are really nicely set out. It's such a trade-off, and you're absolutely write to claim that sometimes, simplicity trumps perfection.
This resonates so much with me, like the last 12 years of my life.
I've spent the last ~4 months building a new Rust crate, Typesynth, based on that experience and many of the challenges highlighted in this article.
The general idea is a fully declarative, git embedded and addressable, composable context language where all declarations are decomposed, traced, stacked, merged, and stored in in-memory CAS for immutable access to everything in the composed context. Those contexts can then the "projected" into any form, yaml, json, PyO3, petgraph, etc. as needed.
My inspiration came from working on a Python codebase I initially built almost a decade ago that was based on a layered, hierarchically merged yaml "recipe" for delivery. Tasks in the framework originally had a task_options dictionary. We later built infrastructure for using Pydantic for task_options but never rolled it out to most of the tasks.
I felt the pain of that last year, trying to build UX on top of those tasks and really missing the Pydantic models. So, I went the opposite direction, building a FastAPI app with hundreds of Pydantic/SQLModel models (GitHub API, Salesforce API, Infisical API, etc).
Typesynth is my first ever Rust project. I've put a LOT of time into a proc macro framework to make the whole framework fast with the ambitious goal of composing complex yaml/json in <1ms. Rushing through the final features to be able to release the prototype and share here. Registered the placeholder crate last week!
I’ve been absolutely loving template types and dot notation pathing. I have an entire compile time (and therefore autocompletable) argument for major.minor.patch.theme.schemaname for all schemas in the program manage. I don’t consider these “hyper typing” because they’re very, very easy to reason about when used in the right context like dot notation paths.
I wish, however, I could cleanly type “this must be an integer between 0 and 58” but typescript isn’t that expressive unless you do some pretty ridonkulous things. Especially with template strings it would be so cool to have something like:
type foo = `v${0:1}.{0:99}.{0:}`
(or whatever pre-existing format exists elsewhere. I just made that up)
This would be generalized as a “number range literal”, maybe. So not particular to template strings.
But not regex. Solving this with a regex literal type would be the poster child of “hyper typing”.
There is something to be said about pushing such things to runtime checks.
I'm not arguing for giving up type systems in general, but I'd rather read something like `class VersionString` that asserts the requirements in the constructor. If it really matters, you can check this at run time but before releasing using a test.
Like what exactly are you trying to get from autocomplete in a version string literal?
There are libraries that let you define refinement types[0] but it does require a bit of runtime overhead, and the added complexity depends on the library.
At the peak of my hyper typing trip, doing lots of Haskell and C++, I was trying to encode all the column/table/query types of my database in the host language.
Nothing I would recommend, perfect doesn't mean its a good idea.
Glad there’s finally a name for this phenomenon. Effect.js particularly egregious in this area. In my own libraries, I’ve definitely found myself producing less-than-perfect type safety because the readability tradeoff was too great.
Overall, I think one day the gradual type system trend will be regarded as a misstep. I’d rather just manually define a new type than play the generics mini-game.
> For example, the way the Astro framework for building static websites generates types for your content collections is just delightful. I really hope more tools follow in its footsteps.
Oh god, I really hope not. Those generated types are an abomination and have caused me so much pain. And don't even get me started on the ridiculous number of bugs I've run into in their type checker (which more or less wraps TSC but does some additional magic to handle their custom .astro file format and others).
Great post and def needs to be said. Won’t name names but there are some VERY popular ts library that are guilty of this. Once you have type constructor constructors I tend to bail and just cast things as described here. But then you lose guardrails, and confidence that you’re consuming the library properly
I can definitely relate to this post. (And don't get me started on those auto-generated SDKs in Typescript, eugh!)
I am far from an expert on type safety or JavaScript, so take this with a large grain of salt, but for anything I write for me, I like my simple JSDoc "typing" for that reason. It feels like any time I introduce TypeScript into anything I'm doing, I now have another problem. Or, more accurately, I spend more time worrying about types than I do writing code that does things that I find useful. And isn't the goal to save time and make development easier? If not, then what's the point?
I should clarify I am not a developer by trade or education and I am mostly doing things more closely related to systems programming/automation/serverless cloud things as opposed to what a lot of other people working with TS might be doing. So my perspective might be a bit warped :-)
I think typescript is just not a very ergonomic static typing system.
Don't get me wrong, it's a fantastic feat of engineering. It's wonderful that it exists. But it's retro-fitted onto a very dynamic language and it shows.
I prefer static typing - but when writing typescript I often question why I'm bothering.
My happy place seems to be typescript, with strict mode, and using //@ts-ignore about every 100 lines or so, usually inside a function.
Apparently an unpopular opinion, but actually strong types are useful above and beyond editor linting errors.
- Jit optimizations
- Less error checking code paths leading to smaller footprints
- Smaller footprints leading to smaller vulnerability surface area
- less useful: refactorability
Don't get me wrong, I love the flexibility of JavaScript. But you shouldn't rely on it to handle your poorly written code.
I've been trying to get this point across for some time but people tend to ignore incentives when discussing technical matters. These arguments often come across as 'hand-wavy' even though the effects are significant in practice. Incentives and human psychology matters a lot and a type system should be seen as a tradeoff and not a win-win.
I disagree. Types are in itself a coding language and as a result they can get arbitrarily complex. When you code your types do it like you code regular code. Use aliases and proper naming. Don’t let a type be that many words long.
I jumped in to the TypeScript deep end a few months back. I build a lot of web applications back in the 2000s, then disappeared onto a big tech island where everything was a little different. After popping back out into the real world, I wanted to see how the cool kids are doing things these days, so I figured I would try full immersion by creating a bunch of Next.js sites.
For the purpose of the experiment, I turned every linter and compiler strictness to maximum, and enforced draconian code formatting requirements with pre-commit hooks. Given that my last language love was Perl, I thought I would despise TypeScript for getting in the way. To my surprise, I think I like it. It's not just complexity like I hated in C++ and tedious boilerplate like I hated in Java. The complexity is highly expressive and serves a purpose beyond trying to protect me from a class of bugs that are frankly pretty rare. When done well, TypeScript-native APIs feel a lot more intentional and thought out. When I refactored my code from slinging bags of properties around to take more advantage of TypeScript features, it shook out weaknesses in the design and interfaces.
I've definitely run into those libraries, though, where someone has constructed an elaborate and impenetrable type jungle. If that were an opaque implementation detail, it would be one thing, but I find these are often the libraries where there's little to no documentation, so you're forced to dig into the source code, desperately trying to understand what all of this indirection is trying to accomplish.
The other one that surprises me when it pops up (unfortunately more than once) is the "in your zeal to keep the implementation opaque, you didn't export something I need, so I have to do weird backflips with ReturnType<> and Parameters<>" problem.
> The strictness even allows us to remove the if check inside the function, since now TypeScript gives us the compile-time guarantee that obj will always have property key.
This is a dangerous suggestion. While the author does acknowledge it is a compile-time guarantee only, that doesn’t imply it is safe to remove the if inside the function.
An API call, reading a file, calling less well-behaved libraries or making some system calls can all result in objects that pass compile-time checks but will cause a runtime exception or unexpected behavior.
As for the thesis of TFA itself, it sounds quite reasonable. In fact a high “level” of typing can give a false sense of security as that doesn’t necessarily translate automatically to more stable applications.
> An API call, reading a file, calling less well-behaved libraries or making some system calls can all result in objects that pass compile-time checks but will cause a runtime exception or unexpected behavior.
These all boil down to implicit `as` type casting parsed boundary data into expected types. What you suggest is replacing casts with to type narrowing guards, libraries like Zod help with some of that. I think TS needs a special flag where `JSON.parse` and alike default to `unknown` and force you to type guard in runtime.
> An API call, reading a file, calling less well-behaved libraries or making some system calls can all result in objects that pass compile-time checks but will cause a runtime exception or unexpected behavior.
Yeah, that is a design flaw that makes this kind of solution less useful than it might be. C# has this problem with "nullable": just because you've marked a type as not nullable doesn't mean some part of the program can't sneak a null in there. Haskell people wouldn't stand for that kind of nonsense.
I think the point though was that practical solutions can be imperfect, and spending complexity in an attempt at perfection can lead to impractical solutions.
> An API call, reading a file, calling less well-behaved libraries or making some system calls can all result in objects that pass compile-time checks but will cause a runtime exception or unexpected behavior.
Seems crazy to me to have this attitude, the whole point of typescript (and indeed many other languages with type checkers) is that we can leave out unecessary checks if proven by the compiler. The burden of compatibility is on the caller to ensure they supply correct values
I don't systematically pick one or the other location for these guards, but wouldn't it make more sense to have it in one place, the function itself, both for DRY and to ensure it being checked, rather than on every call site?
Such a requirement "oh yeah always guard your arguments for calls against this function for the "same" thing your compiler is doing anyway" shouldn't be implicit and duplicated everywhere if it's always meant to be fulfilled.
I think I take the attitude that you parse/validate data as soon as possible (ie when reading a file/coming from an API), so that the rest of the code can rely on typechecks alone.
That said, I come from a background where the language doesn't let you consider that the type of a value might be wrong (Haskell, for example), so perhaps I have more trust than typescript deserves.
Typescript will eventually get the bad reputation of confusing types just like perl got the reputation of being line noise, then everybody will ridicule it and move on to something else.
IMO it’s great when libraries are fully typed: it’s like documentation you experience at the moment of use. I think what the author is really dealing with at “when the library types are so difficult to understand and use, I often end up resorting to casting things as any, losing more type safety than I gained” is more the API design being unwieldy rather than the typing itself. You can fully-type a terrible API just as well as a great one and the terrible API will still be a pain to use.
I think they’re talking about something slightly different, and they allude to it by saying the useful complex types on the happy path become less useful when something goes wrong.
What I believe they’re encountering is that type errors—as in mistaken use of APIs, which are otherwise good when used correctly—become harder to understand with such complex types. This is a frequent challenge with TypeScript mapped and conditional types, and it’s absolutely just as likely with good APIs as bad ones. It’s possible to improve on the error case experience, but that requires being aware of/sensitive to the problem, and then knowing how to apply somewhat unintuitive types to address it.
For instance, take this common form of conditional type:
The never case can cause a lot of confusion, especially at a distance where CollectionValue may be invoked indirectly. It can often be a lot easier to understand why a type error occurs by producing another incompatible type in the same position: (I’ve used somewhat simplistic examples, because I’m typing this on my phone. But hopefully the idea is clear enough for this discussion!)What about the (incredibly unlikely, i'll admit) scenario where somebody attempts to pass the literal 'Expected a Collection' as an instance of this type? What's the best way to insert a warning, but also guarantee the type is unsatisfiable?
('Expected a Collection' & never)?
It’s very situational. If you can predict the shape of error cases, anything that doesn’t match that shape will do. If you can’t, you can fabricate a nominal type in one way or another (such as the symbol suggestion made by a sibling commenter, or by a class with a private member). The broad strokes solution though is to use a type that:
1. Won’t be assignable to the invalid thing.
2. Conveys some human-meaningful information about what was expected/wrong and what would resolve it.
Perhaps you could make it a private Symbol? Then it should be impossible semantically to use it from the outside.
Thanks for the tip ill try it out
I haven’t written a complicated library so I’m just guessing, but something as general as a form builder is probably so jam-packed with edge cases and configurability that I’d guess the types need to be that complex in order to be accurate.
I think it’s an inevitable trade off: the safer and more specific you want your type inference to be, the more inscrutable your generics become. The more accurately they describe complicated types, the less value they serve as quick-reference documentation.
Which makes me think the types are not the problem, it’s the lack of quick reference documentation. If a complicated type had a little blurb that said “btw here are 5 example ways you can format these args”, you wouldn’t need to understand the types at first glance. You’d just rely on them for safety and autocomplete
I agree with this.
The error messages in TypeScript can be difficult to understand. I often scroll to the very bottom of the error message then read upward line-by-line until I find the exact type mismatch. Even with super complex types, this has never failed me; or at least I can't recall ever being confused by the types in popular libraries like React Hook Form and Tanstack Table.
Another thing I find strange in the article is the following statement.
Every TypeScript codebase I have worked with typically includes a linter (Biome or ESLint) where explicit use of `any` is prohibited. Additionally, when reviewing code, I also require the writer to justify their usage of `as` over `satisfies`, since `as` creates soundness holes in the type system.Lastly, I wish the author had written a bit more about type generation as an alternative. For instance, React Router -- when used as a framework -- automatically generates types in order to implement things like type-safe links. In the React Native world, there is a library called "React Navigation" that can also provide type-safe links without needing to spawn a separate process (and file watcher) that generates type declarations. In my personal experience, I highly prefer the approach of React Navigation, because the LSP server won't have temporary hiccups when data is stale (i.e. the time between regeneration and the LSP server's update cycle).
At the end of the day, the complexity of types stems directly from modelling a highly dynamic language. Opting for "simpler" or "dumber" types doesn't remove this complexity; it just shifts errors from compile-time to runtime. The whole reason I use TypeScript is to avoid doing that.
Yeah react router is neat in this regard I was confused then pleased to see this the first time I used it
Which lets me avoid a lot of manual typedefsThe examples of bad, overly complex types are indeed unpleasant and unwieldy: colossal, highly nested types with long, cryptic lists of type parameters.
I think this speaks of lack of abstraction, not excess of it.
If your type has 17 type parameters, you likely did not abstract away some part of it that can be efficiently split out. If your type signature has 9 levels of parameter type nesting, you likely forgot to factor out quite a bit of intermediate types which could have their own descriptive names, useful elsewhere.
Unfortunately many languages have poor support for named type definitions and higher order types, unlike e.g. Haskell. That would definitely help to avoid these problems.
This is so, but Tyepscript has a pretty good support for that.
TS doesn't support higher order types. You can't return a generic and pass its parameters later. It's basically only a single level of parametrization.
``` type MyGeneric<TParam> = ...; type HigherOrderGeneric<TParam> = TParam extends string ? MyGeneric : never;
type Hey = HigherOrderGeneric<string><number>;
```
There are libraries that try to achieve this through some hacks, tho their ergonomics are really bad.
Does anybody have a good example of an 'alternative' that handle complex static types more gracefully? The Go language discourages Generics in favor of empty interface which feels similar to what the author is arguing... but I also find myself not always loving Go because of that. (I heavily lean on things like .map() in TS).
Trying to think of alternatives, I can only think of Haskell and C++ which are their own flavors of pain. In both C# and Java I've fallen into Hyper Typing pits (often of my own creation eee).
So what else exists as examples of statically typed languages to pull inspiration from? Elm? Typed Racket? Hax? (Not Scala even though it's neat lol)
Anybody have any tips to explore this domain in more depth? Example libraries that are easy to debug in both the happy and unhappy cases?
I've seen Go codebases where serialization was used as a way to generics... ugly.
The generic code would take a string of the serialized struct, which could be passed through (the code was operating on an outer structure) then be deserialized at the other end, preserving the type information. Maybe it could have been handled by some kind of typecasting plus an enum containing the name of the type (don't remember the specifics of Go right now), but the devs had halfway convinced themselves that the serialization served another purpose.
Lately I’ve been exploring zig and outside of painfully underdeveloped documentation I haven’t had so much fun with types (of comptime) for the long time.
I find it much more ergonomic than Rust and less energy draining than OCaml.
Then there’s pattern matching, but IMO Elixir is heading in the wrong direction. Erlang has accumulated dust over the decades. Clojure is very interesting choice because it can do both „comptime” (i.e. macros) and pattern matching.
Elm's a great example of a language that's fully statically typed, but where the language doesn't result in long complex types.
In my mind Rust is one of the nicest, most ergonomic type systems. People say it's highly complex, but I think that's really because its type system also includes reference and lifetime annotation.
As a culture, I think Rust developers do a great job of designing well for a simpler type signature.
lean4 unifies types and values, so that types feels so natural. you stop thinking types at all as you think about types in rust or csharp. and haskell does not have that feeling too.
zig may be like that too, but not tried.
Yes. It might sound counter-intuitive or even ironical but the next logical step is to make the type system more, not less expressive and to add completely dependent types. Every language already has them to the extent of Array<T, N> (or T[] & { length: N }, or a recursively defined Tuple<T, N>), but having true flow- and value-dependent types would allow for more concise and expressive code.
Gleam is great too!
Many of the highlighted problems come from TypeScript allowing such complex type definitions in the first place, which in turn stems from JavaScript allowing such an open and untyped (and yet convenient) compositional model.
The expressiveness of JavaScript is a curse upon every library author who (in vain) may try to design an interface with a simpler subset of types, but is undone by the temptation and pressure to use the flexibility of the language beneath.
The author's instincts are right though - target a simpler subset of TypeScript, combine code generation with simpler helper libraries to ease the understandability of the underlying code, and where a simpler JavaScript idiom beckons, use runtime safety checks and simpler types like `unknown`.
Might I add - this isn't limited to languages like TypeScript which simply try and make underlying untyped languages (like JavaScript) safer. It affects well-designed strongly-and-statically-typed-from-the-ground-up languages which have expressive type-constructs (Rust, anything ML-based, etc.) used to reduce repetition and code-generation steps.
This is true to some degree, but something about javascript/TS seem to lead to just incomprehensible typings. Imo any types where you start having to look at the keys of some map is going to lead to pain, but most other languages don't let you do that.
Javascript is not untyped; it's dynamically typed.
In this context untyped means that it is dynamically typed and doesn't have static type hints. It's a fairly new word but I've seen it used enough and the meaning is obvious enough that I don't think you need to pedantically correct it.
I find that these complex types often reflect the underlying complex code. This usually happens when you interact with something that is explicitly written for javascript and takes advantage of its dynamic nature. (usually the base javascript api)
window.addEventListener(event, callback), dispatchEvent(event) and removeEventListener(callback) is a good example. In a dynamic language, this api is at least unsurprising. It's easy to understand.
In a typed language, although strategies could vary, one would probably not write the api like that if you prefer to have simpler types.
Something like this would make more sense in a typed language:
import { onChange } from 'events'
const event = onChange.Add((event) => {
})
event.Remove()
// ..
event.onChange.Dispatch({value: "1,2,3"})
I love the term "Hyper Typing" and I hope it becomes commonplace. I've long been searching for a phrase with a similar to "premature optimization" (with a similarly mildly-negative connotation) but for overengineered type safety and I think this is it.
I would call it "over typing".
I wish IDEs had more features/tooling around types. For example, something like "expand all types by one level" where
{ foo: Bar } would expand to { foo : { bar1: string, bar2: Baz } } (and you could trigger it again to expand Baz)
(this would be especially nice if it worked with vscode/cursor on-hover type definitions)
The poor support for types in VS Code has always been a blocker for me adopting it vs. Jetbrains, where types and the intellisense are much easier to jump between.
This guy gets it
This would produce interesting results with types defined via `infer` or template literal types.
That being said I wish the same.
Author here. Curious to hear if anyone's experience also matches mine, or if instead you find the trade-off to be worth it most of the times. :)
I've felt the same, but I blame error messages and language ergonomics rather than the practice itself. Basically, everything you said, but with optimism that future languages and language implementations make 'hyper typing' a good practice. Recent languages have shown that there's a lot of room for improvement on error messages in complex programs. Hopefully that extends to complex types before too long.
Fully agree. TS shouldn't throw internal types into the user's face. The errors UX is abyssymal. The fact that the most popular editor - VSC - doesn't preserve line breaks makes reading TS errors even worse. I highly recommend the pretty TS errors extension, otherwise you're only hurting yourself working with TS.
I don't use typing for correctness. I use it for documentation. That's why I prefer JSDoc these days. I only type top-level variables/functions to get hints from my editor. Everything inside a function body remains untyped unless necessary. It’s the benefit of using Typescript without being forced to write dumb code just to satisfy the compiler.
I've been frustrated about this for a while, but I haven't had a good term to describe it, so thanks for that! The biggest annoyance I find from the increasingly complex types is the impact on tooling. My IDE slows to a crawl trying to process recursive types. tsc takes 4 minutes because of some needlessly complicated library types. Just pump the brakes on the types and write some damn unit tests.
I started using swift with a lot of enthusiasm for the type system, but at times it was a huge time suck. There were lots of obscure interface types that read like BipartisanPoliticallyCorrectSequence<T> that made writing my own generic utilities challenging. Documentation for that stuff was very poor and the source code was often totally inscrutable due to the implementation of core types in c++ and the overall complexity of the compiler.
I recently saw Chris Lattner talk about Mojo and he made passing reference to Swift trying to do too much with the type system. It’s telling that a guy with his experience is trying something more like zig’s comptime approach to types after two decades of generics.
my experience absolutely matches yours. Navigating the types of many libraries is often daunting (MUI, React-Aria, react-hook-form to name a few)
I certainly felt guilty reading it.
Having worked on large codebases with many developers of varying levels of experience I have noticed that bugs that can be written very often will be written - a sort of programming specific version of Murphy's law. So I try to make the ones that seem the most likely impossible. Sometimes I go too far.
> So I try to make the ones that seem the most likely impossible.
Yeah this is a very key point to reflect on. Is this stricter type actually catching a bug that's easy to make? Or is it just giving you more satisfaction of more precise typing? It takes time and practice to make that distinction.
Similar things can also be said about automated tests. I've written too many of them that end up being written for a mistake that never gets made.
Really nice write-up, thanks. The issues you raise with complex typing are really nicely set out. It's such a trade-off, and you're absolutely write to claim that sometimes, simplicity trumps perfection.
Absolutely matches your experience!
Also curious about the delightful type generation Astro uses.
This resonates so much with me, like the last 12 years of my life.
I've spent the last ~4 months building a new Rust crate, Typesynth, based on that experience and many of the challenges highlighted in this article.
The general idea is a fully declarative, git embedded and addressable, composable context language where all declarations are decomposed, traced, stacked, merged, and stored in in-memory CAS for immutable access to everything in the composed context. Those contexts can then the "projected" into any form, yaml, json, PyO3, petgraph, etc. as needed.
My inspiration came from working on a Python codebase I initially built almost a decade ago that was based on a layered, hierarchically merged yaml "recipe" for delivery. Tasks in the framework originally had a task_options dictionary. We later built infrastructure for using Pydantic for task_options but never rolled it out to most of the tasks.
I felt the pain of that last year, trying to build UX on top of those tasks and really missing the Pydantic models. So, I went the opposite direction, building a FastAPI app with hundreds of Pydantic/SQLModel models (GitHub API, Salesforce API, Infisical API, etc).
Typesynth is my first ever Rust project. I've put a LOT of time into a proc macro framework to make the whole framework fast with the ambitious goal of composing complex yaml/json in <1ms. Rushing through the final features to be able to release the prototype and share here. Registered the placeholder crate last week!
I’ve been absolutely loving template types and dot notation pathing. I have an entire compile time (and therefore autocompletable) argument for major.minor.patch.theme.schemaname for all schemas in the program manage. I don’t consider these “hyper typing” because they’re very, very easy to reason about when used in the right context like dot notation paths.
I wish, however, I could cleanly type “this must be an integer between 0 and 58” but typescript isn’t that expressive unless you do some pretty ridonkulous things. Especially with template strings it would be so cool to have something like:
type foo = `v${0:1}.{0:99}.{0:}`
(or whatever pre-existing format exists elsewhere. I just made that up)
This would be generalized as a “number range literal”, maybe. So not particular to template strings.
But not regex. Solving this with a regex literal type would be the poster child of “hyper typing”.
There is something to be said about pushing such things to runtime checks.
I'm not arguing for giving up type systems in general, but I'd rather read something like `class VersionString` that asserts the requirements in the constructor. If it really matters, you can check this at run time but before releasing using a test.
Like what exactly are you trying to get from autocomplete in a version string literal?
There are libraries that let you define refinement types[0] but it does require a bit of runtime overhead, and the added complexity depends on the library.
[0] https://zod.dev/?id=refine as an example
At the peak of my hyper typing trip, doing lots of Haskell and C++, I was trying to encode all the column/table/query types of my database in the host language.
Nothing I would recommend, perfect doesn't mean its a good idea.
Kysley does a good job of this. I haven't found it annoying.
Well... I think MySQL is a 2nd class citizen so I had to write my own schema gen but that only burned a few hours. Now it's great
On the other hand: https://kysely.dev/docs/recipes/excessively-deep-types
Is it though, or are you currently enjoying the same trip?
I mean, take a step back, is it worth the effort?
Glad there’s finally a name for this phenomenon. Effect.js particularly egregious in this area. In my own libraries, I’ve definitely found myself producing less-than-perfect type safety because the readability tradeoff was too great.
Overall, I think one day the gradual type system trend will be regarded as a misstep. I’d rather just manually define a new type than play the generics mini-game.
> For example, the way the Astro framework for building static websites generates types for your content collections is just delightful. I really hope more tools follow in its footsteps.
Oh god, I really hope not. Those generated types are an abomination and have caused me so much pain. And don't even get me started on the ridiculous number of bugs I've run into in their type checker (which more or less wraps TSC but does some additional magic to handle their custom .astro file format and others).
Great post and def needs to be said. Won’t name names but there are some VERY popular ts library that are guilty of this. Once you have type constructor constructors I tend to bail and just cast things as described here. But then you lose guardrails, and confidence that you’re consuming the library properly
I can definitely relate to this post. (And don't get me started on those auto-generated SDKs in Typescript, eugh!)
I am far from an expert on type safety or JavaScript, so take this with a large grain of salt, but for anything I write for me, I like my simple JSDoc "typing" for that reason. It feels like any time I introduce TypeScript into anything I'm doing, I now have another problem. Or, more accurately, I spend more time worrying about types than I do writing code that does things that I find useful. And isn't the goal to save time and make development easier? If not, then what's the point?
I should clarify I am not a developer by trade or education and I am mostly doing things more closely related to systems programming/automation/serverless cloud things as opposed to what a lot of other people working with TS might be doing. So my perspective might be a bit warped :-)
I think typescript is just not a very ergonomic static typing system.
Don't get me wrong, it's a fantastic feat of engineering. It's wonderful that it exists. But it's retro-fitted onto a very dynamic language and it shows.
I prefer static typing - but when writing typescript I often question why I'm bothering.
My happy place seems to be typescript, with strict mode, and using //@ts-ignore about every 100 lines or so, usually inside a function.
Apparently an unpopular opinion, but actually strong types are useful above and beyond editor linting errors.
- Jit optimizations - Less error checking code paths leading to smaller footprints - Smaller footprints leading to smaller vulnerability surface area - less useful: refactorability
Don't get me wrong, I love the flexibility of JavaScript. But you shouldn't rely on it to handle your poorly written code.
How do you get JIT optimizations from TypeScript types? Don't the types get stripped before you run it?
I've been trying to get this point across for some time but people tend to ignore incentives when discussing technical matters. These arguments often come across as 'hand-wavy' even though the effects are significant in practice. Incentives and human psychology matters a lot and a type system should be seen as a tradeoff and not a win-win.
Here I was thinking this was about someone typing at superhuman speed. LOL!
I disagree. Types are in itself a coding language and as a result they can get arbitrarily complex. When you code your types do it like you code regular code. Use aliases and proper naming. Don’t let a type be that many words long.
I jumped in to the TypeScript deep end a few months back. I build a lot of web applications back in the 2000s, then disappeared onto a big tech island where everything was a little different. After popping back out into the real world, I wanted to see how the cool kids are doing things these days, so I figured I would try full immersion by creating a bunch of Next.js sites.
For the purpose of the experiment, I turned every linter and compiler strictness to maximum, and enforced draconian code formatting requirements with pre-commit hooks. Given that my last language love was Perl, I thought I would despise TypeScript for getting in the way. To my surprise, I think I like it. It's not just complexity like I hated in C++ and tedious boilerplate like I hated in Java. The complexity is highly expressive and serves a purpose beyond trying to protect me from a class of bugs that are frankly pretty rare. When done well, TypeScript-native APIs feel a lot more intentional and thought out. When I refactored my code from slinging bags of properties around to take more advantage of TypeScript features, it shook out weaknesses in the design and interfaces.
I've definitely run into those libraries, though, where someone has constructed an elaborate and impenetrable type jungle. If that were an opaque implementation detail, it would be one thing, but I find these are often the libraries where there's little to no documentation, so you're forced to dig into the source code, desperately trying to understand what all of this indirection is trying to accomplish.
The other one that surprises me when it pops up (unfortunately more than once) is the "in your zeal to keep the implementation opaque, you didn't export something I need, so I have to do weird backflips with ReturnType<> and Parameters<>" problem.
Nevertheless, on balance, I'm pretty happy.
> The strictness even allows us to remove the if check inside the function, since now TypeScript gives us the compile-time guarantee that obj will always have property key.
This is a dangerous suggestion. While the author does acknowledge it is a compile-time guarantee only, that doesn’t imply it is safe to remove the if inside the function.
An API call, reading a file, calling less well-behaved libraries or making some system calls can all result in objects that pass compile-time checks but will cause a runtime exception or unexpected behavior.
As for the thesis of TFA itself, it sounds quite reasonable. In fact a high “level” of typing can give a false sense of security as that doesn’t necessarily translate automatically to more stable applications.
> An API call, reading a file, calling less well-behaved libraries or making some system calls can all result in objects that pass compile-time checks but will cause a runtime exception or unexpected behavior.
These all boil down to implicit `as` type casting parsed boundary data into expected types. What you suggest is replacing casts with to type narrowing guards, libraries like Zod help with some of that. I think TS needs a special flag where `JSON.parse` and alike default to `unknown` and force you to type guard in runtime.
> An API call, reading a file, calling less well-behaved libraries or making some system calls can all result in objects that pass compile-time checks but will cause a runtime exception or unexpected behavior.
Yeah, that is a design flaw that makes this kind of solution less useful than it might be. C# has this problem with "nullable": just because you've marked a type as not nullable doesn't mean some part of the program can't sneak a null in there. Haskell people wouldn't stand for that kind of nonsense.
I think the point though was that practical solutions can be imperfect, and spending complexity in an attempt at perfection can lead to impractical solutions.
> An API call, reading a file, calling less well-behaved libraries or making some system calls can all result in objects that pass compile-time checks but will cause a runtime exception or unexpected behavior.
Seems crazy to me to have this attitude, the whole point of typescript (and indeed many other languages with type checkers) is that we can leave out unecessary checks if proven by the compiler. The burden of compatibility is on the caller to ensure they supply correct values
I don't systematically pick one or the other location for these guards, but wouldn't it make more sense to have it in one place, the function itself, both for DRY and to ensure it being checked, rather than on every call site?
Such a requirement "oh yeah always guard your arguments for calls against this function for the "same" thing your compiler is doing anyway" shouldn't be implicit and duplicated everywhere if it's always meant to be fulfilled.
I think I take the attitude that you parse/validate data as soon as possible (ie when reading a file/coming from an API), so that the rest of the code can rely on typechecks alone.
That said, I come from a background where the language doesn't let you consider that the type of a value might be wrong (Haskell, for example), so perhaps I have more trust than typescript deserves.
"Do not put all generics in one basket"
Small discussion, including post from author (19 points, 13 days ago, 13 comments) https://news.ycombinator.com/item?id=43893127
Thanks! That one didn't get much frontpage attention so maybe we'll merge those comments hither.
(Note the merge process relativizes the timestamps on the comments, so if you see confusing timestamps, that's why (https://hn.algolia.com/?dateRange=all&page=0&prefix=true&que...)
[dead]
Typescript will eventually get the bad reputation of confusing types just like perl got the reputation of being line noise, then everybody will ridicule it and move on to something else.