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
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.