I think when there is a generic type parameter T extends A | B
and you get an error of the form "T & X
is not assignable to T & Y
" where X
and Y
are equivalent but not identical, it is probably the compiler bug mentioned at microsoft/TypeScript#24688.
You could convince the compiler to accept it by refactoring LatLon & LatLng
into a type the compiler sees as identical to { lng: number; lat: number; lon: number; }
:
interface LatLonLng extends LatLon, LatLng { }
const fillLonLng1 = <T extends LatLng | LatLon>(loc: T): T & LatLonLng => {
if (isLatLon(loc)) {
return { ...loc, lng: loc.lon };
} else if (isLatLng(loc)) {
return { ...loc, lon: (loc as LatLng).lng };
} else throw new Error();
}
The caveats below still stand (even if they are not as directly applicable as I first thought )
As mentioned in this question, the problem with a function that purports to craft a value of a generic type is that the caller of the function can specify the generic to be whatever they want that satisfies the constraint, and that the caller might do so in a way that the implementer did not anticipate.
For example, T extends LatLng | LatLon
can be satisfied not only by adding new properties to LatLng
or LatLon
, but also by narrowing existing properties:
const crazyTown = { lat: 0, lon: 0, lng: 1 } as const;
const crazierTown = fillLonLng1(crazyTown);
const crazyTuple = [123, "hello"] as const;
const crazyString = crazyTuple[crazierTown.lng].toUpperCase(); // no compile error, but:
// RUNTIME ERROR! crazyTuple[crazierTown.lng].toUpperCase is not a function
Here, crazyTown
has a lon
and a lng
, both of which are of numeric literal types. The function fillLonLng1
purports to return a value of type typeof crazyTown & LatLon & LatLng
. This requires that the output lng
value be 1
as passed in, but unfortunately you're getting a 0
out at runtime. Thus the compiler is happy with the (admittedly very unlikely) code afterward that looks like you're manipulating a string
at compile time but actually throws a runtime error. Oops.
Generally speaking, the compiler doesn't even try to verify assignability of concrete values to unspecified generic types, so it's quite possible to write an implementation which is 100% safe yet will still be rejected by the compiler. In your case, the fillLonLng2()
function implemented via Omit
has a safer type signature, but if you implemented it the same way as fillLonLng1()
the compiler couldn't tell.
And, frankly, even the "correct" error above might be something you don't want to worry about because the chances of someone running into an edge case like that are too low. Either way, the solution is the same thing: use a type assertion and move on:
const fillLonLng1 = <T extends LatLng | LatLon>(loc: T): T & LatLon & LatLng => {
if (isLatLon(loc)) {
return { ...loc, lng: loc.lon } as any;
} else {
return { ...loc, lon: (loc as LatLng).lng } as any;
}
}
or
const fillLonLngSafer = <T extends LatLng | LatLon>(loc: T
): Omit<T, keyof LatLng | keyof LatLon> & LatLon & LatLng => {
if (isLatLon(loc)) {
return { ...loc, lng: loc.lon } as any;
} else {
return { ...loc, lon: (loc as LatLng).lng } as any;
}
}
Playground link to code