2

I am working with two APIs which use different property names for longitude coordinates: lon and lng. I am writing a converter function that adds the alternative property name so that locations from both APIs can be used interchangeably.

This function gives an error which honestly seems like a bug to me. I know plenty of a work-arounds, but I am interested in understanding why this error is occurring. Is it a bug, or is there something that I am missing here?

const fillLonLng1 = <T extends LatLng | LatLon>(loc: T): T & LatLon & LatLng => {
    if ( isLatLon(loc) ) {
        return {...loc, lng: loc.lon};
    } else {
        return {...loc, lon: loc.lng};
    }
}

The first return statement gives an error:

Type 'T & { lng: number; lat: number; lon: number; }' is not assignable to type 'T & LatLon & LatLng'.
Type 'T & { lng: number; lat: number; lon: number; }' is not assignable to type 'LatLon'.(2322)

Typescript properly understands the returned value contains a lat and lon which are both number. I don't understand how this is possible not assignable to LatLon, which is defined as:

interface LatLon {
    lat: number;
    lon: number;
}
const isLatLon = <T extends Partial<LatLon>>(loc: T): loc is T & LatLon => {
    return loc.lat !== undefined && loc.lon !== undefined;
}

Complete Typescript Playground. I found two different approaches which don't give any error (and don't rely on as either). One where I break it into two separate functions, and one with stupidly complex typings.

Linda Paiste
  • 38,446
  • 6
  • 64
  • 102
  • what is the type of 'isLatLon'? – user1852503 Dec 04 '20 at 21:02
  • It's a type guard function `const isLatLon = >(loc: T): loc is T & LatLon`. I will edit that into the question. – Linda Paiste Dec 04 '20 at 21:07
  • It used to work normally in my library, which consumed Google map NPM, but after common issue with NPM packages and reinstalling them - NPM I - obviously some 3rd party packages have been upgraded and I now have this issue all of the sudden! it's so frustrating... – Alexander Jul 24 '22 at 18:46

2 Answers2

2

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

jcalz
  • 264,269
  • 27
  • 359
  • 360
  • Thanks for sharing your knowledge :) I considered the possibility of numeric literals and that's why I came up with the `Omit` version. But if that's the issue, then the error message is wrong, right? It should be that the returned type is not assignable to `T`, but instead it's saying that it's not assignable to `LatLon`, which it always is. – Linda Paiste Dec 04 '20 at 21:37
  • Hmm, not sure. When I get to a real computer again I’ll look; it might be more of the general limitation of verifying assignability to an unspecified generic type; the compiler doesn’t try too hard because it can’t do it properly in general and because it’s often a (sometimes subtle) mistake to do it. – jcalz Dec 04 '20 at 23:56
  • 1
    Yeah, looking at it, I agree with you: the compiler is not really bothering to try to verify assignability here. I see if you refactor like [this](https://tsplay.dev/jwgY6m) the compiler is happy enough (`LatLonLng` is equivalent to `LatLon & LatLng` but they are not treated the same when intersected with `T`). I think this is still the same underlying issue but I do wonder if they'd consider this specific thing a bug or not. Possibly [microsoft/TypeScript#24688](https://github.com/microsoft/TypeScript/issues/24688) – jcalz Dec 05 '20 at 03:15
  • Interesting! I had no idea `LatLonLng` would be treated differently than the union. – Linda Paiste Dec 05 '20 at 05:26
1

This should do:

interface LatLon {
  lat: number;
  lon: number;
}
namespace LatLon {
  export function is(loc: object): loc is LatLon {
    return ['lat', 'lon'].every(key => key in loc && loc[key] !== undefined);
  }
}

type LatLng = { lat: number; lng: number; };

export function fillLonLng(loc: LatLng | LatLon): LatLon & LatLng {
  return LatLon.is(loc) ? { ...loc, lng: loc.lon } : { ...loc, lon: loc.lng };
}
Tomasz Gawel
  • 8,379
  • 4
  • 36
  • 61