1

I have a function that should only accept arguments that are part of a group that I define. The code explains it better than I can:

// Ham.ts
interface Ham {
  a: string
}

// Cheese.ts
interface Cheese {
  b: string
}

// Aux.ts
type Food = Ham | Cheese

const onlyFood = (arg: Food) => {
  console.log("executed onlyFood")
}

The data that is modeled by the Food interfaces are not guaranteed to share any common structure.

This approach works fine. However, as I add new Food interfaces (in their own files), I will need to go back into the Aux.ts file and update my sum type. Is there a way to encode the same functionality without having to modify the Aux.ts file on additional interface creation?

My initial idea for a solution was to use an empty interface that I could extend like so

// Data.ts
interface Food {}

// Ham.ts
interface Ham extends Food {
  a: string
}

// Cheese.ts
interface Cheese extends Food {
  b: string
}

// Aux.ts
const onlyFood = (arg: Food) => {
  console.log("executed onlyFood")
}

but because of TypeScript's structural typing, this approach won't work. Also, Prettier complains about it, which I don't like (even if I can disable it for this line/file).

Any ideas?

  • 2
    You could use [declaration merging](https://www.typescriptlang.org/docs/handbook/declaration-merging.html) to add a property to a "food registry" interface and then `Food` is just the values of that interface, as shown [here](https://tsplay.dev/wRAQnW). Does that meet your needs? If so I'll write up an answer explaining; if not, what am I missing? – jcalz Sep 02 '23 at 21:15
  • Yup this works. The extra interface wrapper in each file is a little ugly, but this solution certainly uncouples the files, which is what I was looking for. Thank you! – systems_n_systems Sep 02 '23 at 21:41

1 Answers1

1

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

jcalz
  • 264,269
  • 27
  • 359
  • 360