From fb749eeb2f272fdd24ebbdaeb9901fc4a3955307 Mon Sep 17 00:00:00 2001 From: Luke Bennett Date: Fri, 25 Sep 2026 14:01:13 +1000 Subject: [PATCH] Reject unknown recipe variant keys (#658) * Reject unknown recipe variant keys * Deslop recipe.ts JSDoc and tighten predeclared-config wording Say why RejectUnknownKeys exists once, at its own type, instead of repeating the reasoning on SinglePartConfig, MultiPartConfig, and withDefaultVariants. Spell out "a config declared before the recipe() call" instead of "predeclared config" in recipe.ts, recipe.test.ts, and STYLING.md, and split the test file's header comment into one sentence per idea. --- docs/STYLING.md | 5 + .../react/src/core/styles/recipe.test.ts | 142 ++++++++++++++++++ .../@luke-ui/react/src/core/styles/recipe.ts | 111 +++++++++++--- 3 files changed, 236 insertions(+), 22 deletions(-) create mode 100644 packages/@luke-ui/react/src/core/styles/recipe.test.ts diff --git a/docs/STYLING.md b/docs/STYLING.md index fa606b53..25886bd1 100644 --- a/docs/STYLING.md +++ b/docs/STYLING.md @@ -203,6 +203,11 @@ export type ButtonRecipeVariants = RecipeSelection; `RecipeSelection` contains only variant keys. `className` is composition input, not a variant. +The variant groups in `variants` are the only groups a recipe accepts. A group name that `variants` +does not declare is a type error in `defaultVariants`, `compoundVariants`, `compoundSlots`, and +`withDefaultVariants` defaults, including in a config declared before the `recipe()` call and in a +compound entry a helper function returns. + ### Shared input-state selectors Field-control recipes share hover, focus, disabled, invalid, and read-only selectors from diff --git a/packages/@luke-ui/react/src/core/styles/recipe.test.ts b/packages/@luke-ui/react/src/core/styles/recipe.test.ts new file mode 100644 index 00000000..1608ceab --- /dev/null +++ b/packages/@luke-ui/react/src/core/styles/recipe.test.ts @@ -0,0 +1,142 @@ +import { assertType, describe, expectTypeOf, test } from 'vite-plus/test'; +import type { RecipeSelection } from './recipe-types.js'; +import type { SlottedConfigInput } from './recipe.js'; +import { recipe, withDefaultVariants } from './recipe.js'; + +// `recipe()` needs a Vanilla Extract file scope, so every build sits in an arrow that only the +// type checker reads. A config declared before the call matters most, because excess property +// checks skip it. + +// Single-part `compoundVariants` is a mutable array, so `as const` goes on each entry's parts. +const singlePart = { + compoundVariants: [{ style: {}, variants: { size: 'small', tone: 'accent' } as const }], + defaultVariants: { size: 'medium' } as const, + variants: { + size: { medium: {}, small: {} }, + tone: { accent: {} }, + } as const, +}; + +const slotted = { + compoundSlots: [{ slots: ['root'], style: {}, variants: { size: 'small' } }], + defaultVariants: { size: 'medium' }, + slots: { label: {}, root: {} }, + variants: { size: { medium: { root: {} }, small: { label: {} } } }, +} as const satisfies SlottedConfigInput; + +describe('recipe variant keys', () => { + test('infers known groups and values from the definition', () => { + const buildSinglePart = () => recipe(singlePart); + const buildSlotted = () => recipe(slotted); + + expectTypeOf>>().toEqualTypeOf<{ + size?: 'medium' | 'small' | undefined; + tone?: 'accent' | undefined; + }>(); + expectTypeOf>>().toEqualTypeOf<{ + size?: 'medium' | 'small' | undefined; + }>(); + }); + + test('rejects an unknown group in a predeclared single-part config', () => { + const unknownDefault = { + defaultVariants: { bogus: 'x', size: 'small' }, + variants: { size: { small: {} } }, + } as const; + const unknownCompound = { + compoundVariants: [ + { style: {}, variants: { size: 'small' } as const }, + { style: {}, variants: { bogus: 'x', size: 'small' } as const }, + ], + variants: { size: { small: {} } } as const, + }; + + assertType(() => + // @ts-expect-error — `bogus` is not a variant group + recipe(unknownDefault), + ); + assertType(() => + // @ts-expect-error — `bogus` is not a variant group + recipe(unknownCompound), + ); + }); + + test('rejects an unknown group in a compound variant built by a helper', () => { + const entry = () => ({ style: {}, variants: { bogus: 'x' as const, size: 'small' as const } }); + + assertType(() => + recipe({ + // @ts-expect-error — `bogus` is not a variant group + compoundVariants: [entry()], + variants: { size: { small: {} } }, + }), + ); + }); + + test('rejects an unknown group in a predeclared slotted config', () => { + const unknownDefault = { + defaultVariants: { bogus: 'x', size: 'small' }, + slots: { root: {} }, + variants: { size: { small: { root: {} } } }, + } as const satisfies SlottedConfigInput; + const unknownCompound = { + compoundSlots: [{ slots: ['root'], style: {}, variants: { bogus: 'x', size: 'small' } }], + slots: { root: {} }, + variants: { size: { small: { root: {} } } }, + } as const satisfies SlottedConfigInput; + + assertType(() => + // @ts-expect-error — `bogus` is not a variant group + recipe(unknownDefault), + ); + assertType(() => + // @ts-expect-error — `bogus` is not a variant group + recipe(unknownCompound), + ); + }); + + test('rejects any group on a recipe without variants', () => { + const singlePartDefault = { base: {}, defaultVariants: { bogus: 'x' } } as const; + const slottedDefault = { + defaultVariants: { bogus: 'x' }, + slots: { root: {} }, + } as const satisfies SlottedConfigInput; + + assertType(() => + // @ts-expect-error — the recipe has no variant groups + recipe(singlePartDefault), + ); + assertType(() => + // @ts-expect-error — the recipe has no variant groups + recipe(slottedDefault), + ); + assertType(() => + // @ts-expect-error — the recipe has no variant groups + recipe({ base: {} })({ bogus: 'x' }), + ); + assertType(() => + // @ts-expect-error — the recipe has no variant groups + recipe({ slots: { root: {} } })({ bogus: 'x' }), + ); + }); + + test('rejects an unknown group in a selection', () => { + assertType(() => + // @ts-expect-error — `bogus` is not a variant group + recipe(singlePart)({ bogus: 'x', size: 'small' }), + ); + assertType(() => + // @ts-expect-error — `bogus` is not a variant group + recipe(slotted)({ bogus: 'x', size: 'small' }), + ); + }); + + test('rejects an unknown key in predeclared withDefaultVariants defaults', () => { + const defaults = { bogus: 'x', size: 'small' } as const; + + assertType(() => + // @ts-expect-error — `bogus` is not a variant group + withDefaultVariants(recipe(singlePart), defaults), + ); + }); +}); diff --git a/packages/@luke-ui/react/src/core/styles/recipe.ts b/packages/@luke-ui/react/src/core/styles/recipe.ts index c5975dc0..791e37e2 100644 --- a/packages/@luke-ui/react/src/core/styles/recipe.ts +++ b/packages/@luke-ui/react/src/core/styles/recipe.ts @@ -67,6 +67,35 @@ type Selection = -readonly [Group in keyof Variants]?: BooleanMap | undefined; }; +/** Keys of every member of a union, not only the keys every member shares. */ +type KeysOfUnion = T extends unknown ? keyof T : never; + +/** + * Marks each key of `Actual` that `Allowed` does not declare as `never`. + * + * Excess property checks run only on fresh object literals. A config declared before the + * `recipe()` call (the `as const satisfies SlottedConfigInput` pattern) or a compound entry a + * helper function returns is not a fresh literal at the call site, so it would otherwise name any + * group with no error. + */ +type RejectUnknownKeys = { + [Key in Exclude, keyof Allowed>]?: never; +}; + +/** A selection that rejects every group `Actual` names that the recipe does not declare. */ +type StrictSelection = Selection & + RejectUnknownKeys>; + +/** + * The type a config passes for an authored selection: the captured `Actual`, checked against the + * recipe's groups. `NoInfer` keeps `config.variants` as the only inference source for `Variants`. + */ +type CheckedSelection = Actual & NoInfer>; + +/** The union of every `variants` selection in a list of compound entries. */ +type CompoundSelections> = + Compounds[number]['variants']; + /** Variant selection plus optional `className`. */ type RecipeInput = HasNoGroups extends true @@ -75,17 +104,28 @@ type RecipeInput = -readonly [Group in keyof Variants]?: BooleanMap | undefined; } & RecipeComposition; -/** A compound variant for a single-part recipe. */ -interface CompoundVariant { +/** + * A compound variant for a single-part recipe. `Selected` is the union of every compound + * selection in the config, so an unknown group in any entry is rejected. + */ +interface CompoundVariant> { style: RecipeStyleRule; - variants: Selection; + variants: StrictSelection; } -/** Single-part recipe config. */ -interface SinglePartConfig { +/** + * Single-part recipe config. `Defaults` and `Compounds` feed `RejectUnknownKeys` the authored + * selections. + */ +interface SinglePartConfig< + Variants extends VariantGroups, + Defaults = Selection, + Compounds extends Array> = Array>, +> { base?: RecipeStyleRule; - compoundVariants?: Array>; - defaultVariants?: Selection; + compoundVariants?: Compounds & + NoInfer>>>; + defaultVariants?: CheckedSelection; variants?: Variants; } @@ -103,16 +143,29 @@ type SlotVariantGroups = Record> { +interface CompoundSlot< + Slot extends string, + Variants extends SlotVariantGroups, + Selected = Selection, +> { slots: ReadonlyArray>; style: SlottedStyleRule; - variants?: NoInfer>; + variants?: NoInfer>; } -/** Slotted recipe config. */ -interface MultiPartConfig> { - compoundSlots?: Array>; - defaultVariants?: Selection; +/** + * Slotted recipe config. `Defaults` and `Compounds` feed `RejectUnknownKeys` the authored + * selections. + */ +interface MultiPartConfig< + Slot extends string, + Variants extends SlotVariantGroups, + Defaults = Selection, + Compounds extends Array> = Array>, +> { + compoundSlots?: Compounds & + NoInfer>>>; + defaultVariants?: CheckedSelection; slots: Record; variants?: Variants; } @@ -148,14 +201,19 @@ export interface SlottedConfigInput { // --------------------------------------------------------------------------- /** Builds a slotted recipe (variant selection at the outer call, one function per slot). */ -export function recipe>( - config: MultiPartConfig, -): MultiPartRecipe; +export function recipe< + const Slot extends string, + const Variants extends SlotVariantGroups, + const Defaults = Selection, + const Compounds extends Array> = Array>, +>(config: MultiPartConfig): MultiPartRecipe; /** Builds a single-part recipe (variant selection at the outer call, returns a class string). */ -export function recipe( - config: SinglePartConfig, -): SinglePartRecipe; +export function recipe< + const Variants extends VariantGroups, + const Defaults = Selection, + const Compounds extends Array> = Array>, +>(config: SinglePartConfig): SinglePartRecipe; export function recipe(config: AnyMultiPartConfig | SinglePartConfig): unknown { if (isMultiPart(config)) { @@ -192,11 +250,20 @@ function registerSerializer(fn: object, importName: string, args: ReadonlyArray< }); } -/** Adds defaults to a recipe without changing consumer-supplied variant selections. */ -export function withDefaultVariants( +/** + * Adds defaults to a recipe without changing consumer-supplied variant selections. `Defaults` + * feeds `RejectUnknownKeys` the authored defaults. + */ +export function withDefaultVariants< + Input extends object, + PublicInput extends Input = Input, + const Defaults extends Input = Input, +>( recipe: (input?: Input) => string, - defaults: Input, + checkedDefaults: Defaults & RejectUnknownKeys, ): (input?: PublicInput) => string { + // The rejection map only exists to fail the call; past it, the defaults are an `Input`. + const defaults: Input = checkedDefaults; const wrapped = (input?: PublicInput) => { const selection = { ...defaults, ...input }; for (const key in defaults) { -- 2.51.2