簡體   English   中英

Typescript 歧視聯合問題

[英]Typescript Issue on Discriminated Union

我正在嘗試編寫一個帶有區分聯合的簡單工廠 function 來創建 class 的新實例。 但是我還沒有找到任何滿足我要求的方法。

  • I want to pass platform as extra parameter or inside of the data object or even as generic to function but I would like to be able to understand instance type of object right after I create object (intellisense should understand if it's telegram or whatsapp instance & shows屬性等)
import { v4 as uuidv4 } from 'uuid';

type Gender = "male" | "female" | "nonBinary";
type Platform = "telegram" | "whatsapp";
type City = "myLocation" | "amsterdam" | "barcelona" | "berlin" | "brussels" | "buenosAires" | "capeTown" | "copenhagen" | "chicago" | "dubai" | "gothenburg" | "helsinki" | "hongKong" | "istanbul" | "kualaLumpur" | "kyiv" | "lisbon" | "london" | "losAngeles" | "madrid" | "malmo" | "manchester" | "melbourne" | "milan" | "montreal" | "newYork" | "oslo" | "paris" | "reykjavik" | "rio" | "rome" | "sanFrancisco" | "saoPaulo" | "singapore" | "stockholm" | "sydney" | "toronto" | "vienna" | "zurich";
type BodyType = "thin" | "athletic" | "average" | "extraPounds" | "bigTall";
type Education = "none" | "highSchool" | "college" | "bachelors" | "masters" | "phDPostDoctoral";


interface IUser {
    userId?: string;
    name: string;
    age: number;
    gender: Gender;
}

class User implements IUser {
    readonly userId: string;
    readonly name: string;
    readonly age: number;
    readonly gender: Gender;

    constructor(props: { name: string, age: number, gender: Gender, userId?: string }) {
        this.userId = props.userId || uuidv4();
        this.name = props.name
        this.age = props.age
        this.gender = props.gender
    }

    public get ID(): string | undefined {
        return this.userId;
    }
}

interface TelegramMeta {
    telegramId: number;
    job: string;
    bodyType?: BodyType,
    height?: number,
    children?: boolean,
    smoker?: boolean,
    education?: Education,
    educationName?: string,
    about?: string,
    favouriteCities?: string,
    placesToGo?: string,
    freeTime?: string
}

interface ITelegramUser extends IUser {
    platform: "telegram",
    telegramMeta: TelegramMeta,
}

class TelegramUser extends User implements ITelegramUser {
    readonly platform = "telegram"
    telegramMeta: TelegramMeta;

    constructor(props: { name: string, age: number, gender: Gender, telegramMeta: TelegramMeta, userId?: string }) {
        super(props);
        this.telegramMeta = props.telegramMeta;
    }
}

interface WhatsappMeta {
    whatsappId: string,
    intagramLinks?: string[]
}

interface IWhatsappUser extends IUser {
    platform: "whatsapp",
    whatsappMeta: WhatsappMeta,
}

class WhatsappUser extends User {
    readonly platform = "whatsapp"

    whatsappMeta: WhatsappMeta;

    constructor(props: { name: string, age: number, gender: Gender, whatsappMeta: WhatsappMeta, userId?: string }) {
        super(props);
        this.whatsappMeta = props.whatsappMeta;
    }
}

type UserTypes = ITelegramUser | IWhatsappUser;
type UserPlatforms = UserTypes['platform'];

type ReturnTypeLookup = {
    "telegram": ITelegramUser;
    "whatsapp": IWhatsappUser;
};

type SchemaFor<T extends UserTypes> = ReturnTypeLookup[T['platform']];

export function createUser<T extends UserTypes>(data: T): SchemaFor<T> {
    if (data.platform === 'telegram') {
        return new TelegramUser(data);
    } 
    return new WhatsappUser(data);
}

const telegramUser = createUser<ITelegramUser>({
    name: "Harrison",
    age: 30,
    gender: "male",
    platform: "telegram",
    telegramMeta: {
        telegramId: 123,
        job: 'Developer'
    },
});

它給出了這個錯誤信息:

Type 'TelegramUser' is not assignable to type 'SchemaFor<T>'.
  Type 'TelegramUser' is not assignable to type 'never'.
    The intersection 'ITelegramUser & IWhatsappUser' was reduced to 'never' because property 'platform' has conflicting types in some constituents.

TLDR 示例: https://tsplay.dev/wEPMyW

讓我解釋一下您要做什么以及遇到的問題。 你有一個受歧視的工會

interface Foo {
    type: "foo",
    fooMeta: { a: string }
}

interface Bar {
    type: "bar",
    barMeta: { b: number }
}

type Types = Foo | Bar;

你有一些功能,map 這個聯合的每個成員到一些相應的 output 類型:

declare function doFoo(data: Foo): number;
declare function doBar(data: Bar): Date;

您想將 package 這些轉換為單個通用function 接受可區分聯合的任何成員並委托給相應的 function',並返回相應的函數,

type ReturnLookup = {
    "foo": number;
    "bar": Date;
};    

declare function create<T extends Types>(data: T): ReturnLookup[T['type']];

const doneFoo = create({ type: "foo", fooMeta: { a: "" } }); // number
const doneBar = create({ type: "bar", barMeta: { b: 123 } }); // Date

但是,不幸的是,您似乎無法在不出現類型錯誤的情況下實現此 function:

export function create<T extends Types>(data: T): ReturnLookup[T['type']] {
    if (data.type === 'foo') {
        return doFoo(data) // number is not assignable to number & Date
    }
    return doBar(data) // Date is not assignable to number & Date
}

為什么會發生這種情況,如何解決?


發生這種情況是因為當您檢查類型T的值data時編譯器無法縮小類型參數T 檢查data.type縮小data范圍,但不會影響T本身。 由於T在 function 實現中不受影響,編譯器永遠不知道您應該返回 doFoo 的返回類型doBar doFoo的返回類型。 它唯一認為安全的是,如果您以某種方式返回了兩種類型的值,即兩種類型的交集 這就是為什么您會收到有關numberDate不能分配給交叉點number & Date的錯誤。

GitHub 中有各種問題提到了這個問題並請求修復它的方法。 一個是microsoft/TypeScript#33014 ,其中建議有一些方法可以縮小泛型類型參數T

另一個是microsoft/TypeScript#30581 ,它處理相關的聯合類型,如果您願意重構,則在microsoft/TypeScript#47109上針對此問題的推薦修復提供了前進的方向:


重構是將您的可區分聯合更改為 object 類型,其鍵是判別屬性,其屬性值為成員的 rest:

interface TypeMapping {
    foo: {
        fooMeta: { a: string }
    },
    bar: {
        barMeta: { b: number }
    }
}

然后,不是將Types定義為聯合,而是將其定義為分布式 object 類型,在其中創建映射類型並立即對其進行索引以獲得聯合:

type Types<K extends keyof TypeMapping = keyof TypeMapping> =
    { [P in K]: { type: P } & TypeMapping[P] }[K]

type Foo = Types<"foo">
type Bar = Types<"bar">

FooBarTypes類型等價於頂部的類型(請注意,沒有泛型參數的Types由於default計算為Types<keyof TypeMapping> ),但現在編譯器明確地看到了Types<K>之間的關系Types<K>K的工會成員。

然后我們可以將doFoodoBar函數合並到一個 object 中,其類型也明確表示為TypeMapping的鍵:

const funcs: { [P in keyof TypeMapping]: (data: Types<P>) => ReturnLookup[P] } = {
    foo: doFoo,
    bar: doBar
}

這種表示很重要。 它允許編譯器理解,對於泛型KTypes<K>的值data ,您可以調用funcs[data.type](data)並獲取 output 類型ReturnLookup[K]

export function create<K extends keyof TypeMapping>(
    data: Types<K>
): ReturnLookup[K] {
    return funcs[data.type](data); // okay
}

const doneFoo = create({ type: "foo", fooMeta: { a: "" } }); // number
const doneBar = create({ type: "bar", barMeta: { b: 123 } }); // Date

所以那里沒有錯誤,萬歲


但請注意,它起作用的原因與此處類型的確切表示有很大關系。 例如,如果您刪除上面funcs的注釋:

const funcs = {
    foo: doFoo,
    bar: doBar
}

然后錯誤又回來了:

return funcs[data.type](data); // error, number | Date not number & Date

那是因為編譯器失去了funcs[data.type]的類型和data的類型之間的聯系(這是 microsoft/TypeScript#30581 的全部主題),你可能根本沒有重構。

所以要小心這種技術

Playground 代碼鏈接

暫無
暫無

聲明:本站的技術帖子網頁,遵循CC BY-SA 4.0協議,如果您需要轉載,請注明本站網址或者原文地址。任何問題請咨詢:yoyou2525@163.com.

 
粵ICP備18138465號  © 2020-2024 STACKOOM.COM