1

There is probably a much better way of titling this question. If you think of a better name, let me know and I'll change the title. My question is kind of complex, but I have a code sample that demonstrates what I'm looking for well:

type Category = "FooAndBar" | "JustBaz" | "All"

export const widgets = [
  {
    name: 'Foo',
    categories: ["FooAndBar", "All"]
  },
  {
    name: 'Bar',
    categories: ["FooAndBar", "All"]
  },
  {
    name: 'Baz',
    categories: ["JustBaz", "All"]
  },
] as const;

export type WidgetName = typeof widgets[number]['name'];

export interface Widget {
  name: WidgetName;
  categories: Category[]
}

export interface WidgetPrices {
  location: string;
  // some field here that requires you to cover every type of widget,
  // whether it be from categories or specific names
}

// Here's how I would want WidgetPrices to work:

const valid = {
  location: "qazlandia",
  Foo: 1,
  Bar: 2,
  Baz: 3,
}

const valid2 = {
  location: "qazlandia",
  FooAndBar: 3,
  Baz: 3,
}

const valid3 = {
  location: "qazlandia",
  All: 10,
}

const valid4 = {
  location: "qazlandia",
  FooAndBar: 3,
  Bar: 5, // in code, I would probably give a specific name precedence over a category, but Foo would fallback to its category
  Baz: 3,
}

const valid5 = {
  location: "qazlandia",
  FooAndBar: 3,
  All: 5, // in code, I would probably give a specific category precedence over a broader category
}

const valid6 = {
  location: "qazlandia",
  FooAndBar: 3,
  JustBaz: 5,
}

const error = {
  location: "qazlandia",
  Foo: 1,
  // error: missing bar
  Baz: 3,
}

const error2 = {
  location: "qazlandia",
  FooAndBar: 3,
  // error, missing baz
}

The question is, can something be defined like this with typescript's type system? What would WidgetPrices' declaration look like?

I'm flexible on WidgetPrices' declaration in certain ways, e.g., I'd prefer the implementations to look the way I presented, but I'd be ok with separate Names and Category properties, if that's the only way to pull this off. The most important thing to me is that the compiler errors when I'm missing coverage.

Daniel Kaplan
  • 62,768
  • 50
  • 234
  • 356
  • Hi, do you have an arbitrary number of fields to add to widget Prices? In this case I would add a generic optional param, like [_: string] :? Any. If you have a imited number of price types that you can add, I'd extend them from widget Prices. Finally, you could consider having a typed wodgetPrices with a params attribute of type T – Carlos Moura Feb 06 '21 at 21:39
  • Every time I add a new element to `widgets`, `WidgetPrices` would get a new field. I don't know if that's what you're asking, @CarlosMoura – Daniel Kaplan Feb 06 '21 at 21:48
  • This is the type of question I live for. – jcalz Feb 06 '21 at 22:26

1 Answers1

2

I have something that works for your example where WidgetPrices is a specific union of valid selections for the keys of the number-valued properties. This union has a number of members that grows exponentially as the number of Widget names or categories grows linearly. That means you will likely hit compiler performance problems or other limits if your hierarchy is some amount larger than what is shown in your example, and if so, you might need to retreat from a specific type to a generic type. For now I will only worry about the union version, and possibly come back later to work on a generic version if the use case requires it:

type Widgets = typeof widgets[number]
type Price<K extends PropertyKey> = K extends PropertyKey ? { [P in K]: number } : never;
type _WidgetPrices<W extends Widgets = Widgets> =
  (W extends any ? (x: Price<W["name"] | W["categories"][number]>) => void : never) extends
  ((x: infer I) => void) ? I : never
type Id<T> = T extends infer U ? { [K in keyof U]: U[K] } : never
type WidgetPrices = Id<{ location: string } & _WidgetPrices>;

Before I get into explaining any of that, let's just see if WidgetPrices looks right. If you inspect with IntelliSense, you are shown:

/* type WidgetPrices = {
    location: string;
    All: number;
} | {
    location: string;
    Foo: number;
    FooAndBar: number;
    All: number;
} | {
    location: string;
    Foo: number;
    FooAndBar: number;
    Baz: number;
} | {
    location: string;
    Foo: number;
    FooAndBar: number;
    JustBaz: number;
} | ... 21 more ... | {
    ...;
} */

Each of the union members shown corresponds to a valid selection of prices. They all have a location property, and their other fields cover the full set of widgets. The first one has only All, which is enough. The others have a different choice that works. And you can verify that all of your valid… values work:

const valid: WidgetPrices = {
  location: "qazlandia",  Foo: 1,  Bar: 2,  Baz: 3,
}

const valid2: WidgetPrices = {
  location: "qazlandia",  FooAndBar: 3,  Baz: 3,
}

const valid3: WidgetPrices = {
  location: "qazlandia",  All: 10,
}

const valid4: WidgetPrices = {
  location: "qazlandia", FooAndBar: 3, Bar: 5, Baz: 3,
}

const valid5: WidgetPrices = {
  location: "qazlandia", FooAndBar: 3, All: 5,
}

const valid6: WidgetPrices = {
  location: "qazlandia",  FooAndBar: 3,  JustBaz: 5,
}

while the error… ones fail:

const error: WidgetPrices = { // error!
  //  ~~~~~
  // Property 'Bar' is missing in type '{ location: string; Foo: number; Baz: number; }' 
  // but required in type '{ location: string; Foo: number; Bar: number; Baz: number; }'
  location: "qazlandia",
  Foo: 1,
  Baz: 3,
}

const error2: WidgetPrices = { // error!
  //  ~~~~~~
  // Type '{ location: string; FooAndBar: number; }' is missing the following 
  // properties from type '{ location: string; All: number; FooAndBar: number; JustBaz: number; }': 
  // All, JustBaz
  location: "qazlandia",
  FooAndBar: 3,
}

Okay, now for a sketch of the explanation:

  • The Widgets type is just the union of element types in the widgets array.
  • Price<K> turns a union of keys K into a union of single-keyed objects with a number-valued property, for each key in the K union. So Price<"a" | "b"> becomes {a: number} | {b: number}.
  • The heavy lifting is in _WidgetPrices<W>. We give it a union of widgets W and distribute over that union. For each element in W we calculate Price<W["name"] | W["categories"][number]>. So, for example, the Foo element becomes Price<"Foo" | "FooAndBar" | "All">, or {Foo: number} | {FooAndBar: number} | {All: number}. These are all the object types that would cover just the Foo widget; let's just name this CoverageForFoo. What I then do is to get the intersection of the unions-of-coverage-objects, via a technique for getting the compiler to calculate arbitrary-size intersections. If W is Widgets, then this is essentially the thing we're looking for. That is, you want CoverageForFoo & CoverageForBar & CoverageForBaz. Such an object type would definitely cover Foo and Bar and Baz.
  • A big old intersection of unions is uuuugly, so I use another technique to eliminate some of those intersections. The Id<T> type more or less just produces an equivalent object type to T, but something like Id<{a: number} & {b: string}> would become {a: number, b: string}.
  • Finally, WidgetPrices is Id<{location: string} & _WidgetPrices<Widgets>>: a vaguely non-ugly union of things with a string-valued location property and number-valued properties that cover all the widgets.

So, hooray! As I said, this will probably blow up if you add too many things in there. The approach to turn this generic would involve a helper function that checks if a value is a WidgetPrices<W> for some suitable generic type parameter W, but then you'd have to carry around generics whenever you wanted to talk about widget prices. And since I don't know if the use case requires it and I've already spent a bunch of time on this, I'll stop here.

Playground link to code

jcalz
  • 264,269
  • 27
  • 359
  • 360