Search code examples
angulartypescriptangular-httpclientpartial-application

How do I express partial function application in Typescript 3.x in a type-safe way?


I'm working on an Angular codebase that does some standard postprocessing on most API calls. This is done in a service class that wraps HttpClient.get() etc. in methods that pipe the returned observable through a bunch of intercepting methods.

To my dismay this is done using the pattern:

public get(url, options?) {
  const method = 'GET';
  return this._http.get(url, options).pipe(
    map((resp: any) => {
      console.log(`Calling ${method} ${url} returned`, resp);
      return resp;
    }),
    catchError(err => {
      console.error(`Calling ${method} ${url} failed`, err);
      throw(err);
    }),
  );
}

which annoys me, because the options parameter has a fairly hairy type the exact shape of which is important for TypeScript's overload resolution and determines the return type of the call.

I'm trying to figure out a less copy-pastey, typesafe way of wrapping the call, but I can't figure out how to capture the type of the options parameter.

What I have so far is:

export class HelloComponent {
  @Input() name: string;
  response: any;

  constructor(private _http: HttpClient) {
    const httpClientGet = this.method('get');

    const response = this.call('get', 'https://example.com/foo/bar');

    response.subscribe(
      data => this.response = JSON.stringify(data, null, 2),
      (err: HttpErrorResponse) => this.response = err.error
    );

  }

  call<T>(
    method: keyof HttpClient,
    url: string,
    handler: <TObs extends Observable<T>>(partial: (options?: any) => TObs) => TObs = (_ => _())) /* HOW DO I GET THE CORRECT TYPE OF OPTIONS HERE? */
    : Observable<T> {

    const u = new URL(url);
    console.info(`Calling ${method.toUpperCase()} ${u.pathname}`);

    const result = handler(this._http[method].bind(this._http, url)).pipe(
      map((resp) => {
        console.log(`Calling ${method.toUpperCase()} ${u.pathname} returned`, resp);
        return resp;
      }),
      catchError(err => {
        console.error(`Calling ${method.toUpperCase()} ${u.pathname} failed`, err);
        throw err;
      })
    )

    console.info('Returning', result);
    return result;
  }

  method<TMethod extends keyof HttpClient>(name: TMethod): HttpClient[TMethod] {
    return this._http[name];
  }
}

That is:

  • I know I can capture the signature of the method I'm calling on HttpClient by passing its name as a string literal to a method correctly, hovering over httpClientGet gives me the overloads for HttpClient.get()
  • call() is the wrapper function that does the sameish interception as the original, but passes HttpClient.get() with the URL already partially applied using Function.bind() to an optional callback.
  • The role of this callback is to provide the value of the options parameter to from the HttpClient methods if the caller wants to.

Where I'm lost is figuring out what the right construct is to tell TypeScript that the parameters of the partial callback should be the parameters of the corresponding HttpClient method, except the first (url) parameter. Or some alternative way letting me do this in a type-safe fashion, i.e. autocomplete and overload resolution should work correctly if I do:

this.call('get', 'https://example.com/foo/bar',
  get => get({
    // options for `HttpClient.get()`
  })
);

Stackblitz link for a runnable example of the above: https://stackblitz.com/edit/httpclient-partial


Solution

  • Someone with a knowledge of angular can flesh this out or give a more targeted answer, but I'm going to address this question:

    tell TypeScript that the parameters of the partial callback should be the parameters of the corresponding HttpClient method, except the first (url) parameter.

    If you are trying to strip the first parameter off a function type, this is possible in TypeScript 3.0 and up:

    type StripFirstParam<F> = 
      F extends (first: any, ...rest: infer A)=>infer R ? (...args: A)=>R : never
    

    So for your call() method I'd imagine it looking something like this:

    declare function call<M extends keyof HttpClient>(
      method: M, 
      url: string, 
      handler: <TO>(
        partial: (
          ...args: (HttpClient[M] extends (x: any, ...rest: infer A) => any ? A : never)
        ) => TO
      ) => TO
    ): void;
    

    where I've intentionally left out the return type of call and the supertype of TO that you apparently already know how to deal with.

    The important part is that args rest parameter, which is inferred to be the same as the arguments of HttpClient[M] with the first parameter stripped off. That should give you the hints you expect when you call call():

    call('get', 'https://example.com/foo/bar',
      get => get({
        // hints should appear here
      })
    );
    

    Anyway, hope that helps point you in the right direction. Good luck!