@radekmieBy Radosław Miernik · Published on · Comment on Reddit
A few days ago, I started reviewing a rather simple pull request from one of my team members. I added a few comments here and there, but there was one thing that triggered me: an explicit generic type annotation was now needed in a place it wasn’t before. It was but one type, but still.
We started a discussion about whether it’s a bug in the package we’re using, or if it’s something we did incorrectly… Or at least I was, because my colleague wanted to get over it. But as it’s one of the hills I will die on, we started talking.
This text is more or less a summary of our discussion.
Our application has three API layers: an internal DDP API (Meteor-specific), a REST API we developed years ago, and a GraphQL API we slowly migrate all third parties to. When it comes to types, we need three different approaches. We use GraphQL Codegen, and it’s great! When it comes to DDP, we type it manually, mostly due to a lot of old and untyped code1.
As for the REST API, we started gradually typing it, endpoint by endpoint. The actual implementation is rather straightforward: we have a single type that maps the endpoints to the params and response types. A slightly simplified version looks more or less like this:
;
;
Of course, the ApiEndpoints type is, well, only a type, so we can’t really use it to fetch anything from the REST API. In the runtime, we need to execute the request somehow; either by using fetch or some popular wrapper you like. More importantly though, it must take care of authorization and the Accept-Language header. In types, it’s defined using a rather simple generic type:
;
Now that we have it all set up, let’s talk about how we could use it in our React components. The api: ApiFunction is already there2, so we only need a hook around it. Ideally, it would take care of caching and other stuff, so we went with swr, as we already used it elsewhere. The hook looked like this:
;
;
;
If you never used swr, here’s how it works: it accepts a key (here: the [url, params] tuple) that is passed to the fetcher (here: the function calling api). Caching is based on the key, and if it’s null, the query is not executed. Here’s an example of using it:
// `data` is of type `{ restaurant: Restaurant } | undefined`.
;
What was changed in the pull request? We needed to pass an additional config to some requests, i.e., we couldn’t handle it with a global configuration. Here’s the proposed change:
+import type { SWRConfiguration } from 'swr';
+
export const useRestApi = <Endpoint extends keyof ApiEndpoints>({
api,
+ config,
language,
params,
url,
}: {
api: ApiFunction;
+ config?: SWRConfiguration<ApiEndpoints[Endpoint]['response']>;
language: string;
params: ApiEndpoints[Endpoint]['params'] | null;
url: Endpoint;
-}) => useSWR(params ? [url, params] : null, args => api(...args, language));
+}) =>
+ useSWR<ApiEndpoints[Endpoint]['response']>(
+ params ? [url, params] : null,
+ ([url, params]) => api(url, params, language),
+ config,
+ );
Can you spot the problem? I can assure you, it works as expected - config is optional, and typed correctly when passed to useRestApi. The explicit type argument passed to useSWR is noisy, but I guess we all could live with it (yes, I know I said I’d fight, but bear with me).
The thing that drew my attention was the change in the fetcher function: why are the params now expanded? Was that intentional, or collateral damage a result of LLM usage? I did a quick check, and my intuition was right – it was intentional because it was “needed”… Because args was now any, and you can’t spread it. But you can destruct it in the arguments, so… A workaround.
Now, what was the reason? Weren’t the inferred (implicit) types exactly the same before? Well, almost. You see, useSWR and SWRConfiguration take four and three generic types respectively, not one. There’s obviously the result (the one used above), and the error (which we ignore here; it defaults to any).
useSWR accepts both the key and config types. However, SWRConfiguration accepts the fetcher type there… And it defaults to something slightly different, i.e., a fetcher accepting and returning any. How can we fix it? Consider this:
+import type { Fetcher, SWRConfiguration } from 'swr';
export const useFetchData = <Endpoint extends keyof ApiEndpoints>({
api,
+ config,
language,
params,
url,
}: {
api: ApiFunction;
+ config?: SWRConfiguration<
+ ApiEndpoints[Endpoint]['response'],
+ unknown,
+ Fetcher<
+ ApiEndpoints[Endpoint]['response'],
+ [Endpoint, ApiEndpoints[Endpoint]['params']] | null
+ >
+ >;
language: string;
params: ApiEndpoints[Endpoint]['params'] | null;
url: Endpoint;
-}) => useSWR(params ? [url, params] : null, args => api(...args, language));
+}) =>
+ useSWR(params ? [url, params] : null, args => api(...args, language), config);
“Radek, do we really need it? It’s literally eight lines for a single parameter type just to skip one generic passed explicitly.” You’re right. But it’s also about getting rid of any, so I’ll choose that every single time. “Oh, yeah, that’s nice.”
“Why do we rely on the type inference so much, though? Why won’t we make the types more explicit in some cases?” Well, I hope you have time, because I’d love to give a spontaneous lecture now. Where should I start?
Let’s start with the return types. Why am I against stating them explicitly and rather let TypeScript infer them whenever possible? Consider the following code:
It’s simple, well typed and behaves nicely. What would happen if I now return some additional property? Thanks to structural subtyping, it should be corre…
Oh. TypeScript is smart enough to prevent us from doing something obviously incorrect. Well, in the end, it’s so great and will definitely save us from ever…
What? The aforementioned subtyping triggers now? But bar is “as incorrect” as foo! Well, yes and no. It boils down to what TypeScript is doing. In foo’s case, it checks an object literal, so it can assert it matches the expected type. In bar’s case, it checks a variable whose type was already inferred before, at the moment of definition3.
Does that ever happen in real life, though? Quite often, if you ask me – as soon as you need to log (or trace) the result, you’d put it into a variable. Or if you’d need to reuse it, e.g., call a helper and return it afterward. In any case, once you do it, there’s an option to return more properties than actually needed.
It doesn’t mean the type system is unsound, though! Again, it’s just a matter of structural subtyping – we promised x: number, and that’s it. There is a way to force nominal typing, but it’s not something you want to do in every function. Or at least I don’t want to.
Does the same problem happen for arguments, i.e., function parameters? In the end, we just agreed to ignore everything besides the properties we explicitly asked for, right? Let’s start with an example:
Simple and effective – if the average is present, format it; if not, return '-' to indicate that it’s missing. What happens if we change the average type?
-function format(average: number | undefined) {
+function format(average: number) {
return average?.toFixed(2) ?? '-';
}
Yes, nothing happens. There are no type nor runtime errors. But we all know this ?. is as good as . here, and that the ?? '-' part will never evaluate. It may be “obviously refactorable” if the function is that small, but what if it’s buried somewhere deep in the call chain? Or inside of an object, perhaps…?
// Defined somewhere else...
;
// Defined here...
;
That’s a piece of code you saw a lot of times, right? Such a format function is “nicely decoupled” from the Something type, right? True, but if Something will change now, we have to update both types. An alternative is simple:
-type FormatArg = { average?: number };
+type FormatArg = Pick<Something, 'average'>;
function format(input: FormatArg) {
return input.average?.toFixed(2) ?? '-';
}
Yes, sure, it’s not always applicable. Yes, it also doesn’t solve the problem of us not knowing that a helper has some unreachable code branches. But it allows us to make the types automatically synchronized. (It’s not really type inference, but hey, it’s my lecture!)
Lastly, what if we’d like to name some part of an existing type? Everything’s correct and well-typed, but we’d like to improve our developer experience in general. Consider working with a nested type, like this:
;
The way it works is simple – we extracted a property of some type, so its type stayed as-is. It does get better if we alias it inside of Nested:
-type Nested = { x: { y: { z: number } } };
+type Alias = { y: { z: number } };
+type Nested = { x: Alias };
function foo({ x }: Nested) {
- return x; // { y: { z: number } }
+ return x; // Alias
}
However, sometimes we can’t do it, because the types come from the external party packages (or are generated4). We can still work around it, though:
type Nested = { x: { y: { z: number } } };
+type Alias = Nested['x'];
function foo({ x }: Nested) {
- return x; // { y: { z: number } }
+ return x as Alias; // Alias
}
This way all call sites of foo will see an Alias, not the literal object. It comes at a price of an as, though. It could also be a return type annotation, but we already talked about it. There’s also a third, rather unexpected option:
type Nested = { x: { y: { z: number } } };
+type Alias = Nested['x'];
-function foo({ x }: Nested) {
+function foo(nested: Nested) {
- return x; // { y: { z: number } }
+ const x: Alias = nested.x;
+ return x; // Alias
}
Huh? It makes no sense – why are we creating an extra const? How is that better than as? We must consider a slightly more complex example:
;
;
Can you see where it’s going? What happens if we make z not optional? As far as TypeScript goes, bar will cause trouble – it won’t trigger a type error but z will not be there. Here’s a TypeScript playground link so you can see it for yourself. But what if we put the Alias in the return type, like this:
It also triggers a type error – that’s nice! But there’s a small difference: the error is on the return line, not result definition. And just like we in the previous section, it’s not a problem as long as the function is small, but if it’s not, the error may be a whole screen earlier.
And to me that’s bad – the error should be reported as soon as possible. If you’d see a problem at the return line you’d have to scroll to the definition of the result anyway, right? That’s why I’d rather put the type there.
Was it an awful lot of words just to make an argument about a single generic type? Kind of. But the general idea remains: types are meant to help you, not be a pain you have to endure. I may be the wrong person to argue about what’s easy to understand – in the end, I’m fine with regular expressions.
And remember – take care of your teeth types!
We could use the same pattern we used for the REST API, but we simply didn’t; at least not yet. It also wasn’t that much of a problem so far.
It’s passed down in props or via context.
It’s not the only possible approach, though. Rust delays the type inference until the end of scope (kind of; it’s not how it formally works). Consider this code:
If you’d like to create an alias for some nested type in GraphQL Codegen, you can define a fragment for it. While at it, check out fragment masking.