1

I am trying to find a relatively generic way to type POST bodies and the responses I get back in conjunction with their API routes (in a nextjs app).

For this I want the compiler to force me to add a body type and a return type to all the API routes, which I achieved with the following interface:

export interface PostTypeMapping extends Record<string, {body: unknown, return: unknown}> {
  "/api/taskCompletions": {body: PostCompletionBody, return: void},
  "/api/task": {body: PostTaskBody, return: void},
}

So far so good. I can use this type in my API route like so:

 async (req, res: NextApiResponse<PostTypeMapping["api/task"]["return"]>) => {
  //...
}

But when I try to write a wrapper that automatically infers the POST body and the return type from the URL, I get an error in the line with await fetch(url,:

Argument of type 'keyof PostTypeMapping' is not assignable to parameter of type 'RequestInfo'. Type 'number' is not assignable to type 'RequestInfo'

export async function fetchPost<T extends keyof PostTypeMapping>(url: T, body: PostTypeMapping[T]["body"]): Promise<PostTypeMapping[T]["return"]> {
  try {
    const res = await fetch(url, { // <- The error above occurs here
      method: "POST",
      body: JSON.stringify(body),
      headers: {
        "Content-Type": "application/json",
      },
    });
    if(res.status === 201 || res.status === 204){
      return;
    }
    return res.json();
  } catch (err: any){
    return {message: "Fehler: " + err.message};
  }
}

Why can url, which is typed as keyof PostTypeMapping, be a number?

I investigated further and for the most part, the extends Record<string, {body: unknown, return: unknown}> does seem to do what I want (force me to add a body and return type to all the entries of the interface), but allows numeric keys as well as strings. Why? Both cases that are not allowed are good.

export interface PostTypeMapping extends Record<string, {body: unknown, return: unknown}> {
  "/api/taskCompletions": {body: PostCompletionBody, return: void},
  "/api/task": {body: PostTaskBody, return: void},
  1: {body: void, return: void}, // why is this legal? 1 is not a string
  2: "asd" // not allowed -> Property '2' of type '"asd"' is not assignable to 'string' index type '{ body: unknown; return: unknown; }'
  "asd": "asd" // not allowed -> Property '"asd"' of type '"asd"' is not assignable to 'string' index type '{ body: unknown; return: unknown; }
}

typescript playground

EDIT:

A simplified reproduction of the problem can be found at https://tsplay.dev/Nr5X2w thanks to T.J. Crowder

Taxel
  • 3,859
  • 1
  • 18
  • 40
  • 2
    Keys of objects can only be strings or symbols. Anything else is converted to a string. If you do `myObj[1] = "hello"` or `{1: "hello" }` that adds a string key `"1"`. Same way how if you do `{ foo: "world" }` you get the string `"foo"` as key. – VLAZ Apr 11 '22 at 09:00
  • Then why do I get the error in my Post wrapper `Type 'number' is not assignable to type 'RequestInfo'`? – Taxel Apr 11 '22 at 09:05
  • @Taxel - What is the definition of `RequestInfo`? I don't see that anywhere in your question other than the text. But it sounds like you're trying to assign a number to a variable/property/parameter that is defined as an object type. – T.J. Crowder Apr 11 '22 at 09:06
  • `RequestInfo` is the definition of the first argument for `fetch`: `function fetch(input: RequestInfo, init?: RequestInit | undefined): Promise`. From `lib.dom.d.ts`: `type RequestInfo = Request | string;` – Taxel Apr 11 '22 at 09:07
  • @Taxel - That `RequestInfo` is `type RequestInfo = Request | string;`, where `Request` is an object type. Numbers are neither strings nor objects, so you can't assign a number to it. But I guess TypeScript treats object property names specially, since as you say, you can use `1` (which is converted to `"1"`) in an object literal assigned to `Record`, even though you can't use a `number` where a string parameter is expected: https://tsplay.dev/N7OoBN Just another of TypeScript's pragmatic choices I suspect. – T.J. Crowder Apr 11 '22 at 09:12
  • 1
    But that doesn't really explain it, and I have to say I really don't get it. The key type of `Record` is `string`. So far so good. The key type of an interface extending `Record`, though, isn't just `string` (which is fair enough) but also includes `number` (which seems odd). Here's a simpler repro: https://tsplay.dev/Nr5X2w – T.J. Crowder Apr 11 '22 at 09:23
  • 1
    The question here is really "why does `Record` *prohibit* `number` keys when other types with string index signatures *allow* them?". And it looks like it might fall out of [the support for numeric/symbol keys added in TS2.9](https://www.typescriptlang.org/docs/handbook/release-notes/typescript-2-9.html#support-number-and-symbol-named-properties-with-keyof-and-mapped-types); somehow `Record` is left as a mapped type and does not explicitly become a string index signature until you start doing other stuff to it. Seems weird though. – jcalz Apr 11 '22 at 13:40
  • 1
    Okay, the answer is at https://github.com/microsoft/TypeScript/issues/48269. Numeric keys are allowed for string index signatures and always have been, but `keyof {[k: string]: any}` being `string | number` was introduced in TS2.9. `Record` should be equivalent to `{[k: string]: any}` conceptually but is implemented as a mapped type and this is not augmented, in order for mapped types to have proper variance (e.g., `Record` is assignable to `Record

    ` iff `P` is assignable to `K`), and the inconsistency cannot be eliminated.

    – jcalz Apr 11 '22 at 13:47
  • @Taxel would you like me to write up an answer with this information? Or am I missing something about the question? – jcalz Apr 11 '22 at 13:48
  • @jcalz it would be great if you could write the answer with that. – Taxel Apr 11 '22 at 13:48
  • Okay I'll do it when I get a chance. – jcalz Apr 11 '22 at 13:49

1 Answers1

2

See microsoft/TypeScript#48269 for an authoritative answer to this question.

Numeric keys have always been allowed for string index signatures, because non-symbol keys in JavaScript are always coerced to strings first. So the "number" keys should really be more like "numeric strings", but TypeScript allows you to think of them as numbers to support indexing into arrays with numbers.

Prior to TypeScript 2.9, keyof {[k: string]: any} would have just been string. But TypeScript 2.9 introduced support for number and symbol properties with keyof. Part of this change is that keyof X where X has a string index signature now includes number. So keyof {[k: string]: any} is string | number. This is working as intended.

But for mapped types like Record, the compiler does not immediately augment the keys this way. Apparently it is important that Record<K, V> be properly contravariant in K (according to the comment in ms/TS#48269 anyway).

But Record<string, any> is, after all, equivalent to {[k: string]: any}, and therefore we have an inconsistency. TypeScript doesn't take consistency as its most important design goal; indeed, it is a non-goal of TypeScript to have a provably correct type system. Productivity is, in some sense, more important than correctness. If fixing an inconsistency would make TypeScript very annoying to use for a lot of people, then it's better to leave the inconsistency. And this is apparently one of those situations; according to the same comment, the inconsistency here can't be eliminated (presumably without destroying some oft-used part of the language, such as numeric keys for arrays), so it stays.

Oh well!

jcalz
  • 264,269
  • 27
  • 359
  • 360
  • Thanks for the detailed answer. If someone is interested in the very simple fix I added to make my code work: `await fetch(url.toString(), //...` did the trick. – Taxel Apr 12 '22 at 08:03