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