如何在打字稿中将可区分的联合与函数重载结合起来

hgl*_*hgl 6 typescript

我有一个非常简单的示例,我希望打字稿通知我有关返回类型错误的信息:

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"})
Run Code Online (Sandbox Code Playgroud)

我想我可能没有正确使用受歧视的工会。将可区分联合与函数重载结合起来的正确方法应该是什么?

jca*_*alz 6

TypeScript 的编译器无法从其实现中验证或推断函数输入和输出类型之间的条件关系。它可以在函数实现内部执行控制流分析,以基于类型防护测试缩小返回类型,但整个函数的返回类型只能根据所有返回类型的并集进行推断或验证。

因此,编译器能够为您的预期函数实现找到的最佳结果是:

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

返回类型为Person | Car. opts.type与返回的特定成员之间的关系Person | Car丢失。您可以使用重载来告诉编译器这种关系,但这相当于类型断言。仅当您断言的关系与它可以验证的内容完全无关时,编译器才会抱怨:

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
    }
}
Run Code Online (Sandbox Code Playgroud)

因此,重载是不合理的,就像类型断言是不合理的一样:您可以使用它们来欺骗编译器。

有一个(长期存在的)开放问题要求您进行类型检查:microsoft/TypeScript#10765。目前尚不清楚如何改进这一点而不会对性能产生很大的负面影响。此外,人们依赖这种不健全性,并经常使用重载签名作为类型断言的替代方案,因此修复此问题最终将成为许多代码的巨大破坏性更改。在可预见的未来,您应该将重载函数语句实现视为类型安全需要由实现而不是编译器保证的地方。


相关旁白:重载是 TypeScript 的一项较旧功能,可能会被泛型条件类型取代。相反{ (a: string) => number; (a: number) => string; },您可以有签名<T extends string | number>(a: T) => T extends string ? number : string;。但泛型条件类型与重载几乎有相同的问题:编译器无法验证实现中的关系。唯一的区别是,对于条件类型,编译器只是一直抱怨,并且您需要类型断言,而对于重载,编译器基本上保持沉默:

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
}
Run Code Online (Sandbox Code Playgroud)

这里有一个未解决的问题,要求对此进行改进:microsoft/TypeScript#33912,同样,尚不清楚如何有效地做到这一点。


那么,还有其他选择吗?编译器能够验证的一件事是,如果您有一个类型的值和一个扩展T类型的键,则对该属性的索引会生成一个类型的值。如果您可以使通用事物像键一样(例如和),那么您可以将从输入到输出的关系重新构建为索引访问:Kkeyof TT[K]"person""car"

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!
}
Run Code Online (Sandbox Code Playgroud)

如果您不想Mapping预先创建对象,则可以使用getters推迟创建返回值,直到需要它为止:

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];
}
Run Code Online (Sandbox Code Playgroud)

所以上面的内容是类型安全的,编译器也知道这一点,但它不是惯用的 JS,所以你可能更愿意只关心你的重载。


好的,希望有帮助;祝你好运!

Playground 代码链接