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.

3
  • what is the type of 'isLatLon'? Commented Dec 4, 2020 at 21:02
  • It's a type guard function const isLatLon = <T extends Partial<LatLon>>(loc: T): loc is T & LatLon. I will edit that into the question. Commented Dec 4, 2020 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... Commented Jul 24, 2022 at 18:46

2 Answers 2

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

Sign up to request clarification or add additional context in comments.

4 Comments

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.
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.
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 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
Interesting! I had no idea LatLonLng would be treated differently than the union.
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 };
}

Comments

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.