Description
This proposal aims to fix problems around React's use of defaultProps
, which TypeScript has trouble understanding today.
Background
Today, React has a concept called defaultProps
; these are props that an external user of a component may consider optional, but are always present in the component definition.
export class Greeting extends React.Component {
static defaultProps = {
name: 'stranger'
}
render() {
return (
<div>Hello, {this.props.name}</div>
)
}
}
let myElement1 = <Greeting name="Daniel!"/>; // <div>Hello, Daniel!</div>
let myElement2 = <Greeting />; // <div>Hello, stranger</div>
Unfortunately, TypeScript --strictNullChecks
users who want to take advantage of this are in a tough spot.
They can either specify these props as optional and assert that defaulted properties are present internally,
export interface GreetingProps {
name?: string
}
export class Greeting extends React.Component<GreetingProps> {
static defaultProps = {
name: 'stranger'
}
render() {
// Notice the non-null assertion!
// | |
// | |
// \ /
// V
return (
<div>Hello, {this.props.name!.toUpperCase()}</div>
)
}
}
or they can jump through levels of indirection:
export interface GreetingProps {
name?: string
}
// Notice we are using a 'const' declaration here!
export const Greeting: React.Component<GreetingProps> = class extends React.Component<GreetingProps> {
static defaultProps = {
name: 'stranger'
}
render() {
return (
<div>Hello, {this.props.name!.toUpperCase()}</div>
)
}
}
Proposals
Using resolution semantics of the JSX factory
Users can already emulate this behavior with React.createElement
: https://tinyurl.com/y9jbptt9
// React-like API
export interface Component<P> {
props: P;
}
export interface ComponentClass<P, Defaults = void> {
new(props: P): Component<P>;
defaultProps: Defaults;
}
export interface Element<P> {
type: string | ComponentClass<P>;
props: P;
}
// Some additions
type Defaultize<T, Defaults> =
& Partial<Pick<T, Extract<keyof T, keyof Defaults>>>
& Pick<T, Exclude<keyof T, keyof Defaults>>;
declare function createElement<Props, Defaults>(
type: ComponentClass<Props, Defaults>,
props: Defaultize<Props, Defaults> | null):
Element<Props>
// Now comes your code...
class FooComponent {
props: { hello: string, world: number };
static defaultProps: { hello: "hello" };
}
// works: 'hello' got defaulted
createElement(FooComponent, { world: 100 });
// error: missing 'world'
createElement(FooComponent, { hello: "hello" });
In theory, just passing the right data to the JSX factory should do the work for us.
However, there are some issues:
- The
Defaultize
type is hard to read. - TypeScript treats the object literal in
createElement
as an inference site, and when inference fails, users get no completions for props. It's not ideal in the general case, but the goal is to continue to give a good experience in JSX.
Add a new type in the JSX namespace
We could potentially add a new field called ComponentDefaultPropNames
in JSX
which would either be a union of literal types or possibly an object type whose property names describe the respective names that defaultProps
could have:
namespace JSX {
export type ComponentDefaultPropNames = "defaultProps";
}
When writing out a JSX tag for a component, JSX.ComponentDefaultPropNames
is looked up on the type of the tag name to determine which properties should be considered optional.
So for example:
<TagName {...someObject}>
If TagName
expects props with the type Props
, TypeScript can relate typeof someObject
to some combination of Props
and (typeof TagName)[JSX.ComponentDefaultPropNames]
.
Potential issues
Under either proposal, some type is inferred from the type of the component's defaultProps
property.
Since React's .d.ts
files currently define defaultProps
to be a Partial<P>
, where P
is the type parameter for the type of props, we need to avoid that from making all props optional.
To avoid issues here, we could say that only required properties on defaultProps
satisfy a JSX element's type. So for example:
interface MyProps {
name: string;
age: number;
}
class MyComponent extends React.Component<MyProps> {
render() {
return <div>My name is {this.props.name} and I am {this.props.age} years old.</div>;
}
static defaultProps: { name?: string, age: number } = {
name: "Daniel",
age: 26,
}
}
// error: 'name' is still considered required
let x = <MyComponent />
// works! 'age' is optional because it was definitely present in the type of 'defaultProps'.
let y = <MyComponent name="Daniel" />