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
Let me paraphrase what you’re trying to do and the problem you’re running into. You have a discriminated union:
And you have some functions that map each member of this union to some corresponding output type:
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:
But, unfortunately, you can’t seem to implement this function without getting type errors:
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 valuedata
of typeT
. Checkingdata.type
narrowsdata
, but it does not affectT
itself. SinceT
is not affected inside the function implementation, the compiler never knows whether you should return the return type ofdoFoo
or the return type ofdoBar
. 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 aboutnumber
orDate
not being assignable to the intersectionnumber & 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:
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:The types
Foo
,Bar
, andTypes
types are equivalent to the ones at the top (note thatTypes
without the generic argument evaluates toTypes<keyof TypeMapping>
due to the default), but now the compiler explicitly sees the relationship betweenTypes<K>
and the union members ofK
.Then we can package the
doFoo
anddoBar
functions into a single object whose type is also explicitly represented in terms of the keys ofTypeMapping
:And this representation is important. It allows the compiler to understand that, for a value
data
of typeTypes<K>
for genericK
, you can callfuncs[data.type](data)
and get the output typeReturnLookup[K]
: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:Then the error comes back:
That’s because the compiler loses the connection between the type of
funcs[data.type]
and that ofdata
(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
I was struggling with similar issue and your idea of extracting type in this way was helpful
I’d like to note however that it is possible to simplify by using generic and replacing these fragments:
with this:
full example: