Search code examples
typescriptapiinterfacetype-safetytypesafe

Simple, typesafe consumption of an API in Typescript


I struggled with naming this question - open to changing it.

I'm fairly new to typescript, and I'm trying to consume an API in a generic, typesafe, and very extensible way.

Taking inspiration from RESTyped, I've defined a generic "API definition" interface:

interface ApiBase {
    [route: string]: ApiRoute   
}

interface ApiRoute {
    query: { [key: string]: string }
    body: any
    response: any
}

interface ApiSpec {
    [route: string]: {
        [method: string]: ApiRoute  
    }
}

and this can be used to define the types for multiple API endpoints, like so:

interface MyApi extends ApiSpec {
    "/login": {
        "POST": {
            body: {
                username: string,
                password: string
            },
            response: {
                token: string
            }   
        }   
    },
    "/user": {
        "GET": {
            query: {
                "username": string
            },
            response: {
                "email": string,
                "name": string
            }
        }
    }
}

I suspect that it's possible for a generic class to consume these types, and provide type-safe methods for them. Something like:

const api = ApiService<MyApi>();
api.post("/login", {
    // This body is typesafe - won't compile if it doesn't match the spec
    username: "johnny99",
    password: "hunter2"
});

Where the post() method won't compile if the object doesn't match the body defined in the MyApi interface.

Unfortunately, I'm pretty lost for where to go from here. Something like this:

class ApiService<T> {
    post(route: string, body: T[route].body): T[route].response {
        // todo
    }
}

Which obviously doesn't compile. How can I access the subtype in the MyApi interface? T[route].body is definitely wrong. How do I do this?

Cheers

EDIT ------------------------------------------

I did some reading, and I think I'm getting somewhere!

This works on the typescript playground:

class ApiService<API extends ApiSpec> {
    async post<Path extends Extract<keyof API, string>>(
        route: Path,
        data: API[Path]["POST"]["body"]
    ): Promise<API[Path]["response"]> {
        const resp = await fetch(route, {
            method: "POST",
            body: JSON.stringify(data),
        });
        return await resp.json();
    }
}

And works perfectly when calling a route that exists:

const api = new ApiService<MyApi>();

// Will give an error if the wrong "body" is passed in!
api.post("/login", {
    username: "johnny99",
    password: "rte"
});

but it also works when calling a route that doesn't exist, which is not what I want to happen.

// Should error, but doesn't!
api.post("/bad", {
    whatever: ""
});

I'm also a bit worried about my post() implementation – what happens when the object given by resp.json() is different to what's defined in the type definition? Will it throw a runtime error – should I always call it in try/catch guards, or can I somehow make the Promise fail instead?


Solution

  • Before I get to the answer, I tried to reproduce your situation in the Playground and noticed I needed to change the type of ApiRoute to

    interface ApiRoute {
      query?: { [key: string]: string }; // optional
      body?: any; // optional
      response: any;
    }
    

    to avoid errors. If that wasn't an error for you, it's because you're not using --strictNullChecks, which you really should. I will assume we're doing strict null checking from now on.


    I think your problem here is that your ApiSpec interface says that it has ApiRoute properties for every possible key and every posible subkey:

    declare const myApi: MyApi;
    myApi.mumbo.jumbo; // ApiRoute
    myApi.bad.POST.body; // any
    

    That code isn't an error. So, when you call

    api.post("/bad", {
      whatever: ""
    });
    

    you are essentially just looking up the body property of some myApi.bad.POST, which is not an error.


    So how do we fix this? I think it might make more sense to express the type of ApiSpec as a generic constraint on possible MyApi-like types instead of a concrete type with a pair of nested index signatures:

    type EnsureAPIMeetsSpec<A extends object> = {
      [P in keyof A]: { [M in keyof A[P]]: ApiRoute }
    };
    

    That's a mapped type which turns an A like {foo: {bar: number, baz: string}} into {foo: {bar: ApiRoute, baz: ApiRoute}}. So if you have an A extends EnsureAPIMeetsSpec<A>, then you know A meets your intended specifications (more or less... I think you might think of making sure each property of A is itself an object type).

    And you don't need to say MyApi extends ApiSpec. You can just leave it like

    interface MyApi { /* ... */ }
    

    and if it's bad it won't be accepted by ApiService. Or, if you want to know right away, you could do it like this:

    interface MyApi extends EnsureAPIMeetsSpec<MyApi> { /* ... */ }
    

    Now to define ApiService. Before we get there, let's make some type helpers we'll use shortly. First, PathsForMethod<A, M> takes an api type A and a method name M, and returns the list of string-valued paths that support that method:

    type PathsForMethod<A extends EnsureAPIMeetsSpec<A>, M extends keyof any> = {
      [P in keyof A]: M extends keyof A[P] ? (P extends string ? P : never) : never
    }[keyof A];
    

    And then Lookup<T, K>:

    type Lookup<T, K> = K extends keyof T ? T[K] : never;
    

    is basically T[K] except if the compiler can't verify that K is a key of T, it returns never instead of giving a compiler error. This will be useful because the compiler isn't smart enough to realize that A[PathsForMethod<A, "POST">] has a "POST" key, even though that's how PathsForMethod was defined. It's a bit of a wrinkle we have to overcome.

    Okay, here's the class:

    class ApiService<A extends EnsureAPIMeetsSpec<A>> {
      async post<P extends PathsForMethod<A, "POST">>(
        route: P,
        data: Lookup<A[P], "POST">["body"]
      ): Promise<Lookup<A[P], "POST">["response"]> {
        const resp = await fetch(route, {
          method: "POST",
          body: JSON.stringify(data)
        });
        return await resp.json();
      }
    }
    

    Going over that... we constrain A to EnsureAPIMeetsSpec<A>. We then constrain the route parameter to be only those paths in PathsForMethod<A, "POST">. This will automatically exclude the "/bad" route you tried in your code. Finally we can't just do A[P]["POST"] without compiler error, so we do Lookup<A[P], "POST"> instead, and it works fine:

    const api = new ApiService<MyApi>(); // accepted
    
    const loginResponse = api.post("/login", {
      username: "johnny99",
      password: "rte"
    });
    // const loginResponse: Promise<{ token: string; }>
    
    api.post("/bad", { // error!
      whatever: ""
    }); // "/bad" doesn't work
    

    That's the way I'd proceed to start with. After that you might want to narrow down your definition of ApiSpec and ApiRoute. For example, perhaps you want two types of ApiRoute, some of which require body and others prohibit it. And you can likely represent your http methods as some union of string literals like "POST" | "GET" | "PUT" | "DELETE" | ... and narrow down ApiSpec so that "POST" methods require body while "GET" methods prohibit it, etc. This would possibly make it easier for the compiler to ensure that you only call post() on the right paths and that the body of such posts will be required and defined instead of possibly undefined.

    Anyway, hope that helps; good luck!

    Link to code