Hyper Typing

2025-05-1820:4110478pscanf.com

In this article, I talk about an inherent trade-off in TypeScript's type system: stricter types are safer, but often more complex. I describe a phenomenon I call "hyper-typing", where libraries - in…

Summary

In this article, I talk about an inherent trade-off in TypeScript's type system: stricter types are safer, but often more complex. I describe a phenomenon I call "hyper-typing", where libraries - in pursuit of perfect type safety - end up with overly complex types that are hard-to-understand, produce cryptic errors, and paradoxically even lead to unsafe workarounds.

I argue that simpler types, or even type generation, often lead to a more practical and enjoyable developer experience despite being less "perfect".

TypeScript’s type system is gradual: when describing a JavaScript value with TypeScript, you can be more or less accurate, ranging from saying that the value could be anything (any), to describing in absolute detail what the value is under different conditions.

Consider for example this function which prints the property of an object - if it exists:

function printProperty(obj, key) {

if (typeof obj === "object" && obj !== null && Object.hasOwn(obj, key)) {

console.log(obj[key]);

}

}

We can type it in a loose way as follows:

function printProperty(obj: any, key: string) {

if (typeof obj === "object" && obj !== null && Object.hasOwn(obj, key)) {

console.log(obj[key]);

}

}

But we can also be more strict, requiring obj to be an object and key to be one of its properties:

function printProperty<Obj extends object>(obj: Obj, key: keyof Obj) {

console.log(obj[key]);

}

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:

// Passing in a non-existing property gives an error.

printProperty({ a: "a" }, "b");

Having this additional guarantee is obviously desirable, but it comes at the expense of making the type definition more complex. Not much in this case - the type is still very understandable - but it reveals an inherent trade-off. Where should we draw the line?

Lately I’ve been trying out a few libraries that - in pursuit of perfect type safety - make their typings so complex that it makes them almost unusable, in my opinion. I call this approach hyper-typing, and I worry it’s becoming a trend in the TypeScript ecosystem.

I get why, actually. I myself am often a hyper-typer! It’s a slippery slope: “If I add this type constraint then the caller will get an error in this particular case”. “I can make this function infer this type here so the caller will get the correct type hint there”.

At the bottom of the slope you get to a place where yes, things work, and they might also look good for the caller - in the happy case. The resulting types, however, are a complex mess, and the compilation errors produced when the caller deviates from the happy path are walls of inscrutable text.

An Example: TanStack Form

TanStack Form is the new kid on the block of form libraries. It pushes heavily on type-safety, promising “first-class TypeScript support with outstanding autocompletion” for a “smoother development experience”.

What the library accomplishes is honestly impressive. Just give it the default values of your form and you’re set: now for every form field you define - no matter how deeply nested - you get the correct type when you read or write its value, when you do validation, etc.

Don’t look at how the sausage is made, though: you won’t even understand it. Take the simple example from TanStack Form’s documentation. Use the interactive sandbox and try to check what’s the shape of a field’s meta property (or any other library value, really). Here’s what you’ll see:

Typings for the meta property of a TanStack form field.

The FieldMeta type has 17 (!) generic parameters and is the intersection of two types - each taking the same 17 generics - which is where you eventually find its properties defined.

To be fair, after re-formatting the type definition file and staring at it for a couple of minutes, I do start to understand what’s going on. For the FieldMeta type in particular there’s nothing too obscure, but I can’t help but feel that this undoubtedly clever and accurate type definition is not actually helping me as a user of the library.

Cons of Hyper-Typing

TanStack Form is one example of a hyper-typing library, but as I said, lately I’ve encountered others that follow a similar approach and leave me with similar issues:

  • Badly-formatted type definition files. This is technically TypeScript’s fault, but I guess the issue is not apparent until the typings become very complex. It should be easy to fix, though, just by running the files through prettier.

  • Difficult to understand types. I agree that they’re safer and more accurate, but that’s not very useful if I don’t understand what they’re actually describing.

  • Unsafe workarounds. Getting into situations where I need to explicitly define a type is inevitable, and 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.

  • Incomprehensible error messages. TypeScript error messages are not amazing to start, and the more complex the type, the more complex the error message.

A Happy Medium

Having banged my head against hyper-typed stuff, I can now say I prefer less accurate, less safe libraries. They might be dumber; I might need to explicitly define a type that technically could have been inferred. But the practical reality is that I find working with them being overall more enjoyable, and the resulting code more understandable and maintainable.

An alternative approach that I also find more enjoyable is having a separate build step that generates types - from a schema definition, for example. In some corners of the internet I’ve seen it being depicted as the ultimate DX sin, but on more than one occasion I’ve actually found it works very well. 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.


Read the original article

Comments

  • By jasonthorsness 2025-05-1821:493 reply

    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.

    • By eyelidlessness 2025-05-193:041 reply

      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!)

      • By arjvik 2025-05-196:013 reply

        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)?

        • By eyelidlessness 2025-05-1917:14

          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.

        • By bubblyworld 2025-05-196:211 reply

          Perhaps you could make it a private Symbol? Then it should be impossible semantically to use it from the outside.

          • By cdaringe 2025-05-1915:53

            Thanks for the tip ill try it out

    • By koito17 2025-05-1822:051 reply

      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.

        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.

      • By jasonthorsness 2025-05-1822:12

        Yeah react router is neat in this regard I was confused then pleased to see this the first time I used it

            import type { Route } from "./+types/home";
        
        Which lets me avoid a lot of manual typedefs

    • By chamomeal 2025-05-1922:30

      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

  • By nine_k 2025-05-1822:401 reply

    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.

    • By js8 2025-05-192:551 reply

      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.

      • By nine_k 2025-05-194:111 reply

        This is so, but Tyepscript has a pretty good support for that.

        • By Byamarro 2025-05-1911:56

          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.

  • By freeqaz 2025-05-191:465 reply

    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?

    • By woah 2025-05-192:06

      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.

    • By xlii 2025-05-194:56

      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.

    • By dlahoda 2025-05-192:221 reply

      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.

      • By lblume 2025-05-194:13

        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.

    • By benrutter 2025-05-195:51

      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.

    • By giacomocava 2025-05-199:28

      Gleam is great too!

HackerNews