Home > Net >  Stricter typing of possible call signatures defined in interfaces in TypeScript
Stricter typing of possible call signatures defined in interfaces in TypeScript

Time:11-28

I want to define whether a function should contain an argument via an interface. The library I'm developing calls for many different methods to be generated, and hardcoding those methods would require too much maintenance; so I figured that types would be a good place to define such things.

Perhaps this is best explained with code. Here's a library that abstracts some rest API:

interface RequestInterface {
  endpoint: string
  body?: unknown
}

interface GetPosts extends RequestInterface {
  endpoint: '/posts'
  body: never
}

interface CreatePost extends RequestInterface {
  endpoint: '/posts'
  body: string
}

function Factory<R extends RequestInterface> (endpoint: R['endpoint']) {

  return (body?: R['body']): void => {

    console.log(`Hitting ${endpoint} with ${body}`)

  }
}

const myLibrary = {

  getPosts: Factory<GetPosts>('/posts'),
  createPosts: Factory<CreatePost>('/posts'),

}

myLibrary.getPosts('something') // => Correctly errors
myLibrary.createPosts(999)      // => Correctly errors
myLibrary.createPosts()         // => I want this to error

In the above, I'm defining the endpoint and body of a particular type of request in my interfaces. Although the TypeScript compiler correctly guards me against passing the wrong argument types, it doesn't guard me against not passing a value when one is required.

I understand why TypeScript doesn't error (because the method defined in factory can be undefined according to my typings), but I figured the above code was a good way of describing what I want to achieve: a quick, declarative library of methods which satisfy a particular type.

A Possible Solution

If I'm willing to extend my interfaces from two separate interfaces (one or the other) then I can achieve something close to what I want using Construct Signatures:

interface RequestInterface {
  endpoint: string
  call: () => void
}

interface RequestInterfaceWithBody {
  endpoint: string
  call: {
    (body: any): void
  }
}

interface GetPosts extends RequestInterface {
  endpoint: '/posts'
}

interface CreatePost extends RequestInterfaceWithBody {
  endpoint: '/posts'
  call: {
    (body: string): void
  }
}

function Factory<R extends RequestInterface|RequestInterfaceWithBody> (endpoint: R['endpoint']): R['call'] {

  return (body): void => {

    console.log(`Hitting ${endpoint} with ${body}`)

  }
}

const myLibrary = {

  getPosts: Factory<GetPosts>('/posts'),
  createPosts: Factory<CreatePost>('/posts'),

}

myLibrary.getPosts()            // => Correctly passes
myLibrary.getPosts('something') // => Correctly errors
myLibrary.createPosts(999)      // => Correctly errors
myLibrary.createPosts()         // => Correctly errors
myLibrary.createPosts('hi')     // => Correctly passes

Aside from the fact that I need to pick between two "super" types before extending anything, a major problem with this is that the Construct Signature argument is not very accessible.

Although not demonstrated in the example, the types I create are also used elsewhere in my codebase, and the body is accessible (i.e GetPosts['body']). With the above, it is not easy to access and I'll probably need to create a separate re-usable type definition to achieve the same thing.

CodePudding user response:

You almost hit the spot with your initial types. Two changes required:

  1. Make body of GetPosts of type void
  2. Make body of returned function required
interface RequestInterface {
  endpoint: string;
  body?: unknown;
}

interface GetPosts extends RequestInterface {
  endpoint: "/posts";
  body: void;
}

interface CreatePost extends RequestInterface {
  endpoint: "/posts";
  body: string;
}

function Factory<R extends RequestInterface>(endpoint: R["endpoint"]) {
  return (body: R["body"]): void => {
    console.log(`Hitting ${endpoint} with ${body}`);
  };
}

const myLibrary = {
  getPosts: Factory<GetPosts>("/posts"),
  createPosts: Factory<CreatePost>("/posts"),
};

// @ts-expect-error
myLibrary.getPosts("something");
// @ts-expect-error
myLibrary.createPosts(999);
// @ts-expect-error
myLibrary.createPosts();

myLibrary.getPosts();
myLibrary.createPosts("Hello, StackOverflow!");

TS Playground

Explanation

never type tells compiler that this should never happen. So, it someone tries to use GetPosts, it's an error, since it should never happen. void (undefined in this case should be also fine) tells that value should not be there.

Making body required in returned function makes it required. But since it is void for GetPosts, you can call it like myLibrary.getPosts(undefined) or simply myLibrary.getPosts() which is equivalent

  • Related