The limits of type safety in React Context


In a virtual presentation I attended about the compound component pattern, I made a comment about some of this pattern’s tradeoffs. Because the pattern relies on React context (a form of dependency injection in the React tree) it inherits its limitations:

  • Orchestrating state from a top component is not always simple. In some cases, it is simpler to pass props down, which is very explicit. TypeScript can help group props into single objects and maintain the safety (no missing props when needed).
  • TypeScript doesn’t understand the concept of the React tree. TypeScript can’t statistically verify that you are using your nested components under the right parent. It can’t verify that there is a certain ancestor, nor whether there are other components between one and the other, which many compound components depend on.

This last point proved to be difficult to convey as we ran out of time, so I promised to share an example to illustrate the problem. Hence this document.

The simplest version of the problem

Let’s build a context for a Person, and use it to render a PersonCard.

const
const personContext: React.Context<Person>
personContext
=
createContext<Person>(defaultValue: Person): React.Context<Person>

Lets you create a

Context

that components can provide or read.

@paramdefaultValue The value you want the context to have when there is no matching Provider in the tree above the component reading the context. This is meant as a "last resort" fallback.

@seehttps://react.dev/reference/react/createContext#reference React Docs

@seehttps://react-typescript-cheatsheet.netlify.app/docs/basic/getting-started/context/ React TypeScript Cheatsheet

@example

import { createContext } from 'react';
const ThemeContext = createContext('light');
function App() {
return (
<ThemeContext value="dark">
<Toolbar />
</ThemeContext>
);
}

createContext
<
class Person
Person
>(new
constructor Person(): Person
Person
())
const
const PersonCard: () => React.JSX.Element
PersonCard
= () => {
const person =
useContext<Person>(context: React.Context<Person>): Person

Accepts a context object (the value returned from React.createContext) and returns the current context value, as given by the nearest context provider for the given context.

useContext
(
const personContext: React.Context<Person>
personContext
)
const person: Person
return <
JSX.IntrinsicElements.p: React.DetailedHTMLProps<React.HTMLAttributes<HTMLParagraphElement>, HTMLParagraphElement>
p
>{
const person: Person
person
.
Person.getFullname(): string
getFullname
()}</
JSX.IntrinsicElements.p: React.DetailedHTMLProps<React.HTMLAttributes<HTMLParagraphElement>, HTMLParagraphElement>
p
>
}

This is type-safe in that person will always be an instance of the Person class. However, note that this is only because we have provided a default value for the personContext. TypeScript doesn’t know if the context has been provided or not:

// Because, did we do this?
function createRoot(container: Container, options?: RootOptions): Root

createRoot lets you create a root to display React components inside a browser DOM node.

@seehttps://react.dev/reference/react-dom/client/createRoot API Reference for createRoot

createRoot
(
var document: Document

window.document returns a reference to the document contained in the window.

MDN Reference

document
.
Document.getElementById(elementId: string): HTMLElement | null

Returns the first element within node's descendants whose ID is elementId.

MDN Reference

getElementById
('root')!).
Root.render(children: React.ReactNode): void
render
(<
const PersonCard: React.FC
PersonCard
/>)
// ...or this?
function createRoot(container: Container, options?: RootOptions): Root

createRoot lets you create a root to display React components inside a browser DOM node.

@seehttps://react.dev/reference/react-dom/client/createRoot API Reference for createRoot

createRoot
(
var document: Document

window.document returns a reference to the document contained in the window.

MDN Reference

document
.
Document.getElementById(elementId: string): HTMLElement | null

Returns the first element within node's descendants whose ID is elementId.

MDN Reference

getElementById
('root')!).
Root.render(children: React.ReactNode): void
render
((
<
const personContext: React.Context<Person>
personContext
.
Context<Person>.Provider: React.Provider<Person>
Provider
ProviderProps<Person>.value: Person
value
={
const somePerson: Person
somePerson
}>
<
const PersonCard: React.FC
PersonCard
/>
</
const personContext: React.Context<Person>
personContext
.
Context<Person>.Provider: React.Provider<Person>
Provider
>
))
// TypeScript doesn't know.

The workaround for this type limitation is the default value. But what if a default doesn’t make sense? There is no such thing as a default person. Well, that’s what null is for.

const
const personContext: React.Context<Person | null>
personContext
=
createContext<Person | null>(defaultValue: Person | null): React.Context<Person | null>

Lets you create a

Context

that components can provide or read.

@paramdefaultValue The value you want the context to have when there is no matching Provider in the tree above the component reading the context. This is meant as a "last resort" fallback.

@seehttps://react.dev/reference/react/createContext#reference React Docs

@seehttps://react-typescript-cheatsheet.netlify.app/docs/basic/getting-started/context/ React TypeScript Cheatsheet

@example

import { createContext } from 'react';
const ThemeContext = createContext('light');
function App() {
return (
<ThemeContext value="dark">
<Toolbar />
</ThemeContext>
);
}

createContext
<
class Person
Person
| null>(null)
const
const PersonCard: () => React.JSX.Element
PersonCard
= () => {
const person =
useContext<Person | null>(context: React.Context<Person | null>): Person | null

Accepts a context object (the value returned from React.createContext) and returns the current context value, as given by the nearest context provider for the given context.

useContext
(
const personContext: React.Context<Person | null>
personContext
)
const person: Person | null
// TypeScript doesn't know if a person is provided. We have to check.
if (!
const person: Person | null
person
) throw new
var Error: ErrorConstructor
new (message?: string, options?: ErrorOptions) => Error (+1 overload)
Error
('No Person provided')
return <
JSX.IntrinsicElements.p: React.DetailedHTMLProps<React.HTMLAttributes<HTMLParagraphElement>, HTMLParagraphElement>
p
>{
const person: Person
person
.
Person.getFullname(): string
getFullname
()}</
JSX.IntrinsicElements.p: React.DetailedHTMLProps<React.HTMLAttributes<HTMLParagraphElement>, HTMLParagraphElement>
p
>
}

Now, regardless of whether the value has been provided or not, we will be safe at the type level… at the cost of runtime safety. It is still very possible to use the PersonCard in a location in the React tree that doesn’t have a provided personContext:

createRoot(document.getElementById('root')!).render(<PersonCard />)

In which case, the “No Person provided” error will be thrown. TypeScript can’t statically protect against this. It isn’t just that TypeScript doesn’t know whether a value was provided. TypeScript doesn’t know it doesn’t know, because it has no understanding of the React tree.

The situation is akin to not being able to trust the signature of a function inside its body:

const
const calculateAge: (person: Person) => void
calculateAge
= (
person: Person
person
:
class Person
Person
) => {
// what if `person` is not a Person?
// What if it's undefined, null, or something else entirely?
}

If we had this issue with normal functions, TypeScript would not be useful. This is why TypeScript also tends to become more strict, discourage anys, etc.: they defeat the purpose of using TypeScript in a codebase.

Yet, this is what we encounter with useContext. This is really the gist of the issue. Everything else in this document derives from this problem, and shows how it affects compound components, and how you can try hard to cover up for this lack of type safety, making things reasonably safe for many use-cases, while still acknowledging that, ultimately, using React context requires giving up on type safety for convenience.

Still, this doesn’t mean that React context shouldn’t be used. It can be useful and a reasonable tool. But it’s important to understand its tradeoffs and the options to mitigate its cons.

A simple compound component

Consider now a scenario with a Select component built using the compound component pattern. You would use it like this:

<Select onChange={handleChange} value={currentServiceType}>
{Object.values(ServiceType).map(serviceType => (
<Option key={serviceType} value={serviceType}>{serviceType}</Option>
))}
</Select>

If you were to build this component, how would you ensure that the options are valid for the type that you expect in the Select component? Or, looking at it from another angle, how would you make the type of the value and onChange props of the component match the options? How would you make the following impossible (note the last option)?

enum
enum ServiceType
ServiceType
{
function (enum member) ServiceType.Water = "Water"
Water
= 'Water',
function (enum member) ServiceType.Electricity = "Electricity"
Electricity
= 'Electricity',
function (enum member) ServiceType.Gas = "Gas"
Gas
= 'Gas'
}
const [
const serviceType: ServiceType
serviceType
,
const setServiceType: React.Dispatch<React.SetStateAction<ServiceType>>
setServiceType
] =
useState<ServiceType>(initialState: ServiceType | (() => ServiceType)): [ServiceType, React.Dispatch<React.SetStateAction<ServiceType>>] (+1 overload)

Returns a stateful value, and a function to update it.

@version16.8.0

@seehttps://react.dev/reference/react/useState

useState
(
enum ServiceType
ServiceType
.
function (enum member) ServiceType.Water = "Water"
Water
);
<
function Select(props: {
onChange: (serviceType: ServiceType) => void;
value: ServiceType;
children: ReactNode;
}): ReactElement
Select
onChange: (serviceType: ServiceType) => void
onChange
={
const setServiceType: React.Dispatch<React.SetStateAction<ServiceType>>
setServiceType
}
value: ServiceType
value
={
const serviceType: ServiceType
serviceType
}>
{
var Object: ObjectConstructor

Provides functionality common to all JavaScript objects.

Object
.
ObjectConstructor.values<ServiceType>(o: {
[s: string]: ServiceType;
} | ArrayLike<ServiceType>): ServiceType[] (+1 overload)

Returns an array of values of the enumerable own properties of an object

@paramo Object that contains the properties and methods. This can be an object that you created or an existing Document Object Model (DOM) object.

values
(
enum ServiceType
ServiceType
).
Array<ServiceType>.map<React.JSX.Element>(callbackfn: (value: ServiceType, index: number, array: ServiceType[]) => React.JSX.Element, thisArg?: any): React.JSX.Element[]

Calls a defined callback function on each element of an array, and returns an array that contains the results.

@paramcallbackfn A function that accepts up to three arguments. The map method calls the callbackfn function one time for each element in the array.

@paramthisArg An object to which the this keyword can refer in the callbackfn function. If thisArg is omitted, undefined is used as the this value.

map
(
serviceType: ServiceType
serviceType
=> (
<
function Option(props: {
value: string;
children: ReactElement | string;
}): ReactElement
Option
Attributes.key?: React.Key | null | undefined
key
={
serviceType: ServiceType
serviceType
}
value: string
value
={
serviceType: ServiceType
serviceType
}>{
serviceType: ServiceType
serviceType
}</
function Option(props: {
value: string;
children: ReactElement | string;
}): ReactElement
Option
>
))}
// Note this is not a valid ServiceType
<
function Option(props: {
value: string;
children: ReactElement | string;
}): ReactElement
Option
value: string
value
="Custom">Custom</
function Option(props: {
value: string;
children: ReactElement | string;
}): ReactElement
Option
>
</
function Select(props: {
onChange: (serviceType: ServiceType) => void;
value: ServiceType;
children: ReactNode;
}): ReactElement
Select
>

Perhaps this is a good exercise to the reader. If not sure, we can move on and consider a more complex scenario.

A form library

Let’s build a form library for React. This may be too complex an example, but this is a case I’ve had to deal with, and coming up with creative, comprehensive, but simple examples is harder than it seems. And, while it’s true that form libraries don’t necessarily rely on compound components—and even when they do, it may not feel like that—, they run into the same fundamental issue that I’m trying to illustrate. This time, it manifests as the inability to safely use a generic context.

Note

Feel free to explore a more complete version of this exercise. It is hopefully simple enough to allow readers to find the code relevant to each code block below, and tinker with the pieces that work around type safety. It may also be educational as an example for certain somewhat advanced React patterns, or Schema.

In an ideal world, our form library would allow developers to declare a form definition at the top level, describing how to validate its values, and other configuration values. For validation, it would be great to be able to pass a schema that, additionally, describes the form’s shape at the type level, so that we can provide safety in the form fields. Zod is a popular choice for this. I will use Schema in this example because it is a somewhat unknown gem I enjoy using.

Our form library will also need some components that communicate with the form state, to read and to set the field values. Then you can have components that receive a name indicating the path in the schema that they control. Because TypeScript will know about the type of the schema, we would hope to be able to figure out what names are valid, and the value type for each name—thus preventing developers from rendering invalid inputs. Wouldn’t that be great?

const
const PersonSchema: Schema.Struct<{
name: typeof Schema.String;
dob: typeof Schema.Date;
}>
PersonSchema
=
import Schema
Schema
.
function Struct<{
name: typeof Schema.String;
dob: typeof Schema.Date;
}>(fields: {
name: typeof Schema.String;
dob: typeof Schema.Date;
}): Schema.Struct<{
name: typeof Schema.String;
dob: typeof Schema.Date;
}> (+1 overload)

@since3.10.0

Struct
({
name: typeof Schema.String
name
:
import Schema
Schema
.
class String
export String

@since3.10.0

String
,
dob: typeof Schema.Date
dob
:
import Schema
Schema
.
class Date
export Date

This schema converts a string into a Date object using the new Date constructor. It ensures that only valid date strings are accepted, rejecting any strings that would result in an invalid date, such as new Date("Invalid Date").

@since3.10.0

Date
,
})
const
const PersonForm: () => React.JSX.Element
PersonForm
= () => (
<
function Form(props: {
schema: Schema.Schema.Any;
children: ReactNode;
}): ReactElement
Form
schema: Schema.Schema.Any
schema
={
const PersonSchema: Schema.Struct<{
name: typeof Schema.String;
dob: typeof Schema.Date;
}>
PersonSchema
}>
<
function TextInput(props: {
name: string;
label: string;
}): ReactElement
TextInput
name: string
name
="name"
label: string
label
="Name" />
<
function DateInput(props: {
name: string;
label: string;
}): ReactElement
DateInput
name: string
name
="dob"
label: string
label
="Date of birth" />
</
function Form(props: {
schema: Schema.Schema.Any;
children: ReactNode;
}): ReactElement
Form
>
)

What would the implementation of our Form component look like? Something like the following, to start simple.

export class
class FormState<T>
FormState
<
function (type parameter) T in FormState<T>
T
> {
public readonly
FormState<T>.schema: Schema.Schema<T, T, never>
schema
:
import Schema
Schema
.
interface Schema<in out A, in out I = A, out R = never>

@since3.10.0

@since3.10.0

Schema
<
function (type parameter) T in FormState<T>
T
>;
/* the current state of the form object */
private
FormState<T>.value: T
value
:
function (type parameter) T in FormState<T>
T
;
constructor(
schema: Schema.Schema<T, T, never>
schema
:
import Schema
Schema
.
interface Schema<in out A, in out I = A, out R = never>

@since3.10.0

@since3.10.0

Schema
<
function (type parameter) T in FormState<T>
T
>,
initialValue: T
initialValue
:
function (type parameter) T in FormState<T>
T
) {
this.
FormState<T>.schema: Schema.Schema<T, T, never>
schema
=
schema: Schema.Schema<T, T, never>
schema
;
this.
FormState<T>.value: T
value
=
initialValue: T
initialValue
;
}
}
const
const formContext: React.Context<FormState<any> | null>
formContext
=
createContext<FormState<any> | null>(defaultValue: FormState<any> | null): React.Context<FormState<any> | null>

Lets you create a

Context

that components can provide or read.

@paramdefaultValue The value you want the context to have when there is no matching Provider in the tree above the component reading the context. This is meant as a "last resort" fallback.

@seehttps://react.dev/reference/react/createContext#reference React Docs

@seehttps://react-typescript-cheatsheet.netlify.app/docs/basic/getting-started/context/ React TypeScript Cheatsheet

@example

import { createContext } from 'react';
const ThemeContext = createContext('light');
function App() {
return (
<ThemeContext value="dark">
<Toolbar />
</ThemeContext>
);
}

createContext
<
// formContext has to support any type for FormState.
|
class FormState<T>
FormState
<any>
| null
>(null);
const
const useForm: <T extends unknown>() => FormState<T>
useForm
= <
function (type parameter) T in <T extends unknown>(): FormState<T>
T
extends unknown>():
class FormState<T>
FormState
<
function (type parameter) T in <T extends unknown>(): FormState<T>
T
> => {
// Pinky-promise that the FormState will hold T
const
const formState: FormState<T> | null
formState
=
useContext<FormState<T> | null>(context: React.Context<FormState<T> | null>): FormState<T> | null

Accepts a context object (the value returned from React.createContext) and returns the current context value, as given by the nearest context provider for the given context.

useContext
<
class FormState<T>
FormState
<
function (type parameter) T in <T extends unknown>(): FormState<T>
T
> | null>(
const formContext: React.Context<FormState<any> | null>
formContext
);
if (!
const formState: FormState<T> | null
formState
) {
throw new
var Error: ErrorConstructor
new (message?: string, options?: ErrorOptions) => Error (+1 overload)
Error
('Form context used outside of a <Form>');
}
return
const formState: FormState<T>
formState
;
};
const
const Form: <T extends unknown>({ schema, initialValue, children, }: {
schema: Schema.Schema<T>;
initialValue: T;
children: ReactNode;
}) => React.JSX.Element
Form
= <
function (type parameter) T in <T extends unknown>({ schema, initialValue, children, }: {
schema: Schema.Schema<T>;
initialValue: T;
children: ReactNode;
}): React.JSX.Element
T
extends unknown>({
schema: Schema.Schema<T, T, never>
schema
,
initialValue: T extends unknown
initialValue
,
children: React.ReactNode
children
,
}: {
schema: Schema.Schema<T, T, never>
schema
:
import Schema
Schema
.
interface Schema<in out A, in out I = A, out R = never>

@since3.10.0

@since3.10.0

Schema
<
function (type parameter) T in <T extends unknown>({ schema, initialValue, children, }: {
schema: Schema.Schema<T>;
initialValue: T;
children: ReactNode;
}): React.JSX.Element
T
>;
initialValue: T extends unknown
initialValue
:
function (type parameter) T in <T extends unknown>({ schema, initialValue, children, }: {
schema: Schema.Schema<T>;
initialValue: T;
children: ReactNode;
}): React.JSX.Element
T
;
children: React.ReactNode
children
:
type ReactNode = string | number | bigint | boolean | React.ReactElement<unknown, string | React.JSXElementConstructor<any>> | Iterable<React.ReactNode> | React.ReactPortal | Promise<AwaitedReactNode> | null | undefined

Represents all of the things React can render.

Where

ReactElement

only represents JSX, ReactNode represents everything that can be rendered.

@seehttps://react-typescript-cheatsheet.netlify.app/docs/react-types/reactnode/ React TypeScript Cheatsheet

@example

// Typing children
type Props = { children: ReactNode }
const Component = ({ children }: Props) => <div>{children}</div>
<Component>hello</Component>

@example

// Typing a custom element
type Props = { customElement: ReactNode }
const Component = ({ customElement }: Props) => <div>{customElement}</div>
<Component customElement={<div>hello</div>} />

ReactNode
;
}) => {
const [
const formState: FormState<T>
formState
] =
useState<FormState<T>>(initialState: FormState<T> | (() => FormState<T>)): [FormState<T>, React.Dispatch<React.SetStateAction<FormState<T>>>] (+1 overload)

Returns a stateful value, and a function to update it.

@version16.8.0

@seehttps://react.dev/reference/react/useState

useState
(() => new
constructor FormState<T>(schema: Schema.Schema<T, T, never>, initialValue: T): FormState<T>
FormState
(
schema: Schema.Schema<T, T, never>
schema
,
initialValue: T extends unknown
initialValue
));
return (
<
const formContext: React.Context<FormState<any> | null>
formContext
.
Context<FormState<any> | null>.Provider: React.Provider<FormState<any> | null>
Provider
ProviderProps<FormState<any> | null>.value: FormState<any> | null
value
={
const formState: FormState<T>
formState
}>
{
children: React.ReactNode
children
}
</
const formContext: React.Context<FormState<any> | null>
formContext
.
Context<FormState<any> | null>.Provider: React.Provider<FormState<any> | null>
Provider
>
);
};

We need FormState<any> when we create the context because it could literally be a form for any schema. We don’t have that information when creating the context. Pay attention to useForm as well. The best we can do is receive some type arguments for the schema, and pinky-promise TypeScript that the context will be for that (useContext<FormState<T> | null>(formContext)).

OK. Now let’s think about the TextInput. It will need some way to get the value for the field it represents. FormState currently doesn’t expose its values, so let’s do that first:

class FormState<T> {
// ...
// This is added to our previous definition of FormState
public getValue<Name extends keyof T>(name: Name): T[Name] {
return this.value[name];
}
}

To make it easier for components to access their respective values, we can expose it via a hook.

const
const useFormValue: <T, Name extends keyof T>(name: Name) => T[Name]
useFormValue
= <
function (type parameter) T in <T, Name extends keyof T>(name: Name): T[Name]
T
,
function (type parameter) Name in <T, Name extends keyof T>(name: Name): T[Name]
Name
extends keyof
function (type parameter) T in <T, Name extends keyof T>(name: Name): T[Name]
T
>(
name: Name extends keyof T
name
:
function (type parameter) Name in <T, Name extends keyof T>(name: Name): T[Name]
Name
):
function (type parameter) T in <T, Name extends keyof T>(name: Name): T[Name]
T
[
function (type parameter) Name in <T, Name extends keyof T>(name: Name): T[Name]
Name
] => {
// The pinky-promise bubbling
const
const formState: FormState<T>
formState
=
const useForm: <T>() => FormState<T>
useForm
<
function (type parameter) T in <T, Name extends keyof T>(name: Name): T[Name]
T
>();
return
const formState: FormState<T>
formState
.
FormState<T>.getValue<Name>(name: Name): T[Name]
getValue
(
name: Name extends keyof T
name
);
};
> I’m focusing on obtaining the value from the context, but in a real form library, we’d need a way to send the new value up to the state (e.g. as the user inputs).

Again, we will have to pinky-promise that the form context will at least have the T type we have received as a type argument for its working values. We are just bubbling the pinky-promise up and up.

Before we implement the TextInput, I wrote this type that will help us make text inputs safer. It returns keys in an object that have a certain type.

type
type PathValue<T, Target> = keyof { [K in keyof T as T[K] extends Target ? K : never]: true; }
PathValue
<
function (type parameter) T in type PathValue<T, Target>
T
,
function (type parameter) Target in type PathValue<T, Target>
Target
> = keyof {
[
function (type parameter) K
K
in keyof
function (type parameter) T in type PathValue<T, Target>
T
as
function (type parameter) T in type PathValue<T, Target>
T
[
function (type parameter) K
K
] extends
function (type parameter) Target in type PathValue<T, Target>
Target
?
function (type parameter) K
K
: never]: true;
};
// Just an ilustrative example of how to use it.
type Example =
type PathValue<T, Target> = keyof { [K in keyof T as T[K] extends Target ? K : never]: true; }
PathValue
<{
a: string
a
: string;
b: number
b
: number;
c: string
c
: string }, string>;
type Example = "a" | "c"

Now we can get to TextInput. Note how we have to keep promising a type (T) and, in this case, this will lead to us having to perform a runtime check.

const
const TextInput: <T, Name extends keyof T & PathValue<T, string> = keyof T & keyof { [K in keyof T as T[K] extends string ? K : never]: true; }>({ name, label, }: {
name: Name;
label: string;
}) => React.JSX.Element
TextInput
= <
function (type parameter) T in <T, Name extends keyof T & PathValue<T, string> = keyof T & keyof { [K in keyof T as T[K] extends string ? K : never]: true; }>({ name, label, }: {
name: Name;
label: string;
}): React.JSX.Element
T
,
function (type parameter) Name in <T, Name extends keyof T & PathValue<T, string> = keyof T & keyof { [K in keyof T as T[K] extends string ? K : never]: true; }>({ name, label, }: {
name: Name;
label: string;
}): React.JSX.Element
Name
extends keyof
function (type parameter) T in <T, Name extends keyof T & PathValue<T, string> = keyof T & keyof { [K in keyof T as T[K] extends string ? K : never]: true; }>({ name, label, }: {
name: Name;
label: string;
}): React.JSX.Element
T
&
type PathValue<T, Target> = keyof { [K in keyof T as T[K] extends Target ? K : never]: true; }
PathValue
<
function (type parameter) T in <T, Name extends keyof T & PathValue<T, string> = keyof T & keyof { [K in keyof T as T[K] extends string ? K : never]: true; }>({ name, label, }: {
name: Name;
label: string;
}): React.JSX.Element
T
, string> = keyof
function (type parameter) T in <T, Name extends keyof T & PathValue<T, string> = keyof T & keyof { [K in keyof T as T[K] extends string ? K : never]: true; }>({ name, label, }: {
name: Name;
label: string;
}): React.JSX.Element
T
&
type PathValue<T, Target> = keyof { [K in keyof T as T[K] extends Target ? K : never]: true; }
PathValue
<
function (type parameter) T in <T, Name extends keyof T & PathValue<T, string> = keyof T & keyof { [K in keyof T as T[K] extends string ? K : never]: true; }>({ name, label, }: {
name: Name;
label: string;
}): React.JSX.Element
T
, string>
>({
name: Name extends keyof T & PathValue<T, string> = keyof T & keyof { [K in keyof T as T[K] extends string ? K : never]: true; }
name
,
label: string
label
,
}: {
name: Name extends keyof T & PathValue<T, string> = keyof T & keyof { [K in keyof T as T[K] extends string ? K : never]: true; }
name
:
function (type parameter) Name in <T, Name extends keyof T & PathValue<T, string> = keyof T & keyof { [K in keyof T as T[K] extends string ? K : never]: true; }>({ name, label, }: {
name: Name;
label: string;
}): React.JSX.Element
Name
;
label: string
label
: string;
}) => {
// More pinky-promises!
const
const value: T[Name]
value
=
const useFormValue: <T, Name>(name: Name) => T[Name]
useFormValue
<
function (type parameter) T in <T, Name extends keyof T & PathValue<T, string> = keyof T & keyof { [K in keyof T as T[K] extends string ? K : never]: true; }>({ name, label, }: {
name: Name;
label: string;
}): React.JSX.Element
T
,
function (type parameter) Name in <T, Name extends keyof T & PathValue<T, string> = keyof T & keyof { [K in keyof T as T[K] extends string ? K : never]: true; }>({ name, label, }: {
name: Name;
label: string;
}): React.JSX.Element
Name
>(
name: Name extends keyof T & PathValue<T, string> = keyof T & keyof { [K in keyof T as T[K] extends string ? K : never]: true; }
name
);
// Runtime validation. Or we could have `useFormValue`
// validate the type with PathValue, but that wouldn't
// runtime validate, and having the runtime validation is a plus.
if (typeof
const value: T[Name]
value
!== 'string') {
throw new
var Error: ErrorConstructor
new (message?: string, options?: ErrorOptions) => Error (+1 overload)
Error
("You pinky-promised a string, this ain't one!");
}
return (
<
JSX.IntrinsicElements.label: React.DetailedHTMLProps<React.LabelHTMLAttributes<HTMLLabelElement>, HTMLLabelElement>
label
>
{
label: string
label
}
<
JSX.IntrinsicElements.input: React.DetailedHTMLProps<React.InputHTMLAttributes<HTMLInputElement>, HTMLInputElement>
input
InputHTMLAttributes<HTMLInputElement>.type?: React.HTMLInputTypeAttribute | undefined
type
="text"
InputHTMLAttributes<HTMLInputElement>.value?: string | number | readonly string[] | undefined
value
={
const value: T[Name] & string
value
} />
</
JSX.IntrinsicElements.label: React.DetailedHTMLProps<React.LabelHTMLAttributes<HTMLLabelElement>, HTMLLabelElement>
label
>
);
};
Tip

A more common API is to provide a Controller or similarly named component, that then can be made to work with any underlying input—text, date, number, or anything else. My choice here just keeps the example simpler.

Our form library now has the necessary pieces for us to use it and display a form field. As a reminder, if you want a more complete example where it is possible to actually type and submit the form, check out this example.

With all this, we can build a form for a person—or, to keep it simple, just a person’s name.

const
const PersonSchema: Schema.Struct<{
name: typeof Schema.NonEmptyString;
}>
PersonSchema
=
import Schema
Schema
.
function Struct<{
name: typeof Schema.NonEmptyString;
}>(fields: {
name: typeof Schema.NonEmptyString;
}): Schema.Struct<{
name: typeof Schema.NonEmptyString;
}> (+1 overload)

@since3.10.0

Struct
({
name: typeof Schema.NonEmptyString
name
:
import Schema
Schema
.
class NonEmptyString

@since3.10.0

NonEmptyString
,
});
/**
* Represents the encoded version of a person. I.e. a raw, unvalidated value
* such as what you would expect from a form or an API payload.
*/
type
type FormPerson = {
readonly name: string;
}

Represents the encoded version of a person. I.e. a raw, unvalidated value such as what you would expect from a form or an API payload.

FormPerson
= typeof
const PersonSchema: Schema.Struct<{
name: typeof Schema.NonEmptyString;
}>
PersonSchema
.
Schema<{ readonly name: string; }, { readonly name: string; }, never>.Encoded: {
readonly name: string;
}
Encoded
;
const
const PersonForm: () => React.JSX.Element
PersonForm
= () => (
<
const Form: <T extends unknown>({ schema, initialValue, children, }: {
schema: Schema.Schema<T>;
initialValue: T;
children: ReactNode;
}) => React.JSX.Element
Form
schema: Schema.Schema<{
readonly name: string;
}, {
readonly name: string;
}, never>
schema
={
const PersonSchema: Schema.Struct<{
name: typeof Schema.NonEmptyString;
}>
PersonSchema
}
initialValue: {
readonly name: string;
}
initialValue
={{
name: string
name
: '' }}>
<
const TextInput: <T, Name extends keyof T & PathValue<T, string> = keyof T & keyof { [K in keyof T as T[K] extends string ? K : never]: true; }>({ name, label, }: {
name: Name;
label: string;
}) => React.JSX.Element
TextInput
<
type FormPerson = {
readonly name: string;
}

Represents the encoded version of a person. I.e. a raw, unvalidated value such as what you would expect from a form or an API payload.

FormPerson
>
name: "name"
name
="name"
label: string
label
="Name" />
</
const Form: <T extends unknown>({ schema, initialValue, children, }: {
schema: Schema.Schema<T>;
initialValue: T;
children: ReactNode;
}) => React.JSX.Element
Form
>
);
Tip

Here too there may be other valid or better APIs. For example, instead of a Form component that replaces the native form, you may choose to expose a more literal provider, and let users render the form manually.

This is less than ideal, because we have to pinky-promise that the text input is for a form representing a FormPerson. But fine. You can live with this.

However, remember one of the key benefits of React is composition. How would you reuse a component with one of these TextInputs across many forms? I am actually facing this right now, with create and edit versions of a form that are so slightly different, and I can only reuse part of the form by heavily relying on the pinky-promise method.

How about working with forms with nested objects? It’s not possible without pinky-promising everywhere. Once you have a couple layers of indirection (a component inside a component inside a component), it’s very hard to be sure that you are doing things right. There is no static validation of what you are doing. You’ll have to test at runtime. You lost type safety.

Alternatives

Many form libraries work with these limitations. But they also offer different workarounds to bring some type safety back.

Higher Order Context

Create context on demand, with the desired generic type. Instead of a top-level context declaration, you have a function that returns a React context for a specific type. For example:

const createFormContext<T> = (schema: Schema.Schema<T>) =>
React.createContext<FormState<T> | null>(null);

This is often exposed in a more friendly way to library users, so that they can obtain providers and hooks specific to this context:

const PersonForm = MyFormLibrary.createForm(schema)
<PersonForm.Form defaultValue={{ name: '' }}>
<PersonForm.TextInput name="name" label="Name" />
</PersonForm.Form>

And you’ve gained type safety back! Well, to an extent. Remember that TypeScript still doesn’t know if the right provider is present in the ancestors.

Also, this is still not composable, because you can’t reuse components for a subset of the form across multiple forms that use different contexts. This may be partially solvable by further complicating the context objects, making them nestable. Something like:

const AccountForm = PersonForm.createSubform('account')
const AccountFormFields = () => (
<AccountForm.Provider>
<AccountForm.Select name="service" label="Service">
{...options}
</AccountForm.Select>
</PersonForm.Provider>
)
<PersonForm.Form defaultValue={{ name: '', account: { service: '' } }}>
<PersonForm.TextInput name="name" label="Name" />
<AccountFormFields />
</PersonForm.Form>

Needless to say, this can get a bit complicated to manage.

No context

Instead of relying on injected context, pass an object that carries the context. This is one of the possible ways to use react-hook-form (this library allows mixing context and this approach).

const personForm = useForm<CreateForm>({
resolver: effectTsResolver(createSchema)
})
<form onSubmit={personForm.handleSubmit(console.log)}>
<Controller
control={personForm.control}
// This is type-safe because it has to be a key
// of the type for the control.
name="name"
render={field => <input {...field} />}
/>
</form>

This is still not composable. You always have to pass a top-level control, and the name has to be a path from the top-level object. One can also imagine subcontrols, similarly to the subforms in the previous example, but this can also be hard to implement correctly (it’s easy to run into TS limitations).

Other languages

This limitation of TypeScript and React in static type-safe dependency injection is not universal to all languages. See for example Effect erroring at the type level when the Random context is not provided, and it all working with the context being provided (see docs). Or ZIO’s contextual data types for dependency injection.

Conclusion

React Context is a useful tool for managing shared state, but TypeScript is not able to offer the same level of assurances we generally expect. It is possible to find cases where carefully managing where developers must make unsafe type assertions can achieve a compromise between convenience and type safety. However, you will now need runtime tests for cases where typically TypeScript would have you covered. This applies to the compound components pattern as well.

Prop-drilling is, in many cases, a decent alternative with a bad reputation. A careful design of component interfaces and the help of the TypeScript language server for autocomple, as well as LLMs, can make the prop-drilling experience quite nice, and type-safe.

Personally, I really value type safety, so the DX improvement has to be pretty good for me to give that up. That may be worthwhile to improve the experience of extremely common operations such as writing forms. I am also more willing to take the hit if I’m delegating the inner complexity to an external dependency.