TypeScript's union types are exactly what you're looking for (e.g., type Food = Ham | Cheese | ⋯
), but you're not happy about having to enumerate the members of the union in a central location.
TypeScript's structural type system also means that it's not really feasible to have the language automatically compute a union of all known subtypes of a particular interface, since any type that matches interface structurally will suffice; this is particularly noticeable for the empty interface {}
where all non-nullish types are assignable. If you want a union you'll need to either build it yourself, or "register" its members somehow in another type and have the compiler compute the union from the registry.
The only way I can think of that lets the registration occur close to the declaration of the items to be registered (Ham
, Cheese
, etc) instead of close to the declaration of the union (Food
) is to use declaration merging, where you have a declaration split across different locations (including different files) and the compiler merges them all into a single logical declaration.
You can't add members to unions directly with declaration merging. Merging only works in specific scenarios and unions aren't among them. Instead, you can use interface merging to add properties to an interface. Let's call it Fridge
(although maybe FoodRegistry
would be less silly):
// Ham.ts
interface Ham {
a: string
}
interface Fridge {
ham: Ham
}
// Cheese.ts
interface Cheese {
b: string
}
interface Fridge {
cheese: Cheese
}
So we've registered both Ham
and Cheese
as properties of Fridge
. Note that we had to give the properties key names, but they are essentially dummy names that we don't care about. They need to be distinct from each other for merging to work, but other than that they could be anything. Then in the central location we have
// Aux.ts
interface Fridge { } // merge individual foods
type Food = Fridge[keyof Fridge]
(You don't technically need Fridge
to be declared here if it's in use elsewhere, but this will help prevent errors in the case that you haven't registered any Food
elements yet.)
The type type Food = Fridge[keyof Fridge]
uses the keyof
operator and an indexed access to get the union of all property types of Fridge
. (See Is there a `valueof` similar to `keyof` in TypeScript? ).
So if you have only registered Ham
and Cheese
then when you inspect Food
you'll see it as // type Food = Ham | Cheese
. If you register another food in Fridge
it will be added as well.
And so it behaves as expected:
const onlyFood = (arg: Food) => {
if ("a" in arg) {
arg.a.toUpperCase(); // okay
} else {
arg.b.toLowerCase(); // okay, well, until you register more Food
}
}
Note that all the usual caveats of scoping across files apply here. Code in a module scope will behave differently from code in a global scope. If your different files are in different modules then you will need to import/export types and perform module augmentation. (i.e., with declare module
) And if you are trying to interact merge into something in the global namespace from within a module then you'll need global augmentation (i.e., with declare global
). So be careful.
Playground link to code