Kako kombinirati diskriminirane unije s preopterećenjima funkcija u TypeScriptu


Imam ovaj vrlo jednostavan primjer gdje očekujem da će mi TypeScript javiti o pogrešci tipa povratne vrijednosti:

interface Options {
    type: "person" | "car"
}

interface Person {
    name: string
}

interface Car {
    wheels: number
}

function get(opts: Options & {type: "person"}): Person
function get(opts: Options & {type: "car"}): Car
function get(opts: any): any {
    switch (opts.type) {
        case "person": return { name: "john" }
        case "car": return {name: "john"} // why there is no error?
    }
}

let person = get({ type: "person" })
let car = get({type: "car"})

Pretpostavljam da vjerojatno nisam ispravno koristio diskriminirane unije. Koji bi trebao biti ispravan način kombiniranja diskriminiranih unija s preopterećenjem funkcija?

Odgovor #1

TypeScriptov prevoditelj nije u stanju provjeriti ili zaključiti uvjetni odnos između tipova ulaza i izlaza funkcije iz njene implementacije. Može izvršiti analizu toka kontrole unutar implementacije funkcije kako bi suzio tipove povratnih vrijednosti na temelju testova čuvara tipa, ali se tip povratne vrijednosti za cijelu funkciju samo zaključuje ili provjerava u odnosu na uniju svih vraćenih tipova.

Dakle, najbolje što bi prevoditelj mogao shvatiti za vašu namjeravanu implementaciju funkcije bilo bi ovo:

function get(opts: Options & ({ type: "person" } | { type: "car" })): Person | Car {
    switch (opts.type) {
        case "person": return { name: "john" }
        case "car": return { wheels: 4 }
    }
}

Tip povratne vrijednosti je Person | Car. Odnos između opts.type i određenog člana Person | Car koji se vraća je izgubljen. Možete koristiti preopterećenja da kažete kompajleru o tom odnosu, ali to je ekvivalentno tvrdnji tipa. Kompajler će se žaliti samo ako je odnos koji tvrdite potpuno nepovezan s onim što može provjeriti:

function getOops(opts: Options & { type: "person" }): Person
function getOops(opts: Options & { type: "car" }): Car // error!
function getOops(opts: Options & ({ type: "person" } | { type: "car" })) {
    switch (opts.type) {
        case "person": return { name: "john" }
        case "car": return { wheels: "round" } // this isn't a Car or a Person
    }
}

I tako su preopterećenja nezgodna na isti način na koji su nezgodne tvrde izjave tipa: možete ih koristiti da lažete kompajler.

Postoji (dugotrajno) otvoreno pitanje koje traži vrstu provjere tipa koju želite: microsoft/TypeScript#10765. Nije jasno kako to poboljšati bez vrlo negativnog utjecaja na performanse. Nadalje, ljudi se oslanjaju na ovu nesigurnost i često koriste overload signaturu kao alternativu za provjere tipa, pa bi rješavanje ovog problema rezultiralo velikom promjenom koja bi razbila mnogo koda. U predvidivoj budućnosti, trebali biste smatrati da implementacije overload funkcija predstavljaju mjesto gdje sigurnost tipa treba biti zajamčena od strane implementatora, a ne od strane prevoditelja/komepajlera.

Usputna napomena: overloadi su starija značajka TypeScripta koja bi se mogla zamijeniti generičkim i uvjetnim tipovima. Umjesto { (a: string) => number; (a: number) => string; }, možete imati potpis <T extends string | number>(a: T) => T extends string ? number : string;. Ali generički uvjetni tipovi imaju gotovo isti problem kao i kod overloada: kompajler ne može provjeriti odnos u implementaciji. Jedina je razlika u tome što kompajler kod uvjetnih tipova stalno prijavljuje pogreške i potrebna je tvrdnja tipa, dok je kod overloada kompajler uglavnom tih:

function getGenericConditional<K extends "person" | "car">(
    opts: Options & { type: K }
): K extends "person" ? Person : Car {
    switch (opts.type) {
        // need to assert both of these
        case "person": return { name: "john" } as K extends "person" ? Person : Car
        case "car": return { wheels: 4 } as K extends "person" ? Person : Car
    }
    throw new Error(); // compiler can't tell that this is exhaustive
}

A ovdje postoji otvoreno pitanje koje također traži da se ovo poboljša: microsoft/TypeScript#33912, i opet, nije jasno kako to učiniti učinkovito.

Dakle, postoje li još neke opcije? Jedna stvar koju kompajler može provjeriti jest da ako imate vrijednost tipa T i ključ tipa K koji proširuje keyof T, indeksiranje u tu svojinu daje vrijednost tipa T[K]. Ako svoj generički element možete učiniti sličnim ključu (kao što su "person" i "car"), tada možete preoblikovati odnos od ulaza do izlaza kao indeksirani pristup:

type Mapping = {
    person: Person,
    car: Car
}

function getIndexed<K extends "person" | "car">(opts: { type: K }): Mapping[K] {
    return ({ person: { name: "john" }, car: { name: "john" } })[opts.type]; // error!
}

A ako ne želite unaprijed kreirati Mapping objekt, možete koristiti getter-e kako biste odgodili stvaranje vraćene vrijednosti dok vam ne zatreba:

function getIndexedDeferred<K extends "person" | "car">(opts: { type: K }): Mapping[K] {
    const map: Mapping = {
        get person() { return { name: "john" } },
        get car() { return { name: "john" } } // error!
    }
    return map[opts.type];
}

Dakle, gore navedeno je tip sigurno i kompajler to zna, ali nije idiomatski JS pa biste možda radije trebali samo paziti na svoje overloadove.

U redu, nadam se da pomaže; sretno!

Poveznica na Playground za kod

Povezani članci