Skip to content

Commit 1d825ff

Browse files
timphandevclaude
andcommitted
fix(recipe): accept its own slots in a function generic over the slot recipe it composes
A function generic over the slot recipe that its slot recipe composes, such as `withFooter<Base extends ComposableKindSlotRecipe<Style>>(base)`, could not name the recipe's own slots in `base`, the variants, or the compound variants: the config typed their keys with ComposedSlot, a conditional type that TypeScript cannot reduce while Base is generic, so it rejected every slot. The config now types the slots of the composed recipes apart, as indexed access, and checks a variant's slots against the recipe's own slots first. Slot values are one mapped type, which TypeScript relates an object to while some of its keys are generic; it is the same type as before. A recipe that composes nothing, or concrete recipes, is checked as it was. Making library recipes composable advised ComposedSlot for the keys of `base` and of compound variants, which gave a library the same bug. It now advises the slots written out, as the engine types them. Where I looked: the slot config of createSlotRecipeKind, sva in class-recipe, createSlotStyleRecipe and the themed one in native-recipe (each fixed in its own commit), the plain recipe config (not affected: it has no slots), and the docs and skills (Making library recipes composable, fixed here; the skills type no slots of a library). A compound variant whose condition names the recipe's own variants is still rejected in such a function; that is not fixed here. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
1 parent 77a1f04 commit 1d825ff

12 files changed

Lines changed: 267 additions & 23 deletions

File tree

‎api/consumers/recipe/define.d.ts‎

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,4 +10,14 @@ declare function defineSlots<const Slot extends string, const Variants extends K
1010
declare function defineComposed<const Variants extends KindVariants<Style>, const DefaultedName extends keyof ComposedVariants<Composed, Variants> = never, const Composed extends readonly ComposableKindRecipe<Style>[] = readonly []>(config: KindRecipeConfig<Style, Variants, DefaultedName, Composed>): ReturnType<typeof styleRecipe<Variants, DefaultedName, Composed>>;
1111
/** Creates a slot recipe that may compose others, as a library's helper does. */
1212
declare function defineComposedSlots<const Slot extends string, const Variants extends KindSlotVariants<Style>, const DefaultedName extends keyof ComposedVariants<Composed, Variants> = never, const Composed extends readonly ComposableKindSlotRecipe<Style>[] = readonly []>(config: KindSlotRecipeConfig<Slot, Style, Variants, DefaultedName, Composed>): ReturnType<typeof slotStyleRecipe<Slot, Variants, DefaultedName, Composed>>;
13-
export { define, defineComposed, defineComposedSlots, defineSlots };
13+
/** Adds a footer to any slot recipe, as a library's own helper does. */
14+
declare function withFooter<const Base extends ComposableKindSlotRecipe<Style>>(base: Base): ReturnType<typeof slotStyleRecipe<"footer", {
15+
readonly dense: {
16+
readonly true: {
17+
readonly footer: {
18+
readonly borderStyle: "dashed";
19+
};
20+
};
21+
};
22+
}, never, readonly [Base]>>;
23+
export { define, defineComposed, defineComposedSlots, defineSlots, withFooter };

‎api/consumers/recipe/index.d.ts‎

Lines changed: 89 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -458,4 +458,92 @@ declare const sheet: ((props?: {
458458
}, "tone", Readonly<Record<string, string | number>>, readonly ("footer" | "root" | "title")[]> | undefined;
459459
};
460460
declare const sheetStyles: Readonly<Record<"root" | "title" | "footer", Style>>;
461-
export { bigText, bigTextStyle, badge, badgeStyle, card, composable, composableSlots, dialog, dialogOptions, dialogStyles, emphasis, emphasisKeys, emphasisStyle, field, fieldStyles, knownSlots, sizes, cardKeys, cardStyles, recipe, sheet, sheetStyles, slotStyleRecipe, style, styleRecipe, text, textDefaults, textKey, textKeys, textOptions, variants, };
461+
declare const footedCard: ((props?: {
462+
readonly dense?: import("@lynstack/recipe").VariantOption<{
463+
readonly true: {
464+
readonly footer: {
465+
readonly borderStyle: "dashed";
466+
};
467+
};
468+
}> | undefined;
469+
readonly tone?: import("@lynstack/recipe").VariantOption<{
470+
readonly dark: {
471+
readonly root: {
472+
readonly backgroundColor: "black";
473+
};
474+
readonly title: {
475+
readonly color: "white";
476+
};
477+
};
478+
readonly light: {
479+
readonly root: {
480+
readonly backgroundColor: "white";
481+
};
482+
};
483+
}> | undefined;
484+
} | undefined) => Readonly<Record<"footer" | "root" | "title", Readonly<Record<string, string | number>>>>) & {
485+
readonly variantKeys: readonly ("dense" | "tone")[];
486+
readonly variantOptions: {
487+
readonly dense: readonly ("false" | "true")[];
488+
readonly tone: readonly import("@lynstack/recipe").VariantOption<{
489+
readonly dark: {
490+
readonly root: {
491+
readonly backgroundColor: "black";
492+
};
493+
readonly title: {
494+
readonly color: "white";
495+
};
496+
};
497+
readonly light: {
498+
readonly root: {
499+
readonly backgroundColor: "white";
500+
};
501+
};
502+
}>[];
503+
};
504+
readonly defaultVariants: {
505+
readonly dense: "false" | "true";
506+
readonly tone: import("@lynstack/recipe").VariantOption<{
507+
readonly dark: {
508+
readonly root: {
509+
readonly backgroundColor: "black";
510+
};
511+
readonly title: {
512+
readonly color: "white";
513+
};
514+
};
515+
readonly light: {
516+
readonly root: {
517+
readonly backgroundColor: "white";
518+
};
519+
};
520+
}>;
521+
};
522+
} & {
523+
readonly "~composition"?: import("@lynstack/recipe").RecipeComposition<{
524+
readonly dense: {
525+
readonly true: {
526+
readonly footer: {
527+
readonly borderStyle: "dashed";
528+
};
529+
};
530+
};
531+
readonly tone: {
532+
readonly dark: {
533+
readonly root: {
534+
readonly backgroundColor: "black";
535+
};
536+
readonly title: {
537+
readonly color: "white";
538+
};
539+
};
540+
readonly light: {
541+
readonly root: {
542+
readonly backgroundColor: "white";
543+
};
544+
};
545+
};
546+
}, "tone", Readonly<Record<string, string | number>>, readonly ("footer" | "root" | "title")[]> | undefined;
547+
};
548+
declare const footedCardStyles: Readonly<Record<"root" | "title" | "footer", Style>>;
549+
export { bigText, bigTextStyle, badge, badgeStyle, card, composable, composableSlots, dialog, dialogOptions, dialogStyles, emphasis, emphasisKeys, footedCard, footedCardStyles, emphasisStyle, field, fieldStyles, knownSlots, sizes, cardKeys, cardStyles, recipe, sheet, sheetStyles, slotStyleRecipe, style, styleRecipe, text, textDefaults, textKey, textKeys, textOptions, variants, };

‎api/recipe.d.ts‎

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,8 @@ interface RecipeKind<Value, Accumulator, Result> {
2323
readonly finish?: ((accumulator: Accumulator) => Result) | undefined;
2424
readonly cache?: boolean | undefined;
2525
}
26-
type NoUnknownSlots<Variants, Slot extends string> = { readonly [Name in keyof Variants]: { readonly [Option in keyof Variants[Name]]: string extends keyof Variants[Name][Option] ? unknown : Readonly<Partial<Record<Exclude<keyof Variants[Name][Option], Slot>, never>>>; }; };
26+
type NoUnknownSlots<Variants, Slot extends string> = NoUnknownComposedSlots<Variants, Slot, never>;
27+
type NoUnknownComposedSlots<Variants, Slot extends string, InheritedSlot extends string> = { readonly [Name in keyof Variants]: { readonly [Option in keyof Variants[Name]]: string extends keyof Variants[Name][Option] ? unknown : Readonly<Partial<Record<Exclude<Exclude<keyof Variants[Name][Option], Slot>, InheritedSlot>, never>>>; }; };
2728
//#endregion
2829
//#region src/kind-selection.d.ts
2930
type AnySelection = Readonly<Record<string, unknown>>;
@@ -53,6 +54,7 @@ type MergeVariants<Union> = { readonly [Name in KeyOfEach<Union>]: MergeEach<Val
5354
type ComposedDefaultedName<Composed extends readonly unknown[], DefaultedName> = DefaultedName | ComposedPart<Composed, "defaultedName">;
5455
type InheritedDefaultedName<Composed extends readonly unknown[], Variants> = Extract<ComposedPart<Composed, "defaultedName">, keyof ComposedVariants<Composed, Variants>>;
5556
type ComposedSlot<Composed extends readonly unknown[], Slot extends string> = Slot | SlotOf<ComposedPart<Composed, "slots">>;
57+
type InheritedSlot<Composed extends readonly ComposableKindSlotRecipe<unknown>[]> = NonNullable<Composed[number]["~composition"]>["slots"][number];
5658
type SlotOf<Slots> = Slots extends readonly (infer Slot extends string)[] ? Slot : never;
5759
type ComposableKindRecipe<Value> = Composable<RecipeComposition<object, PropertyKey, Value, undefined>>;
5860
type ComposableKindSlotRecipe<Value> = Composable<RecipeComposition<object, PropertyKey, Value, readonly string[]>>;
@@ -84,7 +86,7 @@ type CreateKindRecipe<Value, Result> = <const Variants extends KindVariants<Valu
8486
declare function createRecipeKind<Value, Accumulator, Result = Accumulator>(kind: RecipeKind<Value, Accumulator, Result>): CreateKindRecipe<Value, Result>;
8587
//#endregion
8688
//#region src/slot-recipe-kind.d.ts
87-
type SlotValues<Slot extends string, Value> = Readonly<Partial<Record<Slot, Value | undefined>>>;
89+
type SlotValues<Slot extends string, Value> = { readonly [Name in Slot]?: Value | undefined; };
8890
type KindSlotVariants<Value> = KindVariants<SlotValues<string, Value>>;
8991
interface KindSlotCompoundVariant<Variants, Slot extends string, Value> {
9092
readonly variants: KindCompoundCondition<Variants>;
@@ -93,9 +95,9 @@ interface KindSlotCompoundVariant<Variants, Slot extends string, Value> {
9395
interface KindSlotRecipeConfig<Slot extends string, Value, Variants extends KindSlotVariants<Value>, DefaultedName extends keyof ComposedVariants<Composed, Variants>, Composed extends readonly ComposableKindSlotRecipe<Value>[] = readonly []> {
9496
readonly composes?: Composed | undefined;
9597
readonly slots: readonly Slot[];
96-
readonly base?: SlotValues<NoInfer<ComposedSlot<Composed, Slot>>, Value> | undefined;
97-
readonly variants: Variants & NoUnknownSlots<Variants, NoInfer<ComposedSlot<Composed, Slot>>>;
98-
readonly compoundVariants?: readonly KindSlotCompoundVariant<NoInfer<ComposedVariants<Composed, Variants>>, NoInfer<ComposedSlot<Composed, Slot>>, Value>[] | undefined;
98+
readonly base?: SlotValues<NoInfer<Slot> | InheritedSlot<Composed>, Value> | undefined;
99+
readonly variants: Variants & NoUnknownComposedSlots<Variants, NoInfer<Slot>, InheritedSlot<Composed>>;
100+
readonly compoundVariants?: readonly KindSlotCompoundVariant<NoInfer<ComposedVariants<Composed, Variants>>, NoInfer<Slot> | InheritedSlot<Composed>, Value>[] | undefined;
99101
readonly defaultVariants?: KindDefaultVariants<ComposedVariants<Composed, Variants>, DefaultedName> | undefined;
100102
readonly cache?: boolean | undefined;
101103
}

‎consumers/recipe/define.ts‎

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -76,4 +76,27 @@ function defineComposedSlots<
7676
return slotStyleRecipe(config);
7777
}
7878

79-
export { define, defineComposed, defineComposedSlots, defineSlots };
79+
/** Adds a footer to any slot recipe, as a library's own helper does. */
80+
function withFooter<const Base extends ComposableKindSlotRecipe<Style>>(
81+
base: Base,
82+
): ReturnType<
83+
typeof slotStyleRecipe<
84+
"footer",
85+
{
86+
readonly dense: {
87+
readonly true: { readonly footer: { readonly borderStyle: "dashed" } };
88+
};
89+
},
90+
never,
91+
readonly [Base]
92+
>
93+
> {
94+
return slotStyleRecipe({
95+
base: { footer: { borderStyle: "solid" } },
96+
composes: [base],
97+
slots: ["footer"],
98+
variants: { dense: { true: { footer: { borderStyle: "dashed" } } } },
99+
});
100+
}
101+
102+
export { define, defineComposed, defineComposedSlots, defineSlots, withFooter };

‎consumers/recipe/index.ts‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import {
1717
defineComposed,
1818
defineComposedSlots,
1919
defineSlots,
20+
withFooter,
2021
} from "./define.js";
2122

2223
type Style = Readonly<Record<string, string | number>>;
@@ -125,6 +126,10 @@ const sheetStyles: Readonly<Record<"root" | "title" | "footer", Style>> = sheet(
125126
{ tone: "dark" },
126127
);
127128

129+
const footedCard = withFooter(card);
130+
const footedCardStyles: Readonly<Record<"root" | "title" | "footer", Style>> =
131+
footedCard({ dense: true });
132+
128133
export {
129134
bigText,
130135
bigTextStyle,
@@ -138,6 +143,8 @@ export {
138143
dialogStyles,
139144
emphasis,
140145
emphasisKeys,
146+
footedCard,
147+
footedCardStyles,
141148
emphasisStyle,
142149
field,
143150
fieldStyles,

‎docs/src/content/docs/recipe/composable-libraries.mdx‎

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -85,10 +85,13 @@ A library of slot recipes does the same with the slot recipe types:
8585

8686
- `ComposableKindSlotRecipe<Style>` instead of `ComposableKindRecipe`, as
8787
the type of `composes`.
88-
- `ComposedSlot<Composed, Slot>` for the slot names, which adds the slots
89-
of the slot recipes it composes to those of `slots`. Use it for the
90-
keys of `base` and of compound variants.
91-
- The same slots written out for the keys of the result and in its
88+
- The slots of `slots` and those of the slot recipes it composes,
89+
written out, for the keys of `base` and of compound variants, as
90+
`SlotValues<NoInfer<Slot> | NonNullable<Composed[number]["~composition"]>["slots"][number], Style>`.
91+
Not `ComposedSlot<Composed, Slot>`, a conditional type that TypeScript
92+
cannot reduce while `Composed` is generic: a user's function generic
93+
over the recipe it composes could then name none of its own slots.
94+
- The same slots for the keys of the result and in its
9295
`RecipeComposition`, whose last type parameter is the list of its
9396
slots:
9497
`Slot | Exclude<Composed[number]["~composition"], undefined>["slots"][number]`.

‎packages/recipe/.oxlintrc.json‎

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,5 +21,14 @@
2121
]
2222
}
2323
]
24-
}
24+
},
25+
"overrides": [
26+
{
27+
// TypeScript relates an object to one mapped type over slots that
28+
// are generic, as in a function generic over the slot recipe that a
29+
// recipe composes, but not to a Record nested in Partial and Readonly.
30+
"files": ["src/slot-recipe-kind.ts"],
31+
"rules": { "typescript/consistent-indexed-object-style": "off" }
32+
}
33+
]
2534
}

‎packages/recipe/CHANGELOG.md‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,11 @@ tagged `recipe@<version>`.
66

77
## Unreleased
88

9+
- A slot recipe's config types the slots of the slot recipes it composes
10+
apart from its own, so that a function generic over the slot recipe it
11+
composes can name its own slots, and declarations print the slots of
12+
`base` and of compound variants by name. Making library recipes
13+
composable advises the same for a library's types.
914
- Creating a recipe or a slot recipe warns, once, with `console.warn`,
1015
about a default or a compound variant that names a variant or an option
1116
that no config of the recipe declares, and, in a slot recipe, about a

‎packages/recipe/src/compose-slots.test.ts‎

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { describe, expect, expectTypeOf, it } from "vitest";
22

3+
import type { ComposableKindSlotRecipe } from "./composition.js";
34
import type { VariantsOf } from "./types.js";
45
import { createRecipeKind } from "./recipe-kind.js";
56
import { createSlotRecipeKind } from "./slot-recipe-kind.js";
@@ -108,4 +109,66 @@ describe("a slot recipe that composes slot recipes", () => {
108109

109110
expect(Object.keys(labeled())).toStrictEqual(["root", "label"]);
110111
});
112+
113+
it("accepts its own slots in a function generic over the slot recipe it composes", () => {
114+
const footed = withFooter(field);
115+
116+
expect(footed({ dense: true })).toStrictEqual({
117+
root: ["field", "field-sm"],
118+
label: ["field-label"],
119+
footer: ["footer", "footer-dense"],
120+
});
121+
expectTypeOf<VariantsOf<typeof footed>>().toEqualTypeOf<{
122+
readonly size?: "sm" | "md" | undefined;
123+
readonly invalid?: boolean | "true" | "false" | undefined;
124+
readonly dense?: boolean | "true" | "false" | undefined;
125+
}>();
126+
});
127+
128+
it("rejects a slot that it does not declare in a function generic over the slot recipe it composes", () => {
129+
expect(Object.keys(withIcon(field)())).toStrictEqual([
130+
"root",
131+
"label",
132+
"footer",
133+
]);
134+
});
111135
});
136+
137+
/** Adds a footer to any slot recipe, as a library's own helper does. */
138+
function withFooter<const Base extends ComposableKindSlotRecipe<string>>(
139+
base: Base,
140+
): ReturnType<
141+
typeof slotListRecipe<
142+
"footer",
143+
{ readonly dense: { readonly true: { readonly footer: "footer-dense" } } },
144+
never,
145+
readonly [Base]
146+
>
147+
> {
148+
return slotListRecipe({
149+
composes: [base],
150+
slots: ["footer"],
151+
base: { footer: "footer" },
152+
variants: { dense: { true: { footer: "footer-dense" } } },
153+
});
154+
}
155+
156+
/** Adds a footer, and a value for a slot that no recipe declares. */
157+
function withIcon<const Base extends ComposableKindSlotRecipe<string>>(
158+
base: Base,
159+
): ReturnType<
160+
typeof slotListRecipe<
161+
"footer",
162+
{ readonly dense: { readonly true: { readonly footer: "footer-dense" } } },
163+
never,
164+
readonly [Base]
165+
>
166+
> {
167+
return slotListRecipe({
168+
composes: [base],
169+
slots: ["footer"],
170+
// @ts-expect-error: icon is not a slot of the recipe.
171+
base: { icon: "icon" },
172+
variants: { dense: { true: { footer: "footer-dense" } } },
173+
});
174+
}

‎packages/recipe/src/composition.ts‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,15 @@ type InheritedDefaultedName<
114114
type ComposedSlot<Composed extends readonly unknown[], Slot extends string> =
115115
Slot | SlotOf<ComposedPart<Composed, "slots">>;
116116

117+
/**
118+
* The slots of the slot recipes of `Composed`, as indexed access rather
119+
* than a conditional type, so that TypeScript relates a config to them
120+
* while `Composed` is generic.
121+
*/
122+
type InheritedSlot<
123+
Composed extends readonly ComposableKindSlotRecipe<unknown>[],
124+
> = NonNullable<Composed[number]["~composition"]>["slots"][number];
125+
117126
type SlotOf<Slots> = Slots extends readonly (infer Slot extends string)[]
118127
? Slot
119128
: never;
@@ -164,5 +173,6 @@ export type {
164173
ComposedSlot,
165174
ComposedVariants,
166175
InheritedDefaultedName,
176+
InheritedSlot,
167177
RecipeComposition,
168178
};

0 commit comments

Comments
 (0)