How I finally got real type safety on children values without losing the beautiful dot API
Compound components are awesome for building flexible UI primitives — think <Tabs>, <Accordion>, <RadioGroup>, or <Popover> — where the consumer controls the layout and composition.
But flexibility often meant zero type safety on children or values.
I used to write stuff like this:
<RadioGroup value={selected} onChange={setSelected}>
<RadioItem value="draft">Draft</RadioItem>
<RadioItem value="published">Published</RadioItem>
<RadioItem value={123}>Wait what? {/* ← TypeScript is silent 😭 */}
</RadioItem>
</RadioGroup>
I wanted TypeScript to actually yell at me when the value type was wrong, while keeping the clean dot-notation DX everyone loves:
<StatusRadio.RadioGroup value={status} onValueChange={setStatus}>
<StatusRadio.RadioItem value="draft">...</StatusRadio.RadioItem>
{/* value={123} → red squiggle! */}
</StatusRadio.RadioGroup>
After reading TkDodo’s excellent post “Building Type-Safe Compound Components” (Jan 2, 2026), I extended the ideas into a tiny generic factory. It’s now my go-to for typed item groups (radios, tabs, segmented controls, steppers…).
Two Flavors of Compound Components
- Max layout freedom (Card/Dialog style) Free-form children, arbitrary order:
<Card>
<CardHeader>Title</CardHeader>
<CardContent>Anything goes</CardContent>
<CardFooter>Actions</CardFooter>
</Card>
→ Use simple context + sub-components. Super common in shadcn/ui, Radix, etc. (Not the focus here).
- Strict typed children (Radio/Tabs/Steps style) Predictable items + strong value typing — factory time!
type Status = "draft" | "published" | "archived";
const StatusRadio = createTypedRadioGroup<Status>();
<StatusRadio.RadioGroup value={status} onValueChange={setStatus}>
<StatusRadio.RadioItem value="draft">
<StatusRadio.RadioIndicator />
Draft
</StatusRadio.RadioItem>
{/* value={123} → compile error */}
</StatusRadio.RadioGroup>
The Reusable Factory Pattern
import { createContext, useContext } from "react";
import {
RadioGroup as BaseRadioGroup,
Radio,
mergeProps,
} from "@base-ui/react";
import { radioGroupRootVariants, radioItemVariants, RadioIndicator } from "../ui/radio";
type RadioGroupContextValue = {
size?: "sm" | "md" | "lg" | null;
variant?: "default" | "outline" | null;
};
export function createTypedRadioGroup<T>() {
const RadioGroupContext = createContext<RadioGroupContextValue | null>(null);
function TypedRadioGroup({
size,
variant,
orientation,
className,
children,
...props
}: Omit<React.ComponentProps<typeof BaseRadioGroup>, "children" | "className"> & {
size?: "sm" | "md" | "lg";
variant?: "default" | "outline";
orientation?: "horizontal" | "vertical";
className?: string;
children: React.ReactNode;
}) {
const ctxValue = { size: size ?? null, variant: variant ?? null };
return (
<RadioGroupContext.Provider value={ctxValue}>
<BaseRadioGroup
className={radioGroupRootVariants({ size, orientation })}
{...props}
>
{children}
</BaseRadioGroup>
</RadioGroupContext.Provider>
);
}
function TypedRadioItem({
value,
size: itemSize,
className,
children,
...props
}: { value: T; size?: "sm" | "md" | "lg" } & Omit<
React.ComponentProps<typeof Radio.Root>,
"value" | "children"
>) {
const context = useContext(RadioGroupContext);
if (!context) {
throw new Error("RadioItem must be used inside RadioGroup");
}
const finalSize = itemSize ?? context.size ?? "md";
const merged = mergeProps({ className }, {
className: radioItemVariants({ size: finalSize }),
});
// Adapt value to string/number as needed by your base library
return (
<Radio.Root value={String(value)} className={merged.className} {...props}>
{children}
</Radio.Root>
);
}
return {
RadioGroup: TypedRadioGroup,
RadioItem: TypedRadioItem,
RadioIndicator,
} as const;
}
Note: If your headless base (Radix/Ark/Base UI) supports generic values natively, drop the String(value) cast.
Real-World Examples:
- Status Picker (string literals)
type Status = "draft" | "published" | "archived";
const StatusRadio = createTypedRadioGroup<Status>();
export function StatusPicker() {
const [status, setStatus] = useState<Status>("draft");
return (
<StatusRadio.RadioGroup
value={status}
onValueChange={setStatus}
size="md"
variant="default"
>
<StatusRadio.RadioItem value="draft">
<StatusRadio.RadioIndicator />
Draft
</StatusRadio.RadioItem>
<StatusRadio.RadioItem value="published">
<StatusRadio.RadioIndicator />
Published
</StatusRadio.RadioItem>
<StatusRadio.RadioItem value="archived">
<StatusRadio.RadioIndicator />
Archived
</StatusRadio.RadioItem>
</StatusRadio.RadioGroup>
);
}
- Star Rating (numbers)
const RatingRadio = createTypedRadioGroup<number>();
<RatingRadio.RadioGroup value={rating} onValueChange={setRating} orientation="horizontal">
{[1, 2, 3, 4, 5].map((n) => (
<RatingRadio.RadioItem key={n} value={n}>
<RatingRadio.RadioIndicator />
{n} ★
</RatingRadio.RadioItem>
))}
</RatingRadio.RadioGroup>
- Object-based (real-world use case)
interface Option {
id: string;
label: string;
description: string;
}
const OptionRadio = createTypedRadioGroup<Option>();
export function OptionPicker() {
const options: Option[] = [
{ id: "1", label: "Option 1", description: "First option" },
{ id: "2", label: "Option 2", description: "Second option" },
{ id: "3", label: "Option 3", description: "Third option" },
];
const [selected, setSelected] = useState<Option>(options[0]);
return (
<OptionRadio.RadioGroup value={selected} onValueChange={setSelected}>
{options.map((option) => (
<OptionRadio.RadioItem key={option.id} value={option}>
<OptionRadio.RadioIndicator />
<div>
<div className="font-medium">{option.label}</div>
<div className="text-sm text-gray-500">{option.description}</div>
</div>
</OptionRadio.RadioItem>
))}
</OptionRadio.RadioGroup>
);
}
Trade-offs – When to Use (or Skip) This
Use the factory when:
- Strong value type safety is non-negotiable
- Children order is predictable (list/group items)
- You want that sweet dot-notation DX
Skip it (use plain context + sub-components) when:
- 1. You need a completely free-form layout/order
- 2. Building low-level primitives (just use Radix/Ark/Base UI directly)
Final Thoughts
This little factory has been my default for typed groups since 2026 simple, type-safe, minimal boilerplate.
Ideas to level it up:
Propagate disabled from group → items
- Add defaultValue support
- Generalize for
<Tabs>,<Accordion>,<RadioGroup>etc. - Package it as a tiny npm lib
Steal it, tweak it, ship with it.
What’s your go-to pattern for typed compound components in 2026? Drop a comment — or hit me up on X: @sambalicious_ 🚀

Top comments (0)