typescript - TS: typkontroll för ES Map, `if (map.has(key)) map.get(key)` kan vara undefined


I grund och botten vill jag ha en key in obj-typvakt men för ES Map.

När tsconfig.json strict: true är på, kommer TS att ge fel i detta fall:

const map = new Map<string, string>([["foo", "bar"]])

if (map.has("foo")) {
  map.get("foo").toLowerCase();
  // ~~~~~~~~~~ ERROR HERE
  // (method) Map<string, string>.get(key: string): string | undefined
  // Object is possibly 'undefined'.ts(2532)
}

Jag vet att anledningen är att TS:s standardbibliotek typbestämmer ES Map som:

interface Map<K, V> {
    get(key: K): V | undefined;
    has(key: K): boolean;
}

Vi skulle kunna manuellt undertrycka felet med ! non-null-assertion, men vore det inte trevligt om TS bara blev smartare och sparade oss ett tangenttryck? Eftersom ett fall som detta uppenbarligen är korrekt.

Jag tror att något kan göras som med Object.hasOwnProperty i den här frågan.

Svar #1

Efter många experiment klurade jag äntligen ut det, och jag är väldigt nöjd med resultatet! Svaret är att utöka Map-gränssnittet på detta sätt:

interface Map<K, V> {
    has<P extends K>(key: P): this is { get(key: P): V } & this
    // funny thing: `& this` is the final piece of the puzzle
}

const map = new Map<string, string>([['foo', 'bar']])

map.get("foo").toLowerCase()
// ~~~~~~~~~~~
// without narrowing TS raises error as expected

if (map.has("foo")) {
    // after type guard narrowing, error goes away!
    var v = map.get("foo").toLowerCase()

    map.get("bar").toLowerCase()
    // ~~~~~~~~~~~
    // correctly catch unchecked case
}

Prova det i Playground.


Inspirerad av jcalz svar, kom jag till denna lösning ganska tidigt. Men det fungerade inte och jag hade svårt att förstå varför. Det verkade för mig som att typ-predikatet misslyckades med att överlagra den ursprungliga funktionssignaturen för Map#get.

interface Map<K, V> {
    has<P extends K>(key: P): this is { get(key: P): V }
}

// My theory of why this solution doesn't work.
// After narrowing it probably concludes to sth like:
interface Map<K, V> {
    get(key: K): V | undefined
    // 👆 the order of above signature comes first
    // so it has high priority, that's my guess

    get<P extends K>(key: P): V
}

Efter mycket provande och felande och omväg till andra suboptimala lösningar, märkte jag popover-tipset på maps härledda typ efter insnävning lästes:

const map: Map<string, string> & {
    get(key: "foo"): string;
}

Och vänster/höger-ordningen av de två operanderna till &-operatorn slog mig, så jag gick vidare för att försöka lägga till i slutet ett & this, i hopp om att vända på ordningen. Och till min förvåning fungerade det faktiskt!

interface Map<K, V> {
    has<P extends K>(key: P): this is { get(key: P): V } & this // 👈 the trick!
}

interface Map<K, V> {
    get<P extends K>(key: P): V

    // `& this` explicitly lower the priority of original signature
    get(key: K): V | undefined
}