Typescript: Stateless components and defaultProps bug

Created on 28 Sep 2018  路  13Comments  路  Source: microsoft/TypeScript


TypeScript Version: 3.2.0-dev.20180927


Search Terms:

Code


interface Props {
  text: string;
}

function BackButton(props: Props) {
  return <div/>
}
BackButton.defaultProps = {
  text: 'Go Back',
};
let a = <BackButton /> // error: text is missing in type {}

It works ok with React.Component. Also I tried to make LibraryManagedAttributes<C,P>={c: string} . This changed checking for Component, but not for SFC.

Bug

Most helpful comment

Right, so we can do following change in react types or user needs to explicitly define default props:

type Props = { name: string }

const Hello: FunctionalComponent<Props> & {defaultProps: Partial<Props>} = ({name}) => <div>Hello {name}!</div>

Hello.defaultProps = { name: 'Johny Five' }

// No Error
const Test = () => <Hello />

All 13 comments

From the typescript 3.0 release notes:

For stateless function components (SFCs) use ES2015 default initializers for SFCs:

import React from 'react'

interface Props {
    name: string;
}

function Greet({ name = "world" }: Props) {
    return <div>Hello ${name.toUpperCase()}!</div>;
}

However if I copy paste the above into an editor and run tsc with TypeScript 3.1.1 I get this error:

error TS2322: Type '{}' is not assignable to type 'Props'.
  Property 'name' is missing in type '{}'.

export const foo = <Greet />
                    ~~~~~

Also, separate concern: if I'm not mistaken, ES2015 default initializers are less performant than defaultProps. If you're recommending ES2015 default initializers, maybe it should mention that it's less performant?

Source: https://github.com/yannickcr/eslint-plugin-react/issues/1009#issuecomment-269111018

There's no need for a link - by spec, default values are re-evaluated on every invocation of the function. defaultProps, on the other hand, are evaluated once and cached at the module level - across every invocation of the function. Even if React does nothing with regard to them (which I doubt, but haven't bothered to dig up evidence of), defaultProps are more performant than default values.

Ah, my apologies - I just realized that defaultProps is now handled in TypeScript 3.1 (so what @goloveychuk reported is indeed a bug):
https://blogs.msdn.microsoft.com/typescript/announcing-typescript-3-1/#easier-properties-on-function-declarations

screen shot 2018-10-02 at 1 38 26 pm

This doesn't seem to work very well in conjunction with React.forwardRef...you can use typeof as a workaround but it certainly isn't ideal:

interface Props {
    text: string
}

function BackButton(props: Props) {
    console.log('props: ', props)
    return <div />
}
BackButton.defaultProps = {
    text: 'Go Back',
}

const Wrapper = React.forwardRef<HTMLDivElement, Props>((props: Props, ref) => (
    <div ref={ref}>
        <BackButton {...props} />
    </div>
))

const Test = Wrapper as typeof BackButton & React.ClassAttributes<HTMLDivElement>

const a = <Test />

I think it'll work like you want it to if you use React.forwardRef<HTMLDivElement, JSX.LibraryManagedAttributes<typeof BackButton, Props>(...) instead of (incorrectly) just asserting it takes a Props (when it really takes a Props-less-the-defaults-specified-on-BackButton)

While this was fixed for "raw" functional components:

import React from 'react'

type Props = { name: string }
const Hello = ({name}:Props) => <div>Hello {name}!</div>
Hello.defaultProps = { name: 'Johny Five' }


const Test = () => <Hello />

Issue still remains when using generic FunctionalComponent to annotate a component

import React, {FunctionalComponent} from 'react'

type Props = { name: string }
const Hello: FunctionalComponent<Props> = ({name}) => <div>Hello {name}!</div>
Hello.defaultProps = { name: 'Johny Five' }

// Error! missing name
const Test = () => <Hello />

When you use the FunctionalComponent annotation, you're stating that your component has an optional defaultProps of type Partial<P> - the more specific type you assign to the field doesn't do anything, since you already annotated it. You need to include the specific defaultProps type you assign in the type annotation.

Right, so we can do following change in react types or user needs to explicitly define default props:

type Props = { name: string }

const Hello: FunctionalComponent<Props> & {defaultProps: Partial<Props>} = ({name}) => <div>Hello {name}!</div>

Hello.defaultProps = { name: 'Johny Five' }

// No Error
const Test = () => <Hello />

I know this is issue is already closed, but imho the problem still exists. And although @Hotell provided a solid work around, which solves the problem, it also introduces another problem in which TypeScript just treats every prop of the function component as optional, even if there's no default prop given.

type Props = {
  name: string;
  birthday: Date;
}

const Hello: FunctionalComponent<Props> & {defaultProps: Partial<Props>} = ({name, birthday}) => {
  return <div>Hello {name}! Your birthday is {birthday.toLocaleDateString()}</div>
}

Hello.defaultProps = { name: 'Johny Five' }

const Test = () => <Hello />

The last line would still show no error but the rendering will obviously fail with Cannot read property 'toLocaleDateString' of undefined.

You'd need to use a more specific type when declaring Hello, which would still just be a work around.
The actual issue in TypeScript remains.

@cheeZery The current definitions for react include {defaultProps?: Partial<Props>} directly in the FunctionComponent, which I think is unfortunate. This makes it very difficult to make just a subset of props considered for defaultProps. Since Defaultize in react libs considers just keyof D (ie keyof defaultProps). Once you make defaultProps required (with the intersection), all the keys of Partial<Props> will be considered in Defaultize when figuring out what keys are in the defualt. You don't even need any prop names in the intersection, this will exhibit the same behavior: const Hello: FunctionComponent<Props> & { defaultProps: {} }.

We can get things to work as expected if we do some surgery on FunctionComponent to remove the existing defaultProps. Also I would define defaultProps beforehand and use the type of the const:

type Props = {
    name: string;
    birthday: Date;
}
type FixDefaults<T extends FunctionComponent<any>, D extends Partial<ComponentProps<T>>> = Pick<T, Exclude<keyof T, 'defaultProps'>>  // remove defaultProps
    & (T extends (...a: infer A)=> infer R? (...a: A)=> R : never) // keep signature
    & { defaultProps: D } // new defaults 

const defaultProps = { name: 'Johny Five' };

const Hello: FixDefaults<FunctionComponent<Props>, typeof defaultProps> = ({ name, birthday }) => {
    return <div>Hello {name}! Your birthday is {birthday.toLocaleDateString()}</div>
}

Hello.defaultProps = defaultProps

const Test1 = () => <Hello />// Err
const Test2 = () => <Hello birthday={new Date()} /> // ok

Or define a helper function that keeps default definition and assignment in one place:

type Props = {
    name: string;
    birthday: Date;
}
type FixDefaults<T extends FunctionComponent<any>, D extends Partial<ComponentProps<T>>> = Pick<T, Exclude<keyof T, 'defaultProps'>>  // remove defaultProps
    & (T extends (...a: infer A)=> infer R? (...a: A)=> R : never) // keep signature
    & { defaultProps: D } // new defaults 

function declareWithDefaults<TProps>() {
    return function<TDefualts extends Partial<TProps>>(comp: FunctionComponent<TProps>, defaultProps: TDefualts): FixDefaults<FunctionComponent<TProps>, TDefualts> {
        comp.defaultProps = defaultProps;
        return comp as any
    }
}

const Hello = declareWithDefaults<Props>()(({ name, birthday }) => {
    return <div>Hello {name}! Your birthday is {birthday.toLocaleDateString()}</div>
}, { name: 'Johny Five' });

const Test1 = () => <Hello />// Err
const Test2 = () => <Hello birthday={new Date()} /> // ok

Although I understand defining extra functions just to get the types to work out is a bit controversial :)

Update:

defaultProps on functions will eventually go away according to Dan Abramov. The right way to do default props on functional components is just to use default function parameters.


Old answer:

The workaround of just not specifying any type at all on components and letting it be inferred seems to cause everything to work as desired.

const MyComponent = (props: { name: string, telephone: string }) => {
  ...
}

MyComponent.defaultProps = { telephone: "222-333-4444" }

// Works - good
const test = <MyComponent name="Hulk Hogan" />

// Doesn't work - missing name, good
const test = <MyComponent />

(As noted by @cheeZery , the previous workaround causes all props to be considered optional, which won't work for our case and I suspect most others.)

@lukewlms I think you missed the discussion where @Hotell described how "raw" FC infer correctly, but this breaks in cases where the component can't be "raw" because it has types attached to it, e.g. forwardRef.

type FooProps = { name: string, telephone: string };
const MyComponent = (props: FooProps) => null;
MyComponent.defaultProps = {telephone: '222-333-4444'};

// Works
<MyComponent name="Hulk Hogan"/>

const FwdMyComponent = React.forwardRef((props: FooProps, ref: Ref<any>) => null);
FwdMyComponent.defaultProps = {telephone: '222-333-4444'};

// Doesn't work
<FwdMyComponent name="Terry"/>;

Partial application in React is useful for extending components, since defaultProps is going away could this work (at least for React.FCs)?:

import * as React from "react";

// withDefault helper type
const withDefault = <
  Component extends React.FC<any>,
  Props extends React.ComponentProps<Component>,
  DefaultProps extends keyof Partial<Props>
>(
  component: Component,
  defaultProps: Pick<Props, DefaultProps>
): React.FC<
  Omit<Props, DefaultProps> & Partial<Pick<Props, DefaultProps>>
> => props => React.createElement(component, { ...defaultProps, ...props });

// example base component
const Base: React.FC<{
  foo: string;
  bar: string;
}> = ({ foo, bar }) => (
  <pre>
    {foo} {bar}
  </pre>
);

// extended component
const DefaultFoo = withDefault(Base, { foo: "default" });
const DefaultBar = withDefault(Base, { bar: "default" });

// Errors
// const ExtraDefault = withDefault(Base, { qox: "default" });

export default () => (
  <>
    <Base foo="manual" bar="manual" />
    <DefaultFoo bar="manual" />
    <DefaultBar foo="manual" />
    {/* can override defaults */}
    <DefaultFoo foo="manual" bar="manual" />
    <DefaultBar foo="manual" bar="manual" />
  </>
);

output:

manual manual
default manual
manual default
manual manual
manual manual

playground link

Was this page helpful?
0 / 5 - 0 ratings