Skip to content
TkDodo's blog

Building Type-Safe Compound Components

Building Type-Safe Compound Components
Photo by Jeremy Thomas

I think compound components are a really good design-pattern when building component libraries. They give consumers flexibility in how components are composed, without forcing every variation into a single, prop-heavy API. Additionally, they make relationships between components explicit in the markup.

This doesn’t mean that compound components are always a good fit. Sometimes, using props is just better.

A Bad Example

A common example we’ll see regarding compound components is a Select with Options, likely because that’s also how it works in HTML.

Compound Select
function
function ThemeSwitcher({ value, onChange }: Props): React.JSX.Element
ThemeSwitcher
({
value: ThemeValue
value
,
onChange: (value: ThemeValue) => void
onChange
}:
type Props = {
value: ThemeValue;
onChange: (value: ThemeValue) => void;
}
Props
) {
return (
<
function Select(props: {
value: ThemeValue;
onChange: (value: ThemeValue) => void;
children?: ReactNode;
}): ReactElement
Select
value: ThemeValue
value
={
value: ThemeValue
value
}
onChange: (value: ThemeValue) => void
onChange
={
onChange: (value: ThemeValue) => void
onChange
}>
<
function Option(props: {
value: ThemeValue;
children?: ReactNode;
}): ReactElement
Option
value: ThemeValue
value
="system">🤖</
function Option(props: {
value: ThemeValue;
children?: ReactNode;
}): ReactElement
Option
>
<
function Option(props: {
value: ThemeValue;
children?: ReactNode;
}): ReactElement
Option
value: ThemeValue
value
="light">☀️</
function Option(props: {
value: ThemeValue;
children?: ReactNode;
}): ReactElement
Option
>
<
function Option(props: {
value: ThemeValue;
children?: ReactNode;
}): ReactElement
Option
value: ThemeValue
value
="dark">🌑</
function Option(props: {
value: ThemeValue;
children?: ReactNode;
}): ReactElement
Option
>
</
function Select(props: {
value: ThemeValue;
onChange: (value: ThemeValue) => void;
children?: ReactNode;
}): ReactElement
Select
>
)
}

There are a couple of reasons why I think this example is not ideal at showing what compound components are good for.

1. Fixed Layout

Compound components excel at allowing users to layout children however they want. For Selects, we likely don’t need that. The options go into a menu, and we want to show each option one after the other. This is exactly why lots of people want to limit what you can pass (opens in a new window) into children on type-level - like only allowing Option to be passed into Select.

This is not only impossible (opens in a new window) as of now (the issue is open since 2018), it’s also undesirable. I know you were hoping that I would tell you how to make compound components type-safe in this article, and I will - it’s just not about children at all. My take is that if you want to limit children to a specific type, compound components are the wrong abstraction.

2. Dynamic Content

Compound components are really good when your content is mostly static. The above example has three hardcoded options, so it qualifies, right?

In reality, we likely won’t have Selects with just three options, as the content will mostly likely come from an API call with a dynamic resultset. Also, most design guides will tell us that we shouldn’t use a Select for less than five options (opens in a new window), as hiding a small number of choices in a dropdown adds unnecessary clicks and cognitive load.

In fact, at Adverity, we started out with a compound component Select and then had to write this mapping code for most of our usages:

Select Usage
function
function UserSelect({ value, onChange }: Props): React.JSX.Element
UserSelect
({
value: string
value
,
onChange: (value: string) => void
onChange
}:
type Props = {
value: string;
onChange: (value: string) => void;
}
Props
) {
const
const userQuery: UseSuspenseQueryResult<UserOption[], Error>
userQuery
=
useSuspenseQuery<UserOption[], Error, UserOption[], string[]>(options: UseSuspenseQueryOptions<UserOption[], Error, UserOption[], string[]>, queryClient?: QueryClient): UseSuspenseQueryResult<UserOption[], Error>
useSuspenseQuery
(
const userOptions: OmitKeyof<UseQueryOptions<UserOption[], Error, UserOption[], string[]>, "queryFn"> & {
queryFn?: QueryFunction<UserOption[], string[], never>;
} & {
queryKey: string[] & {
[dataTagSymbol]: UserOption[];
[dataTagErrorSymbol]: Error;
};
}
userOptions
)
return (
<
function Select(props: {
value: string;
onChange: (value: string) => void;
children?: ReactNode;
}): ReactElement
Select
value: string
value
={
value: string
value
}
onChange: (value: string) => void
onChange
={
onChange: (value: string) => void
onChange
}>
{
const userQuery: UseSuspenseQueryResult<UserOption[], Error>
userQuery
.
data: UserOption[]
data
.
Array<UserOption>.map<React.JSX.Element>(callbackfn: (value: UserOption, index: number, array: UserOption[]) => React.JSX.Element, thisArg?: any): React.JSX.Element[]
map
((
option: UserOption
option
) => (
<
function Option(props: {
value: string;
children?: ReactNode;
}): ReactElement
Option
value: string
value
={
option: UserOption
option
.
value: string
value
}>{
option: UserOption
option
.
label: string
label
}</
function Option(props: {
value: string;
children?: ReactNode;
}): ReactElement
Option
>
))}
</
function Select(props: {
value: string;
onChange: (value: string) => void;
children?: ReactNode;
}): ReactElement
Select
>
)
}

At that point, we just switched to exposing a Select that used props instead of children:

Select With Options
function
function UserSelect({ value, onChange }: Props): React.JSX.Element
UserSelect
({
value: string
value
,
onChange: (value: string) => void
onChange
}:
type Props = {
value: string;
onChange: (value: string) => void;
}
Props
) {
const
const userQuery: UseSuspenseQueryResult<UserOption[], Error>
userQuery
=
useSuspenseQuery<UserOption[], Error, UserOption[], string[]>(options: UseSuspenseQueryOptions<UserOption[], Error, UserOption[], string[]>, queryClient?: QueryClient): UseSuspenseQueryResult<UserOption[], Error>
useSuspenseQuery
(
const userOptions: OmitKeyof<UseQueryOptions<UserOption[], Error, UserOption[], string[]>, "queryFn"> & {
queryFn?: QueryFunction<UserOption[], string[], never>;
} & {
queryKey: string[] & {
[dataTagSymbol]: UserOption[];
[dataTagErrorSymbol]: Error;
};
}
userOptions
)
return (
<
function Select(props: {
value: string;
onChange: (value: string) => void;
options: ReadonlyArray<{
value: string;
label: string;
}>;
}): ReactElement
Select
value: string
value
={
value: string
value
}
onChange: (value: string) => void
onChange
={
onChange: (value: string) => void
onChange
}
options: readonly {
value: string;
label: string;
}[]
options
={
const userQuery: UseSuspenseQueryResult<UserOption[], Error>
userQuery
.
data: UserOption[]
data
}
/>
)
}

This not only allowed us to get rid of the tedious mapping we had to do everywhere, it also gave us the type safety we wanted, as there were no children that we needed to restrict. Also, Select can easily be made generic to make sure value, onChange and options all get the same type:

SelectProps
type
type SelectValue = string | number
SelectValue
= string | number
type
type SelectOption<T> = {
value: T;
label: string;
}
SelectOption
<
function (type parameter) T in type SelectOption<T>
T
> = {
value: T
value
:
function (type parameter) T in type SelectOption<T>
T
;
label: string
label
: string }
type
type SelectProps<T extends SelectValue> = {
value: T;
onChange: (value: T) => void;
options: ReadonlyArray<SelectOption<T>>;
}
SelectProps
<
function (type parameter) T in type SelectProps<T extends SelectValue>
T
extends
type SelectValue = string | number
SelectValue
> = {
value: T extends SelectValue
value
:
function (type parameter) T in type SelectProps<T extends SelectValue>
T
onChange: (value: T) => void
onChange
: (
value: T extends SelectValue
value
:
function (type parameter) T in type SelectProps<T extends SelectValue>
T
) => void
options: readonly SelectOption<T>[]
options
:
interface ReadonlyArray<T>
ReadonlyArray
<
type SelectOption<T> = {
value: T;
label: string;
}
SelectOption
<
function (type parameter) T in type SelectProps<T extends SelectValue>
T
>>
}

Slots

A ModalDialog component is another example where we’d rather not give our users the full power of compound components. I mean, it doesn’t make sense to render the DialogFooter above the DialogHeader. We also don’t want anyone to accidentally leave out the DialogBackdrop or create different spacings between DialogBody and DialogFooter. In cases where consistency and order is important, slots are usually a better abstraction:

ModalDialog
function
function ModalDialog({ header, body, footer }: Props): React.JSX.Element
ModalDialog
({
header: React.ReactNode
header
,
body: React.ReactNode
body
,
footer: React.ReactNode
footer
}:
type Props = {
header: ReactNode;
body: ReactNode;
footer?: ReactNode;
}
Props
) {
return (
<
function DialogRoot(props: {
children?: ReactNode;
}): ReactNode
DialogRoot
>
<
function DialogBackdrop(): ReactNode
DialogBackdrop
/>
<
function DialogContent(props: {
children?: ReactNode;
}): ReactNode
DialogContent
>
<
function DialogHeader(props: {
children?: ReactNode;
}): ReactNode
DialogHeader
>{
header: React.ReactNode
header
}</
function DialogHeader(props: {
children?: ReactNode;
}): ReactNode
DialogHeader
>
<
function DialogBody(props: {
children?: ReactNode;
}): ReactNode
DialogBody
>{
body: React.ReactNode
body
}</
function DialogBody(props: {
children?: ReactNode;
}): ReactNode
DialogBody
>
<
function DialogFooter(props: {
children?: ReactNode;
}): ReactNode
DialogFooter
>{
footer: React.ReactNode
footer
}</
function DialogFooter(props: {
children?: ReactNode;
}): ReactNode
DialogFooter
>
</
function DialogContent(props: {
children?: ReactNode;
}): ReactNode
DialogContent
>
</
function DialogRoot(props: {
children?: ReactNode;
}): ReactNode
DialogRoot
>
)
}
function
function Usage(): React.JSX.Element
Usage
() {
return <
function ModalDialog({ header, body, footer }: Props): React.JSX.Element
ModalDialog
header: React.ReactNode
header
="Hello"
body: React.ReactNode
body
="World" />
}

They still allow some form of flexibility by injecting arbitrary React components into specific positions while making sure no one has to copy-paste that boilerplate everywhere. It is of course great to have these Dialog primitives inside a design-system, I would just not expose them for consumers.


So those are two indicators - fixed layout and mostly dynamic content - that make me question if we really want a compound component. So when is it a really good fit then? And what does it have to do with type safety?

A Better Example

A good use-case that would likely benefit from dynamically laid out children with mostly fixed elements is a <ButtonGroup>, a <TabBar> or a <RadioGroup>:

RadioGroup
function
function ThemeSwitcher({ value, onChange }: Props): React.JSX.Element
ThemeSwitcher
({
value: ThemeValue
value
,
onChange: (value: ThemeValue) => void
onChange
}:
type Props = {
value: ThemeValue;
onChange: (value: ThemeValue) => void;
}
Props
) {
return (
<
function RadioGroup(props: {
value: ThemeValue;
onChange: (value: ThemeValue) => void;
children?: ReactNode;
}): ReactElement
RadioGroup
value: ThemeValue
value
={
value: ThemeValue
value
}
onChange: (value: ThemeValue) => void
onChange
={
onChange: (value: ThemeValue) => void
onChange
}>
<
function Flex(props: {
direction: ReadonlyArray<"row" | "column">;
gap: string;
children?: ReactNode;
}): ReactElement
Flex
direction: readonly ("row" | "column")[]
direction
={['row', 'column']}
gap: string
gap
="sm">
<
function RadioGroupItem(props: {
value: ThemeValue;
children?: ReactNode;
}): ReactElement
RadioGroupItem
value: ThemeValue
value
="system">🤖</
function RadioGroupItem(props: {
value: ThemeValue;
children?: ReactNode;
}): ReactElement
RadioGroupItem
>
<
function RadioGroupItem(props: {
value: ThemeValue;
children?: ReactNode;
}): ReactElement
RadioGroupItem
value: ThemeValue
value
="light">☀️</
function RadioGroupItem(props: {
value: ThemeValue;
children?: ReactNode;
}): ReactElement
RadioGroupItem
>
<
function RadioGroupItem(props: {
value: ThemeValue;
children?: ReactNode;
}): ReactElement
RadioGroupItem
value: ThemeValue
value
="dark">🌑</
function RadioGroupItem(props: {
value: ThemeValue;
children?: ReactNode;
}): ReactElement
RadioGroupItem
>
</
function Flex(props: {
direction: ReadonlyArray<"row" | "column">;
gap: string;
children?: ReactNode;
}): ReactElement
Flex
>
</
function RadioGroup(props: {
value: ThemeValue;
onChange: (value: ThemeValue) => void;
children?: ReactNode;
}): ReactElement
RadioGroup
>
)
}

The main difference to Select is that we explicitly want to have children that aren’t of type RadioGroupItem. Being able to layout them how we want, maybe even with additional help texts, is essential. Sure, we might have some instances where our RadioGroup needs dynamic options as well, but in that case, creating a loop like I’ve shown before isn’t the end of the world.

That still leaves the problem of type safety, as value passed to ThemeSwitcher hopefully isn’t just a string - it’s likely a string literal:

ThemeValue
type
type ThemeValue = "system" | "light" | "dark"
ThemeValue
= 'system' | 'light' | 'dark'

The value and onChange props can be tied to ThemeValue simply by making RadioGroup generic like I’ve shown before, but what about RadioGroupItem ? How do we make sure that the value passed to each one of them can be statically analyzed?

Type Safety

We can of course also make RadioGroupItem generic. The problem with that approach is that types from the RadioGroup wouldn’t be automatically available to the items, because JSX children don’t “inherit” type parameters from the parent component. So even if RadioGroup is perfectly typed and infers <ThemeValue>, we would still need to parameterize each RadioGroupItem:

Generic RadioGroupItem
type
type ThemeValue = "system" | "light" | "dark"
ThemeValue
= 'system' | 'light' | 'dark'
type
type Props = {
value: ThemeValue;
onChange: (value: ThemeValue) => void;
}
Props
= {
value: ThemeValue
value
:
type ThemeValue = "system" | "light" | "dark"
ThemeValue
onChange: (value: ThemeValue) => void
onChange
: (
value: ThemeValue
value
:
type ThemeValue = "system" | "light" | "dark"
ThemeValue
) => void
}
function
function ThemeSwitcher({ value, onChange }: Props): React.JSX.Element
ThemeSwitcher
({
value: ThemeValue
value
,
onChange: (value: ThemeValue) => void
onChange
}:
type Props = {
value: ThemeValue;
onChange: (value: ThemeValue) => void;
}
Props
) {
return (
<
function RadioGroup<ThemeValue>(props: {
value: ThemeValue;
onChange: (value: ThemeValue) => void;
children?: ReactNode;
}): ReactElement
RadioGroup
value: ThemeValue
value
={
value: ThemeValue
value
}
onChange: (value: ThemeValue) => void
onChange
={
onChange: (value: ThemeValue) => void
onChange
}>
<
function Flex(props: {
direction: ReadonlyArray<"row" | "column">;
gap: string;
children?: ReactNode;
}): ReactElement
Flex
direction: readonly ("row" | "column")[]
direction
={['row', 'column']}
gap: string
gap
="sm">
<
function RadioGroupItem<ThemeValue>(props: {
value: ThemeValue;
children?: ReactNode;
}): ReactElement
RadioGroupItem
<
type ThemeValue = "system" | "light" | "dark"
ThemeValue
>
value: ThemeValue
value
="system">🤖</
function RadioGroupItem<T extends string>(props: {
value: T;
children?: ReactNode;
}): ReactElement
RadioGroupItem
>
<
function RadioGroupItem<ThemeValue>(props: {
value: ThemeValue;
children?: ReactNode;
}): ReactElement
RadioGroupItem
<
type ThemeValue = "system" | "light" | "dark"
ThemeValue
>
value: ThemeValue
value
="light">☀️</
function RadioGroupItem<T extends string>(props: {
value: T;
children?: ReactNode;
}): ReactElement
RadioGroupItem
>
<
function RadioGroupItem<ThemeValue>(props: {
value: ThemeValue;
children?: ReactNode;
}): ReactElement
RadioGroupItem
<
type ThemeValue = "system" | "light" | "dark"
ThemeValue
>
value: ThemeValue
value
="dark">🌑</
function RadioGroupItem<T extends string>(props: {
value: T;
children?: ReactNode;
}): ReactElement
RadioGroupItem
>
</
function Flex(props: {
direction: ReadonlyArray<"row" | "column">;
gap: string;
children?: ReactNode;
}): ReactElement
Flex
>
</
function RadioGroup<T extends string>(props: {
value: T;
onChange: (value: T) => void;
children?: ReactNode;
}): ReactElement
RadioGroup
>
)
}

That’s not a great API, because every manual type annotation is easily forgotten. If you know me, you know that I like my types fully inferred whenever possible. The best way to do this for compound components is by not exposing those components directly, but by only giving your users a method to invoke.

Component Factory Pattern

Not sure if this is the correct name for this pattern, but I think it’s good enough to get the concept across. Basically, we can’t fully get rid of the manual type annotation, but we can try to hide it and make it explicit. Instead of exposing RadioGroup and RadioGroupItem, we only export a function called createRadioGroup that should be called once with a type parameter. This function will then return the statically typed RadioGroup and its RadioGroupItem with types that are tied together:

createRadioGroup
export const
const createRadioGroup: <T extends GroupValue = never>() => {
RadioGroup: (props: RadioGroupProps<T>) => ReactElement;
RadioGroupItem: (props: Item<T>) => ReactElement;
}
createRadioGroup
= <
function (type parameter) T in <T extends GroupValue = never>(): {
RadioGroup: (props: RadioGroupProps<T>) => ReactElement;
RadioGroupItem: (props: Item<T>) => ReactElement;
}
T
extends
type GroupValue = string | number
GroupValue
= never>(): {
type RadioGroup: (props: RadioGroupProps<T>) => ReactElement
RadioGroup
: (
props: RadioGroupProps<T>
props
:
type RadioGroupProps<T extends GroupValue> = {
value: T;
onChange: (value: T) => void;
}
RadioGroupProps
<
function (type parameter) T in <T extends GroupValue = never>(): {
RadioGroup: (props: RadioGroupProps<T>) => ReactElement;
RadioGroupItem: (props: Item<T>) => ReactElement;
}
T
>) =>
interface ReactElement<P = unknown, T extends string | import("/opt/build/repo/node_modules/.pnpm/@[email protected]/node_modules/@types/react/index.d.ts").JSXElementConstructor<any> = string | import("/opt/build/repo/node_modules/.pnpm/@[email protected]/node_modules/@types/react/index.d.ts").JSXElementConstructor<...>>
ReactElement
type RadioGroupItem: (props: Item<T>) => ReactElement
RadioGroupItem
: (
props: Item<T>
props
:
type Item<T extends GroupValue> = {
value: T;
}
Item
<
function (type parameter) T in <T extends GroupValue = never>(): {
RadioGroup: (props: RadioGroupProps<T>) => ReactElement;
RadioGroupItem: (props: Item<T>) => ReactElement;
}
T
>) =>
interface ReactElement<P = unknown, T extends string | import("/opt/build/repo/node_modules/.pnpm/@[email protected]/node_modules/@types/react/index.d.ts").JSXElementConstructor<any> = string | import("/opt/build/repo/node_modules/.pnpm/@[email protected]/node_modules/@types/react/index.d.ts").JSXElementConstructor<...>>
ReactElement
} => ({
type RadioGroup: (props: RadioGroupProps<T>) => ReactElement
RadioGroup
,
type RadioGroupItem: (props: Item<T>) => ReactElement
RadioGroupItem
})

This doesn’t do anything at runtime, except wrapping our internal RadioGroup and RadioGroupItem into an object. But on type-level, it ties the type parameters together. And the fact that we default our generic to never means users will have to pass it in order to be able to do anything meaningful with the result, allowing us to use it like this:

Typed RadioGroup
type
type ThemeValue = "system" | "light" | "dark"
ThemeValue
= 'system' | 'light' | 'dark'
type
type Props = {
value: ThemeValue;
onChange: (value: ThemeValue) => void;
}
Props
= {
value: ThemeValue
value
:
type ThemeValue = "system" | "light" | "dark"
ThemeValue
onChange: (value: ThemeValue) => void
onChange
: (
value: ThemeValue
value
:
type ThemeValue = "system" | "light" | "dark"
ThemeValue
) => void
}
const
const Theme: {
RadioGroup: (props: RadioGroupProps<ThemeValue>) => ReactElement;
RadioGroupItem: (props: Item<ThemeValue>) => ReactElement;
}
Theme
=
const createRadioGroup: <ThemeValue>() => {
RadioGroup: (props: RadioGroupProps<ThemeValue>) => ReactElement;
RadioGroupItem: (props: Item<ThemeValue>) => ReactElement;
}
createRadioGroup
<
type ThemeValue = "system" | "light" | "dark"
ThemeValue
>()
function
function ThemeSwitcher({ value, onChange }: Props): React.JSX.Element
ThemeSwitcher
({
value: ThemeValue
value
,
onChange: (value: ThemeValue) => void
onChange
}:
type Props = {
value: ThemeValue;
onChange: (value: ThemeValue) => void;
}
Props
) {
return (
<
const Theme: {
RadioGroup: (props: RadioGroupProps<ThemeValue>) => ReactElement;
RadioGroupItem: (props: Item<ThemeValue>) => ReactElement;
}
Theme
.
type RadioGroup: (props: RadioGroupProps<ThemeValue>) => ReactElement
RadioGroup
value: ThemeValue
value
={
value: ThemeValue
value
}
onChange: (value: ThemeValue) => void
onChange
={
onChange: (value: ThemeValue) => void
onChange
}>
<
function Flex(props: {
direction: ReadonlyArray<"row" | "column">;
gap: string;
children?: ReactNode;
}): ReactElement
Flex
direction: readonly ("row" | "column")[]
direction
={['row', 'column']}
gap: string
gap
="sm">
<
const Theme: {
RadioGroup: (props: RadioGroupProps<ThemeValue>) => ReactElement;
RadioGroupItem: (props: Item<ThemeValue>) => ReactElement;
}
Theme
.
type RadioGroupItem: (props: Item<ThemeValue>) => ReactElement
RadioGroupItem
value: ThemeValue
value
="system">🤖</
const Theme: {
RadioGroup: (props: RadioGroupProps<ThemeValue>) => ReactElement;
RadioGroupItem: (props: Item<ThemeValue>) => ReactElement;
}
Theme
.
type RadioGroupItem: (props: Item<ThemeValue>) => ReactElement
RadioGroupItem
>
<
const Theme: {
RadioGroup: (props: RadioGroupProps<ThemeValue>) => ReactElement;
RadioGroupItem: (props: Item<ThemeValue>) => ReactElement;
}
Theme
.
type RadioGroupItem: (props: Item<ThemeValue>) => ReactElement
RadioGroupItem
value: ThemeValue
value
="light">☀️</
const Theme: {
RadioGroup: (props: RadioGroupProps<ThemeValue>) => ReactElement;
RadioGroupItem: (props: Item<ThemeValue>) => ReactElement;
}
Theme
.
type RadioGroupItem: (props: Item<ThemeValue>) => ReactElement
RadioGroupItem
>
<
const Theme: {
RadioGroup: (props: RadioGroupProps<ThemeValue>) => ReactElement;
RadioGroupItem: (props: Item<ThemeValue>) => ReactElement;
}
Theme
.
type RadioGroupItem: (props: Item<ThemeValue>) => ReactElement
RadioGroupItem
value: ThemeValue
value
="dark">🌑</
const Theme: {
RadioGroup: (props: RadioGroupProps<ThemeValue>) => ReactElement;
RadioGroupItem: (props: Item<ThemeValue>) => ReactElement;
}
Theme
.
type RadioGroupItem: (props: Item<ThemeValue>) => ReactElement
RadioGroupItem
>
<
const Theme: {
RadioGroup: (props: RadioGroupProps<ThemeValue>) => ReactElement;
RadioGroupItem: (props: Item<ThemeValue>) => ReactElement;
}
Theme
.
type RadioGroupItem: (props: Item<ThemeValue>) => ReactElement
RadioGroupItem
value="wrong">🚨</
const Theme: {
RadioGroup: (props: RadioGroupProps<ThemeValue>) => ReactElement;
RadioGroupItem: (props: Item<ThemeValue>) => ReactElement;
}
Theme
.
type RadioGroupItem: (props: Item<ThemeValue>) => ReactElement
RadioGroupItem
>
Error ts(2322) ― Type '"wrong"' is not assignable to type 'ThemeValue'.
</
function Flex(props: {
direction: ReadonlyArray<"row" | "column">;
gap: string;
children?: ReactNode;
}): ReactElement
Flex
>
</
const Theme: {
RadioGroup: (props: RadioGroupProps<ThemeValue>) => ReactElement;
RadioGroupItem: (props: Item<ThemeValue>) => ReactElement;
}
Theme
.
type RadioGroup: (props: RadioGroupProps<ThemeValue>) => ReactElement
RadioGroup
>
)
}

Of course, this version isn’t bullet proof. We can still create a differently typed RadioGroup and pass those items to our Theme.RadioGroup, but this is far less likely to happen by accident.

All in all, this approach preserves the flexibility that makes compound components great while adding strong type guarantees. The only real cost is that consumers don’t import the components directly, but instead create a typed instance of the component family via a function call. I think that’s a worthwhile tradeoff and the best way to make a compound component as type-safe as possible for the users of your design-system.


That’s it for today. Feel free to reach out to me on bluesky (opens in a new window) if you have any questions, or just leave a comment below. ⬇️

Like the monospace font in the code blocks?

Check out monolisa.dev

Bytes - the JavaScript Newsletter that doesn't suck