@radekmieBy Radosław Miernik · Published on · Comment on Reddit
Back in 2015, only a handful of people using JavaScript (even professionally) knew what generic types were, I believe. While both TypeScript and Flow were already around (at least since October 2014 and June 2015 respectively) and supported them (1, 2), they were still a niche and far from being popular.
Nowadays, I see undergrads applying for an internship who not only know that they exist and what the syntax looks like, but can give a reasonable example of using them as well. Sure, they may have seen that elsewhere, e.g., in statically typed languages (even Go caught up in 2022), including the ones most of us only used at the university and for hobby projects, like Haskell.
I think nobody considers it “magic” anymore. They are considered an advanced topic (they didn’t make it to TypeScript’s Everyday Types section), and for a good reason – type variance alone is hard to grasp. And yes, it can run Doom.
Personally, I love how generics allow me to hide complexity from the public API, and that’s what we’re looking at today. Everything will be TypeScript-centric, but some of the ideas may transfer to other languages and type systems.
While trying to reuse some code, we often end up with generics. Well, we’re trying to make it more generic, so it’s not a surprise, right? Anyway, let’s see our initial code that renders a list of movies (playground):
;
;
movies;
The actual implementation of the renderMovies function is irrelevant here. At some point, you’d like to reuse parts of it (e.g., the table rendering logic) for other types. Let’s start by rendering just a single column of the table (playground):
movies, ;
It may feel like a lot at first, I know. First of all, the data argument is no longer Movie[] but a generic array of T; it means that we can pass any array in there, regardless of its elements.
The second type parameter, K extends keyof T, reads as “whatever T is, K can be used to access it” (access, i.e., given x: T and k: K, x[k] is valid). We use it to define a column with key ('title' can be used to index a Movie), and fn, which, given an array element, returns some string.
Here’s an example renderTable implementation, just to help you wrap your head around it1 (playground):
However, real tables have more than one column, right? It means we’d need to take an array of columns instead of only one. Here’s an improved version that does exactly that (playground):
movies,;
Simple, right? But what’s the K parameter now? In the previous example, it was simply 'title', but now it somehow handles both fields. If you inspect it in your editor, you’ll see that TypeScript correctly inferred (i.e., “guessed”) it’s 'title' | 'genre', i.e., “either 'title' or 'genre'”; a union type.
Great! As it can handle everything now, let’s add just one more column: the year of production. In the end, our users want to know how old this movie is, right? It looks like this (playground):
;
;
movies,;
But it doesn’t work. Type K is inferred as 'title' | 'genre' | 'year', so this part is correct. However, it means that all fns must now accept number | string2, as we don’t know which key is used where… But wait, we know that!
We must somehow tell TypeScript that K can be different for every column. It means we cannot keep it as a generic on renderTable, as it means all columns are “sharing” it. An alternative would be to say “if it’s title, then it’s string” and “if it’s year, then it’s number”.
We can do that with unions! Let’s define a MovieColumn type that does exactly that, and move back to our renderMoviesTable for a second (playground):
So if that works, we “only” need to somehow construct it automatically for any T. Your first idea may be to use a generic type, and that’s correct! I guess it looks more or less like this:
;
And while it’s perfectly fine, there’s a small problem… How can we use it in our renderTable? We have to type columns as Column<T, ?>[], but whatever will go in ?, it will be shared by all columns. Most notably, going with keyof T brings us back to square one.
But what if columns would be something else? Let’s say, an array of a union of all possible columns3? For our Movie type it’d be (Column<Movie, 'title'> | Column<Movie, 'genre'> | Column<Movie, 'year'>)[]… And it works! We’re missing just one last bit now: how to “spread” our Column over keyof T?
We already saw that indexing type T with keyof T results in a union of its elements, so if we could build such an object… That’s exactly what TypeScript’s Mapped Types are for! Let’s see that in action (playground):
The “magic” happens in two steps. First, we create an object type, that mapped each key to a Column for this key. With that in hand, we take a union of all of them with [keyof T]. And lastly, as we wanted an array of columns and not a single one, we sprinkle it with a [] on top.
How often do you build a custom table renderer? Let’s see how one of the most popular React libraries (over 10 million downloads a week) handles that. At the core of it, the useReactTable hook accepts ColumnDef<TData>[]. Let’s see how it works in action (playground):
;
;
Wow, it works! Unfortunately, it’s only because ColumnDef has two, not one type arguments… And the second one, representing the column’s value, defaults to any. Can we somehow make it resolve the actual type?
A simple answer is to wrap each of the columns with some “magic” function typedColumn that does seemingly nothing. Really, here it is, without types:
Therefore, it’s all about the type. So, what does it do, exactly? The goal is to “bind” the generic argument with the accessorKey4. We can follow the same approach as we did before, i.e., use indexed types (playground):
Feel free to ignore the Omit part, as it’s there only to make it work with the underlying implementation of ColumnDef5. Alternatively, we could type the columns outside of the component, like this (playground):
;
;
;
Did I write this whole tutorial only because I really don’t like TanStack Table’s createColumnHelper? Well, yes, why are you asking? For real, it’s a great piece of software, and I enjoy it being so hackable.
Just make sure to properly document your complex types!
It could use Angular, React, Vue, or whatever you’d like.
Movie['title'] is simply string, and I don’t think anyone would doubt that. But why does Movie['title' | 'genre' | 'year'] result in number | string? We have to assume that any of these keys will be used, so we treat it just like Movie['title'] | Movie['genre'] | Movie['year']. It’s covered in TypeScript’s Indexed Access Types.
Some types are a mouthful, I know.
I silently ignored the other column types. They’re not relevant here, but it’s possible to handle them as well.
There’s one more issue with type inference: we need to explicitly pass Movie to useReactTable. It’s a limitation of TypeScript, and it may not be needed in the future. Most importantly, it’s not a deal-breaker, since we can always rely on typeof data[number] to detect it automatically.