Unreleased changes
These pages document main, which has changes 2.1.1 does not. npm install @evanion/compose gives you 2.1.1, documented here. Values on these pages are produced by running the current source on every build.
API reference
@evanion/compose exports two functions and eight types. Each one has a
heading below.
ComposeProvider
function ComposeProvider<const T extends ProviderArray>(
props: ComposeProviderProps<T>,
): React.ReactElement;| Prop | Type | Notes |
|---|---|---|
providers | ValidatedProviders<T> | Providers to compose; the first is outermost |
children | React.ReactNode | Wrapped by every provider |
ComposeProvider is a generic function. Its const T parameter makes
TypeScript infer the array as a
fixed-length array type,
one type per position.
A React.FC annotation would widen the array and drop the per-entry checks.
ComposeProvider returns a React fragment around the nested tree, so it adds
no DOM element of its own. An entry that is a bare component is rendered with
no props; a tuple entry is rendered with its second element spread as props.
Below, CartProvider renders <div id="cart">, and ThemeProvider and
CurrencyProvider each render a div whose id is their theme or
currency prop. renderToStaticMarkup, from
react-dom/server, turns the tree into the HTML string markup:
const = (
<
={[
// A bare component, for a provider whose props are all optional.
,
// A `provider()` call, checked where it is written.
(, { : 'dark' }),
// A tuple, checked where the array reaches `ComposeProvider`.
[, { : 'SEK' }],
]}
>
<>Brass: Birmingham</>
</>,
);
; // -> '<div id="cart"><div id="dark"><div id="SEK"><p>Brass: Birmingham</p></div></div></div>'ComposeProvider throws TypeError when providers is not an array, and when
a components prop, the
1.x name for providers, is supplied
without providers. In development it also warns once on an empty array.
A new props value on an entry re-renders that provider in place, and the state below it stays. A change to the array’s order or length remounts everything below the change.
provider(component, props)
function provider<T extends AnyComponent, P extends PropsWithoutChildren<T>>(
component: T,
props: P & Record<Exclude<keyof P, keyof PropsWithoutChildren<T>>, never>,
): readonly [T, PropsWithoutChildren<T>];provider returns [component, props] as passed, and the check runs only in
the compiler. AnyComponent and
PropsWithoutChildren<T> are defined below. T is
inferred from component in the same expression, so props is checked and
completed against that component. Below, ThemeProvider takes
theme: 'light' | 'dark', and the call reports 'blue' where it is written:
const = (, { theme: 'blue' });
; // -> [ThemeProvider, { theme: 'blue' }]The Record<Exclude<…>, never> intersection is what rejects an excess key.
Plain assignability would not: TypeScript’s excess-property check fires on a
fresh object literal and is skipped for a props object that arrives through a
variable.
AnyComponent
type AnyComponent = ComponentType<any>;AnyComponent is the constraint a generic parameter uses to accept any
component. ComponentType<unknown> would reject a
component with concrete props, because component props are contravariant: a
ComponentType<{ theme: string }> is not assignable to a
ComponentType<unknown>. TypeScript has no “some component, props unknown”
type for this position. Inference is unaffected: every check in
@evanion/compose resolves against the component actually passed.
PropsWithoutChildren<T>
type PropsWithoutChildren<T extends AnyComponent> = Omit<
ComponentProps<T>,
'children'
>;PropsWithoutChildren<T> is the props an entry has to supply. children is
excluded because ComposeProvider provides it: the next provider down, or the caller’s own children at the
innermost level.
Provider
type Provider = AnyComponent | readonly [AnyComponent, unknown];Provider is the type of one entry of the array: a component, or a component
paired with its props.
ProviderArray
type ProviderArray = readonly Provider[];readonly, so an array written as const is assignable. A value annotated
with this type has lost its length and the type at each position, so
ComposeProvider checks none of its entries.
Preserving Checks in Wrapper Components
shows the wrapper that keeps the check.
ValidateProvider<T>
type ValidateProvider<T> = T extends readonly [infer C, infer P]
? C extends AnyComponent
? P extends AnyComponent
? ComposeError<'second tuple element must be props, not another component'>
: Exclude<keyof P, keyof PropsWithoutChildren<C>> extends never
? [P] extends [PropsWithoutChildren<C>]
? T
: readonly [C, PropsWithoutChildren<C>]
: ComposeError<`unknown prop '${Extract<
Exclude<keyof P, keyof PropsWithoutChildren<C>>,
string
>}'`>
: ComposeError<'first tuple element must be a component'>
: T extends AnyComponent
? Record<string, never> extends PropsWithoutChildren<T>
? T
: ComposeError<'this component requires props — use provider(Component, props) or a [Component, props] tuple'>
: ComposeError<'not a valid provider'>;Checks one entry. A valid entry maps to itself. A failing one maps to
ComposeError<"message">, a type naming what is wrong, except for a missing or
mistyped prop value, which maps to the shape the entry should have had.
ComposeError is internal: @evanion/compose does not export it.
Debugging Compose Errors lists
the messages.
ValidateProviders<T>
type ValidateProviders<T extends ProviderArray> = {
readonly [K in keyof T]: ValidateProvider<T[K]>;
};ValidateProvider applied to each position of a fixed-length array type, so
the error points at the offending entry.
ValidatedProviders<T>
type ValidatedProviders<T extends ProviderArray> = number extends T['length']
? ProviderArray
: T & ValidateProviders<T>;The type of the providers prop. number extends T['length'] is true only
when the array’s length is number, so it separates a widened array from a
fixed-length array type. Gating on it does two things:
- A widened
ProviderArraypasses through unchecked. Checking it would always fail, because a widened entry never matches the shape it should have had. - An array whose length is
numberis never compared member by member. That comparison makes TypeScript compare array methods such asevery,mapandreduce, and report a mismatch in a callback’s parameter type before it mentions the real one.
T stays in the intersection so TypeScript can infer T from the array you
pass. Given ValidateProviders<T> alone, TypeScript infers no T from the
array, falls back to ProviderArray, and checks nothing.
ComposeProviderProps<T>
interface ComposeProviderProps<T extends ProviderArray = ProviderArray> {
providers: ValidatedProviders<T>;
children: React.ReactNode;
}The props of ComposeProvider, exported for a wrapper that names them. The
wrapper keeps the per-entry checks only when it declares its own
<const T extends ProviderArray> and passes T here. Under the default T,
providers is a plain ProviderArray.
Preserving Checks in Wrapper Components
shows one.
Migrating @evanion/compose from 1.x
| 1.0.8 | 2.0 |
|---|---|
<ComposeProvider components={[...]}> | <ComposeProvider providers={[...]}> |
| dual CommonJS and ESM builds | ESM only; require('@evanion/compose') needs Node 20.19 or later on Node 20, or 22.12 or later |
the Component type | Provider or ProviderArray |
Provider, an alias of Component | AnyComponent | readonly [AnyComponent, unknown] |
ComposeProvider typed as a React.FC | a generic function that no longer assigns to React.FC |
a missing providers fails reading undefined | throws a TypeError naming providers |
@evanion/compose 2.0 removes the components prop from the type surface. A
JavaScript caller that still passes it gets a named error.
React.createElement passes the props an untyped caller would:
const = ({ }: React.) => (
< ="cart">{}</>
);
// The props a JavaScript caller passes, which no compiler checked.
const = { : [] } as never;
let = '';
try {
(React.(, ));
} catch () {
= ( as Error).;
}
; // -> 'ComposeProvider: `components` was removed in v2.0 — rename it to `providers`.'