简体   繁体   中英

How to let TypeScript infer the specific types of an object implementing a generic interface?

In this code example, TypeScript does not seem to know about the exact type of userService.getByUserId in the very last line, which should be (userId: string) => ServiceResult<User> . Instead, it insists on its more generic type (...args: Array<any>) => ServiceResult<User> , defined in the Service interface.

How can I make sure that both any object that implements a Service adheres to the interface definition and TypeScript knows about the actual type definitions of any implemented Service , eg userService.getByUserId in this example?

Thanks!

type ServiceResult<T> = T | Promise<T> | undefined;

/**
 * When implementing a `Service`, there must be a `get` function that takes a
 * single argument `id` of type `string` and returns a `ServiceResult`.
 * 
 * Any other property on an object that implements `Service` must be a function
 * that takes any number of arbitrary arguments and returns a `ServiceResult`.
 */
interface Service<T> {
  get: (id: string) => ServiceResult<T>;
  [key: string]: (...args: Array<any>) => ServiceResult<T>;
}

type User =  {
  id: string;
  name?: string;
};

const users: Array<User> = [
  {
    id: 'a',
    name: 'Jon',
  },
];


const userService: Service<User> = {
  get: (id: string) =>  users.find((user) => user.id === id),
  /**
   * Here, `getByUserId` has a well defined type of
   * `(userId: string) => ServiceResult<User>`
   */
  getByUserId: (userId: string) =>  users.find((user) => user.id === userId),
};

/**
 * However, when invoking `getByUserId`, TypeScript does not seem to know about
 * its exact type definition and instead insists on
 * `(...args: any[]) => ServiceResult<User>`
 */
userService.getByUserId();

Since userService has no knowledge about the actual type of getByUserId , you have to overload it explicitly,

const userService: Service<User> & {getByUserId: (userId: string) => ServiceResult<User>} = {
  get: (id: string) =>  users.find((user) => user.id === id),
  getByUserId: (userId: string) =>  users.find((user) => user.id === userId),
};
userService.getByUserId(); // error now

When you annotate a variable declaration with a (non-union) type, the compiler will forget about the type of any narrower expression that you assign to it. There is no built-in functionality to say "please infer the variable's type but make sure it is assignable to this given type". You either leave off the annotation and let the compiler infer the type of the variable, or you annotate and get the exact type you annotated.

There is an existing GitHub issue, microsoft/TypeScript#25214 , asking for such a feature, but I don't think that much is going to happen there.


One way to proceed is to just not annotate the variable, and if your initializer is incorrect, you'll get an error when you try to use it somewhere later. For example:

const userServiceNoAnnotation = {
    get: (id: string) => users.find((user) => user.id === id),
    getByUserId: (userId: string) => users.find((user) => user.id === userId),
};

const badUserServiceNoAnnotation = {
    get: (id: number) => users.find((user) => user.id === id.toString()),
    getByUserId: (userId: string) => users.find((user) => user.id === userId),
};

Here I've left off the annotation, and the compiler infers the type of each variable as something narrow enough that it won't forget the parameters of getByUserId() . There's no error in badUserServiceNoAnnotation , yet . But when we actually try to use them as a Service<User> , we get the behavior you want:

function expectUserService(us: Service<User>) { }
expectUserService(userServiceNoAnnotation);
expectUserService(badUserServiceNoAnnotation); // error!
// -------------> ~~~~~~~~~~~~~~~~~~~~~~~~~~
//   Types of property 'get' are incompatible.

This might be good enough for your purposes.


If you want an error closer to the declaration of your variable, you can abstract the "don't-annotate-and-try-to-use-it" to a helper function that uses it right away:

const checkType = <T,>() => <U extends T>(u: U) => u;

const checkUserService = checkType<Service<User>>();

Here, checkType<T>() lets you specify what type T you'd like to check for, and returns an identity function that checks its input against type T without widening it. So checkUserService is such an identity function for Service<User> , and we can now use that instead of annotating. Bad Service<User> s get the errors you expect:

const badUserService = checkUserService({
    get: (id: number) => users.find((user) => user.id === id.toString()), // error!
//  ~~~ <-- id param is incompatible    
    getByUserId: (userId: string) => users.find((user) => user.id === userId),
});

While good ones stay narrow enough for your purposes:

const userService = checkUserService({
    get: (id: string) => users.find((user) => user.id === id),
    getByUserId: (userId: string) => users.find((user) => user.id === userId),
});

userService.getByUserId(); // error!
// -------> ~~~~~~~~~~~~~
// Expected 1 arguments, but got 0.

Playground link to code

You just need to declare your UserService as a class that implements your interface. Like so, you'll be able to narrow down certain methods to their correct types - and so you can tell at compile time what arguments a function requires.

type ServiceResult<T> = T | Promise<T> | undefined;

interface Service<T> {
    get: (id: string) => ServiceResult<T>;
    [key: string]: (...args: Array<any>) => ServiceResult<T>;
}

interface User {
    id: string;
    name: string;
}
const users: User[] =[
    {
        id: "a",
        name: "Jon"
    }
];

class UserService implements Service<User> {
    // This is required because Typescript complains if we don't add this line
    // EVERY property of this class must be this type of function
    [key: string]: (...args: Array<any>) => ServiceResult<User>;

    get (id: string) {
        return users.find(u => u.id === id);
    }

    getByName(name: string) {
        return users.find(u => u.name === name);
    }

    async getByIdAsync(id: string) {
        return new Promise<User>((resolve,reject) => {
            const user = this.get(id);
            if(user === undefined) {
                return reject();
            }
            return resolve(user);
        });
    }
}

// Create the class however you like. Dependency Injection, just call `new`, w/e
declare const userService: UserService;

// result is User|undefined
const result = userService.getByName('');
// asyncResult is Promise<User>
const asyncResult = userService.getByIdAsync('');

When you declare an object as Service<T> , TS can't predict what will happen to that object, for example:

const userService: Service<User> = {
  get: (id: string) =>  users.find((user) => user.id === id),
  /**
   * Here, `getByUserId` has a well defined type of
   * `(userId: string) => ServiceResult<User>`
   */
  getByUserId: (userId: string) =>  users.find((user) => user.id === userId),
};
// this is perfectly legal
userService.getByUserId = (userId:string, somethingElse:number) => users.find((user) => user.id === userId);
// woops! getByUserId now has a different type, even though it conforms to Service<User>

The technical post webpages of this site follow the CC BY-SA 4.0 protocol. If you need to reprint, please indicate the site URL or the original address.Any question please contact:yoyou2525@163.com.

 
粤ICP备18138465号  © 2020-2024 STACKOOM.COM