最新消息:雨落星辰是一个专注网站SEO优化、网站SEO诊断、搜索引擎研究、网络营销推广、网站策划运营及站长类的自媒体原创博客

Typescript function overload with generics doesn't work as expected - Stack Overflow

programmeradmin1浏览0评论

I'm trying to create a function that connects to the database and queries for car details. I need the function to have 3 overloads, the first overload returns the basic car details that exist in the cars table without joining another table to it, the second one returns basic data + the data that came from the other tables by foreign keys, the third one returns custom fields of the table after joining.

After some thinking, I wrote the following code (this code is a simulator of the real code):

type t_car_base = {
  carID: number,
  model: string,
  color: string,
  registratinPlateID: number
}

type t_car_joined = t_car_base & {
  registrationPlateNumber: string
}

export type t_findOne = {
  (carID: number): Promise<t_car_base>
  (carID: number, joinEntities?: true): Promise<t_car_joined>
  <K extends keyof t_car_joined>(carID: number, fields?: K[]): Promise<Pick<t_car_joined, K>>
}

const findOne: t_findOne = <K extends keyof t_car_joined>(
  carID: number,
  joinEntities?: true,
  fields?: K[]
) => {
  return new Promise<t_car_base | t_car_joined | Pick<t_car_joined, K>>((resolve, reject) => {
    if (fields) {
      resolve({} as Pick<t_car_joined, K>)
    } else if (joinEntities) {
      resolve({} as t_car_joined)
    } else {
      resolve({} as t_car_base)
    }
  })
}

findOne(1).then((res) => res.color) // Expected to get access to the basic details but got to the full details
findOne(1, true).then((res) => res.registrationPlateNumber) // Expected to get access to the full details
findOne(1, ['model']).then((res) => res.model) // Expected to get access to custom fields

But I get the following error message:

Type 'Pick<t_car_joined, any>' is missing the following properties from type 't_car_base': carID, model...

I could access the full details by only passing the carID argument instead of the ability to access only the basic details

I'm trying to create a function that connects to the database and queries for car details. I need the function to have 3 overloads, the first overload returns the basic car details that exist in the cars table without joining another table to it, the second one returns basic data + the data that came from the other tables by foreign keys, the third one returns custom fields of the table after joining.

After some thinking, I wrote the following code (this code is a simulator of the real code):

type t_car_base = {
  carID: number,
  model: string,
  color: string,
  registratinPlateID: number
}

type t_car_joined = t_car_base & {
  registrationPlateNumber: string
}

export type t_findOne = {
  (carID: number): Promise<t_car_base>
  (carID: number, joinEntities?: true): Promise<t_car_joined>
  <K extends keyof t_car_joined>(carID: number, fields?: K[]): Promise<Pick<t_car_joined, K>>
}

const findOne: t_findOne = <K extends keyof t_car_joined>(
  carID: number,
  joinEntities?: true,
  fields?: K[]
) => {
  return new Promise<t_car_base | t_car_joined | Pick<t_car_joined, K>>((resolve, reject) => {
    if (fields) {
      resolve({} as Pick<t_car_joined, K>)
    } else if (joinEntities) {
      resolve({} as t_car_joined)
    } else {
      resolve({} as t_car_base)
    }
  })
}

findOne(1).then((res) => res.color) // Expected to get access to the basic details but got to the full details
findOne(1, true).then((res) => res.registrationPlateNumber) // Expected to get access to the full details
findOne(1, ['model']).then((res) => res.model) // Expected to get access to custom fields

But I get the following error message:

Type 'Pick<t_car_joined, any>' is missing the following properties from type 't_car_base': carID, model...

I could access the full details by only passing the carID argument instead of the ability to access only the basic details

Share Improve this question edited Feb 16 at 16:33 jcalz 329k29 gold badges438 silver badges441 bronze badges asked Feb 16 at 13:41 Abdulkerim AwadAbdulkerim Awad 1313 silver badges12 bronze badges 4
  • @jcalz I'm sorry I replaced "carTypeName" by "registrationPlateNumber" – Abdulkerim Awad Commented Feb 16 at 14:38
  • 1 Does this approach meet your needs? If so I'll write an answer or find a duplicate. If not, please edit to clarify your use cases. – jcalz Commented Feb 16 at 14:42
  • @jcalz It really works, Thank you so much, but I have some questions I need you to answer with some references, or if you want you can write an answer here 1. What changed when you set the entire problem as t_findOne and when I set the type of function like this: const findOne: t_findOne 2. How the overloads order can affect the Typescript compiler inferring – Abdulkerim Awad Commented Feb 16 at 14:48
  • 1 Actually your implementation is just wrong, because the fields parameter of the implementation will never get an argument. TS function signatures don't exist in JS (are you aware of type erasure?) Function params/args are determined by position, not by name. (The x and y names in function foo(x, y){} don't mean anything, they just accept whatever is passed in for the 1st and 2nd arguments.) Your implementation's 2nd param should be joinEntitiesOrFields and then you test it. Do you want to edit so your function actually possibly works at runtime? Or is that out of scope here? – jcalz Commented Feb 16 at 16:12
Add a comment  | 

1 Answer 1

Reset to default 1

All you really need to do to get this working for callers is not to make joinEntities or fields optional parameters:

export type t_findOne = {
  (carID: number): Promise<t_car_base>
  (carID: number, joinEntities: true): Promise<t_car_joined>
  <K extends keyof t_car_joined>(carID: number, fields: K[]): Promise<Pick<t_car_joined, K>>
}

findOne(1).then((res) => res.color)
//               ^? (parameter) res: t_car_base
findOne(1, true).then((res) => res.registrationPlateNumber)
//                     ^? (parameter) res: t_car_joined
findOne(1, ['model']).then((res) => res.model) 
//                          ^? (parameter) res: Pick<t_car_joined, "model">

Ideally when you write overloads, none of the call signatures should overlap. That is, you would like it so that no function call matches more than one of the call signatures. If a call does match multiple call signatures, then TypeScript has to decide which one to use, usually based on order they appear... but there are other heuristics used, like sometimes TypeScript selects the "narrowest" or "most specific" overload (which sometimes surprises people, as per microsoft/TypeScript#39833). Sometimes overlap is unavoidable, but you should avoid it, where feasible.

In your case, when joinEntities and fields are optional, then a call like findOne(1) matches all of your call signatures. And it looks like TypeScript decides, for whatever reason, to resolve the call to your second call signature. Maybe that's a bug in the language? But we don't need to worry about it because we can fix it just by making the parameters required. After all, you already have a call signature that handles the findOne(1) case; you don't need the other two call signatures to also handle it, since all that does is give TypeScript an opportunity to do what you don't want it to do. By removing the optionality modifier, then findOne(1, true) can only match the signature that returns t_car_joined, and findOne(1, [⋯]) can only match the signature that returns Pick<t_car_joined, K>. There's no ambiguity, and things just work.


Now, for the implementation, one major problem is that it apparently takes three arguments, but callers will only ever pass at most two. The parameter names don't matter, JavaScript does not match parameters to arguments by name, only by position. TypeScript, by extension, has the same feature. So that third fields parameter in the implementation is useless. You'll need to change it to something like

<K extends keyof t_car_joined>(
  carID: number,
  joinEntitiesOrFields?: true | K[],
) => {
  return new Promise<t_car_base | t_car_joined | Pick<t_car_joined, K>>((resolve, reject) => {
    if (Array.isArray(joinEntitiesOrFields)) {
      resolve({} as Pick<t_car_joined, K>)
    } else if (joinEntitiesOrFields) {
      resolve({} as t_car_joined)
    } else {
      resolve({} as t_car_base)
    }
  })
}

Overloads in TypeScript are erased along with the rest of the type system when compiled to JavaScript. There have been proposals to make overloads do something special when compiled to JavaScript, like microsoft/TypeScript#3442, but these have all been declined (see this comment) as being out of scope for TypeScript. TypeScript does not want to be in the business of syntax sugar for JavaScript.

This is technically out of scope for the question you asked. But it's very important if you want your code to possibly work at runtime.


As for the error you got when assigning your function implementation to findOne, that's because TypeScript does not and cannot properly verify the implementation of an overloaded function against its call signatures. When you have an overloaded function statement, TypeScript checks things too loosely. That tends to reduce compiler errors, but it can also falsely allow things that are unsafe:

function findOne(carID: number): Promise<t_car_base>
function findOne(carID: number, joinEntities: true): Promise<t_car_joined>
function findOne<K extends keyof t_car_joined>(carID: number, fields: K[]): Promise<Pick<t_car_joined, K>>

function findOne<K extends keyof t_car_joined>(
  carID: number,
  joinEntitiesOrFields?: true | K[],
) {
  return new Promise<t_car_base | t_car_joined | Pick<t_car_joined, K>>((resolve, reject) => {
    if (!Array.isArray(joinEntitiesOrFields)) { // oops!
      //^ <-- this is bad, but TypeScript didn't complain
      resolve({} as Pick<t_car_joined, K>)
    } else if (joinEntitiesOrFields) {
      resolve({} as t_car_joined)
    } else {
      resolve({} as t_car_base)
    }
  })
}

It's too complex for TypeScript to check overloads the "right" way, so it doesn't. See microsoft/TypeScript#13235.

On the other hand, when you have an arrow function and try to assign it to an overloaded signature, TypeScript checks things too strictly. It only allows it if the inferred type from the arrow function works for every call signature, and that is rarely true. In your example, TypeScript is upset that you are sometimes returning Pick<t_car_joined, K> when one of the call signatures expects a t_car_base. This is too strict of a check.

So it won't let you do unsafe things, but it also won't let you do safe things. There is a request at microsoft/TypeScript#47669 to at least make this work like function statements, but for now, if you assign an arrow function to a variable of an overloaded function type, you are likely to get error messages about at least one of your call signatures not matching, even if they do match.

For now, if you want to use an overloaded arrow function, you'll have to avoid the problem by intentionally loosening the type checking, such as using a type assertion:

const findOne = (<K extends keyof t_car_joined>(
  carID: number,
  joinEntitiesOrFields?: true | K[],
) => {
  return new Promise<t_car_base | t_car_joined | Pick<t_car_joined, K>>((resolve, reject) => {
    if (Array.isArray(joinEntitiesOrFields)) {
      resolve({} as Pick<t_car_joined, K>)
    } else if (joinEntitiesOrFields) {
      resolve({} as t_car_joined)
    } else {
      resolve({} as t_car_base)
    }
  })
}) as t_findOne;

This is similar-ish to using a function statement for overloads. Yes, it's possible to mess up and return the wrong thing, just as with overloaded function statements. That just means you need to be careful and test your function implementation. But at least you're not getting an error message.


Playground link to code

发布评论

评论列表(0)

  1. 暂无评论