skip to Main Content

I am trying to write a simple factory function with discriminated unions to create new instance of a class. However I haven’t found any way that satisfies my requirements.

  • 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 properties etc.)
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'
    },
});

It gives this error message:

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 Example: https://tsplay.dev/wEPMyW

2

Answers


  1. Let me paraphrase what you’re trying to do and the problem you’re running into. You have a discriminated union:

    interface Foo {
        type: "foo",
        fooMeta: { a: string }
    }
    
    interface Bar {
        type: "bar",
        barMeta: { b: number }
    }
    
    type Types = Foo | Bar;
    

    And you have some functions that map each member of this union to some corresponding output type:

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

    You’d like to package these into a single generic function that accepts any member of the discriminated union and delegates to the corresponding function, and returns the corresponding function’s return type:

    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
    

    But, unfortunately, you can’t seem to implement this function without getting type errors:

    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
    }
    

    Why is this happening and how can you fix it?


    This is happening because the compiler cannot narrow the type parameter T when you check the value data of type T. Checking data.type narrows data, but it does not affect T itself. Since T is not affected inside the function implementation, the compiler never knows whether you should return the return type of doFoo or the return type of doBar. The only thing it would see as safe is if you somehow returned a value of both types, that is, the intersection of both types. And that’s why you get errors about number or Date not being assignable to the intersection number & Date.

    There are various issues in GitHub that mention this problem and request ways to fix it. One is microsoft/TypeScript#33014, where it is suggested that there be some way to narrow the generic type parameter T.

    Another is microsoft/TypeScript#30581, which deals with correlated union types, and the recommended fix for this issue at microsoft/TypeScript#47109 gives a way forward here, if you’re willing to refactor:


    The refactoring is to change your discriminated union to an object type whose keys are the discriminant property and whose property values are the rest of the members:

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

    Then, instead of defining Types as a union, you define it as a distributive object type where you make a mapped type and immediately index into it to get a union:

    type Types<K extends keyof TypeMapping = keyof TypeMapping> =
        { [P in K]: { type: P } & TypeMapping[P] }[K]
    
    type Foo = Types<"foo">
    type Bar = Types<"bar">
    

    The types Foo, Bar, and Types types are equivalent to the ones at the top (note that Types without the generic argument evaluates to Types<keyof TypeMapping> due to the default), but now the compiler explicitly sees the relationship between Types<K> and the union members of K.

    Then we can package the doFoo and doBar functions into a single object whose type is also explicitly represented in terms of the keys of TypeMapping:

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

    And this representation is important. It allows the compiler to understand that, for a value data of type Types<K> for generic K, you can call funcs[data.type](data) and get the output type 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
    

    So there’s no error there, hooray!


    But take note, the reason this works has very much to do with the exact representation of the types here. If, for example, you remove the annotation of funcs above:

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

    Then the error comes back:

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

    That’s because the compiler loses the connection between the type of funcs[data.type] and that of data (which is the whole topic of microsoft/TypeScript#30581), and you might as well not have refactored at all.

    So be careful with this technique!

    Playground link to code

    Login or Signup to reply.
  2. I was struggling with similar issue and your idea of extracting type in this way was helpful

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

    I’d like to note however that it is possible to simplify by using generic and replacing these fragments:

    const funcs: { [P in keyof TypeMapping]: (data: Types<P>) => ReturnLookup[P] } = {
        foo: doFoo,
        bar: doBar
    }
    
    declare function doFoo(data: Foo): number;
    declare function doBar(data: Bar): Date;
    
    type Foo = Types<"foo">
    type Bar = Types<"bar">
    

    with this:

    declare function doTypeFn<K extends keyof TypeMapping>(data: Types<K>): ReturnLookup[K];
    

    full example:

    interface TypeMapping {
        foo: {
            fooMeta: { a: string }
        },
        bar: {
            barMeta: { b: number }
        }
    }
    type Types<K extends keyof TypeMapping = keyof TypeMapping> =
        { [P in K]: { type: P } & TypeMapping[P] }[K]
    
    type ReturnLookup = {
        "foo": number;
        "bar": string;
    };
    
    declare function doTypeFn<K extends keyof TypeMapping>(data: Types<K>): ReturnLookup[K];
    
    export function create<K extends keyof TypeMapping>(
        data: Types<K>
    ): ReturnLookup[K] {
        return doTypeFn<K>(data); // okay
    }
    
    const doneFoo = create({ type: "foo", fooMeta: { a: '' } }); // number
    const doneBar = create({ type: "bar", barMeta: { b: 1 } }); // Date
    
    Login or Signup to reply.
Please signup or login to give your own answer.
Back To Top
Search