ajmnz
ajmnz

Reputation: 912

Unable to index nested property of dynamic type

I have this interface, which consists of different dynamic keys (API Routes / paths), followed by a method and the response.

interface ApiRoutes {
  "auth/login": {
    POST: {
      response: {
        username: string;
        token: string;
      };
    };
  };
  "users/me": {
    GET: {
      response: {
        username: string;
        name: string;
        age: number;
      };
    };
  };
}

I'm trying to write a type that will extract a response given the path and the method. My approach was the following:

type ApiResponse<
  Path extends keyof ApiRoutes,
  Method extends keyof ApiRoutes[Path]
> = ApiRoutes[Path][Method]["response"];

But the compiler complains, saying that "response" can't be used to index

Type '"response"' cannot be used to index type 'ApiRoutes[Path][Method]'.ts(2536)

Oddly enough, if I ignore the error, VS Code will still show suggestions/autocomplete the response object as if nothing happened.

const response: ApiResponse<"auth/login", "POST"> = {
  username: "foo",
  token: "bar",
};

Reference

Upvotes: 4

Views: 312

Answers (3)

jcalz
jcalz

Reputation: 327614

As @futur says, this is a backlogged compiler bug at microsoft/TypeScript#21760. But I would avoid //@ts-ignore except as a last resort.

Instead, my go-to workaround when the compiler loses track of some intended constraint of a generic type is to use the Extract<T, U> utility type to remind it. Let's say I have some type expression T that depends on some as-yet unspecified generic type parameters. I know that T is assignable to U but the compiler can't see it for some reason.

Then, instead of writing T, I write Extract<T, U>. The compiler will concede that Extract<T, U> is assignable to U no matter what T is. Later, when the type parameters in T are specified, as long as it turns out that I was right about T being assignable to U, the type Extract<T, U> will evaluate to just T as desired. (If I was wrong however, then it will evaluate to never, which will probably not be what I wanted... so I should be careful.)


In your case you have ApiRoutes[Path][Method] and you'd like it to be seen as something with a response key. So it should be assignable to {response: any} (or maybe {response: object} or {response: SomeResponseType}). If we use Extract<ApiRoutes[Path][Method], {response: any}>, now the compiler will let you index into it with "response":

type ApiResponse<
    Path extends keyof ApiRoutes,
    Method extends keyof ApiRoutes[Path]
    > = Extract<ApiRoutes[Path][Method], { response: any }>["response"];

Now there's no error. And you can check that it works as expected:

type R = ApiResponse<"auth/login", "POST">;
/* type R = {
    username: string;
    token: string;
} */

const response: ApiResponse<"auth/login", "POST"> = {
    username: "foo",
    token: "bar",
};

Playground link to code

Upvotes: 3

Subrato Pattanaik
Subrato Pattanaik

Reputation: 6049

Credit to @futur answer

One of the comments in the issue that @futur has shared is exactly the reason for the cause of the problem.

The nut of the problem is that the base constraint of U extends keyof IExample[T] is a string, even though we would like it to be "bar". This means that IExample[T][U] doesn't have a constraint, and therefore can't be indexed by anything except keyof IExample[T][U], which "baz" is not.

In your case, the constraint Method extends keyof ApiRoutes[Path] is a string which means that ApiRoutes[Path][Method] doesn't have any constraint.

A simple workaround for your problem would be

type ApiResponse<
  Path extends keyof ApiRoutes,
  Method extends keyof ApiRoutes[Path],
  Response extends keyof ApiRoutes[Path][Method] 
> = ApiRoutes[Path][Method][Response];

const response: ApiResponse<"auth/login", "POST", 'response'> = {
  username: "foo",
  token: "bar",
};

We also need to constraint the string response to keyof ApiRoute[Path][Methiod] which is a string literal response.

Upvotes: 1

futur
futur

Reputation: 1843

Unfortunately, this appears to be a known issue, with no clear timeline on resolution.

In essence, TypeScript seems to "forget" that Method is keyof one of ApiRoutes' nested objects. You can see this for yourself if you try adding in a third generic, which I'm calling Response in the following example.

type ApiResponse<
  Path extends keyof ApiRoutes,
  Method extends keyof ApiRoutes[Path],
  Response extends keyof ApiRoutes[Path][Method] = 'response'
> = ApiRoutes[Path][Method]['response'];

This will result in the following error:

Type 'string' is not assignable to type 'keyof ApiRoutes[Path][string]'.
                                                               ^^^^^^

Note that it states string for the type of Method, indicating that the compiler sees no relation between it and a nested object within ApiRoutes.

The only real way to resolve this, for the time being, seems to be to @ts-expect-error or @ts-ignore the last line of your type declaration.

Upvotes: 4

Related Questions