[DISCLAIMER: My dev experience is quite substantial, but I just started doing TypeScript, oh... about 3 weeks ago. So if I've screwed something up in this article, please feel free to call out my stooopidity in the comments.]
I just ran into something very... odd. It's one of those programming moments that makes you stop and say, "Wait... It can't really be like this, right???" It has to do with implementing default values for component props in React/TypeScript.
The Setup
Our team just began a brand new, "green fields" project. It will be written in React. (Great! That's my specialty.) Specifically, it will use TypeScript & React. (Umm... OK. I got some learnin' to do.) I've been wanting to get my feet wet in a TS project for awhile. So I've been diving in eagerly. But in the last week-or-so, something really threw me for a loop.
To illustrate the issue, I'm gonna take something from a plain-ol' JS component, and convert it into a TS component. The stub of my JS component looks like this:
export default function MyJSComponent(props) {
return (
<>
Here is MyJSComponent:<br/>
{props.children}
</>
);
}
MyComponent.propTypes = {
requiredString: PropTypes.string.isRequired,
requiredNumber: PropTypes.number.isRequired,
optionalBoolean: PropTypes.bool,
optionalString: PropTypes.string,
optionalNumber: PropTypes.number,
};
MyComponent.defaultProps = {
optionalBoolean: true,
optionalString: 'yo',
optionalNumber: 42,
};
Nothing fancy here. A dead-simple component that accepts up to 5 props, with 2 of those props being required. For the 3 optional props, default values are assigned. If the component is wrapping other content, that content will be rendered with props.children
. This is basically React 101
.
So let's set about converting this to TypeScript. In TS, we can infer data types right in the function signature. And in TS, just as in JS, we can supply default values for optional parameters - right in the function signature. So that could look something like this:
export default function MyTSComponent(
requiredString: string,
requiredNumber: number,
optionalBoolean: boolean = true,
optionalString: string = 'yo',
optionalNumber: number = 42,
) {
return (
<>
Here is MyComponent:<br/>
{props.children}
</>
);
}
Except... that doesn't work, does it? This fails on two key levels:
When React invokes a component, it doesn't supply the props to the components as an array of arguments. It supplies them in a single object - the
props
object. So TS will complain about the above code because it will realize that theprops
object does not correlate with therequiredString
type of string.The above code obliterates the standard React convention of being able to call
props.children
. We haven't defined any of the arguments asprops
, and therefore, there is noprops.children
to render.
In other words, the approach above works great when we're writing a "regular" TS function. But it won't work for a TS/React component. We'll need to account for the fact that all of the props are being passed into the component as a single object.
One approach is to alter your tsconfig.json
to disable strict
mode and allow implicit any
types. That would look like this:
export default function MyTSComponent(props) {
return (
<>
Here is MyComponent:<br/>
{props.children}
</>
);
}
With all of your configs disabled/relaxed, you can actually get the above code to run/compile. But if your solution to TS problems is to disable the power of TS, then... don't use TS.
And if your answer to coding problems in any language is to turn off strict
mode or to relax the core config constructs... Well, let's just say that nothing in this article - or this entire site - is going to help you in any way.
Assuming that you're not in favor of disabling TS's core strengths, the next step is to figure out how to get TS to "accept" that props
object. In other words, we need to explicitly define what's in props
.
Inline Type-Hinting
I believe that, in TS, whenever possible, it's best if you can define your data types right in the function signature. It's efficient. It's easy for other devs to "grok". So now that we know that we must specifically define the props
object being passed in, maybe we can do this?
export default function MyTSComponent(props: {
requiredString: string,
requiredNumber: number,
optionalBoolean: boolean = true,
optionalString: string = 'yo',
optionalNumber: number = 42,
children: JSX.Element,
}) {
return (
<>
Here is MyComponent:<br/>
{props.children}
</>
);
}
Except... that doesn't work, does it? If you try typing this out in your IDE, you'll notice that it does, for the most part, work - until you reach the point where you're trying to define default values on the optional properties. (Also, even if the default values worked, the idea of having to manually define props.children
is just... yuck.)
Interfaces
It seems to me that interfaces are the "default" TypeScript way to handle these kinda situations. With a good interface, you can definitively type all the values that are expected in React's traditional props
object. After tinkering with many different configurations, this is what I came up with:
interface Props extends PropsWithChildren<any>{
requiredString: string,
requiredNumber: number,
optionalBoolean?: boolean,
optionalString?: string,
optionalNumber?: number,
}
export default function MyTSComponent({
requiredString,
requiredNumber,
optionalBoolean = true,
optionalString = 'yo',
optionalNumber = 42,
children,
}: Props) {
return (
<>
Here is MyComponent:<br/>
{children}
</>
);
}
Unlike the other attempts shown above, this one actually works. React knows which values are required and which ones are optional. TypeScript understands the type associated with each argument. But IMHO, this approach still has... problems.
The full list of properties is spelled out twice - once in the interface, and once in the function signature. This is necessary because, if we neglect to list, say,
requiredString
in the interface, then TS won't know what type to assign to it. And if we neglect to listrequiredString
in the function signature, it simply won't be available anywhere within the function.We have to list
children
in the function signature. For a long-time React guy, that just feels... wrong. It would be like having to define theconsole.log()
method before you can use it. In React,children
is supposed to be something that you just get "for free".Speaking of React conventions, object destructuring obliterates the near-universal React practice of referencing
props.foo
orprops.children
. That may not be a big deal to some. But for me, it's huge. When I'm combing through the logic in a component, I definitely want to have a clear indicator that a particular variable was passed into the component as a prop. Once you destructure the props out of their original object, you lose that clear scoping.
defaultProps
You may be thinking, "If you want default prop values, why don't you just use the built-in functionality for defaultProps
??" I certainly investigated this. It would look like this:
interface Props extends PropsWithChildren<any>{
requiredString: string,
requiredNumber: number,
optionalBoolean?: boolean,
optionalString?: string,
optionalNumber?: number,
}
const defaultProps: Props = {
requiredString: '',
requiredNumber: 0,
optionalBoolean: true,
optionalString: 'default',
optionalNumber: 42,
}
const MyTSComponent: React.FC<Props> = (props) => {
console.log(props);
return (
<>
Here is MyComponent:<br/>
{props.children}
</>
);
};
MyTSComponent.defaultProps = defaultProps;
export default MyTSComponent;
There's a lot to like here. It maintains the traditional props
convention. It doesn't require explicitly defining props.children
. It makes for a clean function signature.
One thing I don't like about this approach is that I couldn't seem to get it to work unless I also defined default values inside defaultProps
for the required props. If I remove requiredString
and requiredNumber
from the defaultProps
definition, TS complains about it. Still - that's not really that big of a deal.
So is this the end of the article? The "real" solution for default props in React/TS? Umm... no.
No sooner did I start researching this pattern than I found out that there's a big push to deprecate defaultProps
on functional components.
Given the issues I've outlined above, I don't honestly understand why anyone would want to deprecate defaultProps
on functional components. They say things like, "default values are already handled in the function signature". Umm... no, they're not (at least not in a way that properly accommodates React's props
object).
Regardless of the twisted reasoning behind this, it does seem as though this deprecation might happen. So with a big sigh, I moved on to search for other solutions.
My WTF?!?! Moment
Honestly, at this point, I started getting pretty annoyed. What I'm trying to do is, in React/JS, a five-minute lesson. When you first start doing React with plain-ol' JavaScript, it takes mere minutes to realize how you can set default values on the optional props. And yet, in React/TS, this seemingly-simple operation requires jumping through a ridiculous number of hoops. How can this be???
Imagine you travel to another country - one that speaks a language very similar to your own. While you're there, you say to your tour guide, "In your language, how do I say 'thank you'?" And the tour guide points you to a dozen different web pages that all explain ways that you can try to say 'thank you' - with no definitive answer. Finally, the tour guide says, "Well, in our variant of the language, there's really no simple way to say 'thank you'."
What???
It's not like I'm trying to migrate from JavaScript to Objective-C, or from JavaScript to C++. I'm merely moving from React/JS to React/TS. And I'm trying to do something that really should be drop-dead simple. And yet... I'm burning many many hours trying to solve this most-basic of questions.
Nevertheless, I pushed onward. The fact that this "problem" feels ridiculous to me doesn't do anything to help me solve the problem.
In-Function Processing
At this point, I started to think of "other" ways that I could provide default values. So I looked at applying them inside of the function itself. That looks like this:
interface Props extends PropsWithChildren<any>{
requiredString: string,
requiredNumber: number,
optionalBoolean?: boolean,
optionalString?: string,
optionalNumber?: number,
}
export default function MyTSComponent(props: Props) {
props.optionalBoolean = props.optionalBoolean !== undefined ? props.optionalBoolean : true;
props.optionalString = props.optionalString !== undefined ? props.optionalString : 'yo';
props.optionalNumber = props.optionalNumber !== undefined ? props.optionalNumber : 42;
console.log(props);
return (
<>
Here is MyComponent:<br/>
{props.children}
</>
);
}
This doesn't throw any TS linting errors. However, it won't run, because React complains that the props
object is not extensible. So, to get around that, we can do a deep clone of props
with a cloneObject()
function that I outlined in one of my previous articles.
[Yeah, yeah - I get it. Cloning props
just so I can manually add default values feels a bit... hack-ish. But I'm just outlining a progression-of-thought here.]
So with an extra line to clone the props
object, the code looks like this:
interface Props extends PropsWithChildren<any>{
requiredString: string,
requiredNumber: number,
optionalBoolean?: boolean,
optionalString?: string,
optionalNumber?: number,
}
export default function MyTSComponent(props: Props) {
props = cloneObject(props);
props.optionalBoolean = props.optionalBoolean !== undefined ? props.optionalBoolean : true;
props.optionalString = props.optionalString !== undefined ? props.optionalString : 'yo';
props.optionalNumber = props.optionalNumber !== undefined ? props.optionalNumber : 42;
console.log(props);
return (
<>
Here is MyComponent:<br/>
{props.children}
</>
);
}
This approach... works. It compiles. It preserves the conventional props
object, along with props.children
. And for about a day-or-two, I really thought that this was the answer.
Then I started noticing a few annoyances...
While the above code indeed "works" just fine, I found that things started to get wonky when I began adding functions inside the functional component. Consider this example:
interface Props extends PropsWithChildren<any>{
requiredString: string,
requiredNumber: number,
optionalBoolean?: boolean,
optionalString?: string,
optionalNumber?: number,
}
export default function MyTSComponent(props: Props) {
props = cloneObject(props);
props.optionalBoolean = props.optionalBoolean !== undefined ? props.optionalBoolean : true;
props.optionalString = props.optionalString !== undefined ? props.optionalString : 'yo';
props.optionalNumber = props.optionalNumber !== undefined ? props.optionalNumber : 42;
console.log(props);
const getLetterArrayFromOptionalString = (): Array<string> => {
return props.optionalString.split('');
};
return (
<>
Here is MyComponent:<br/>
{props.children}
</>
);
}
I've set a default value of 'yo'
on props.optionalString
. And inside getLetterArrayFromOptionalString()
, I'm trying to split()
that string into an array of letters. But TS won't compile this. It complains that the props.optionalString
object is possibly undefined - even though I clearly defined a default value at the top of the function.
Why does it do this?? Well, TS sees the function as being bound at the point when the component is mounted. And at the point that the component is mounted, there has been no default value set for props.optionalString
yet. It doesn't matter that getLetterArrayFromOptionalString()
will never be called until after a default value has been added to props.optionalString
. TS doesn't fully grok that.
TS chokes on this because the split()
function requires a type string | RexExp
. But props.optionalString
is type: string | undefined
.
Where did that | undefined
come from in our props.optionalString
type? It was dynamically added by TS because the optionalString
parameter is defined as optional (i.e., with the ?
appended to it).
When you add ?
to an interface property, TS will append | undefined
as part of the type definition. This may seem like a good thing, but it can cause headaches later on because TS will expect you to write a whole bunch of code that's tolerant of undefined
values - even though you know that you manually set a value for the variable, and it will never be undefined
.
Annnnnd... I'm right back to the drawing board.
Finally - A Solution
For the time being, I think I have a working solution. (Until I find some other edge case where everything gets borked up...) It looks like this:
//all.props.requires.ts
export type AllPropsRequired<Object> = {
[Property in keyof Object]-?: Object[Property];
};
// my.ts.component.tsx
interface Props extends PropsWithChildren<any>{
requiredString: string,
requiredNumber: number,
optionalBoolean?: boolean,
optionalString?: string,
optionalNumber?: number,
}
export default function MyTSComponent(props: Props) {
const args: AllPropsRequired<Props> = {
...props,
optionalBoolean: props.optionalBoolean !== undefined ? props.optionalBoolean : true,
optionalString: props.optionalString !== undefined ? props.optionalString : 'yo',
optionalNumber: props.optionalNumber !== undefined ? props.optionalNumber : 42,
};
console.log(args);
const getLetterArrayFromOptionalString = (): Array<string> => {
return args.optionalString.split('');
};
return (
<>
Here is MyComponent:<br/>
{props.children}
</>
);
}
So what's actually happening here?
The first thing you see is the AllPropsRequired
type. In TS, it's something called a partial. I'm not gonna go through a tutorial on that here. Suffice it to say that AllPropsRequired
is a type that will make all the properties of some other generic interface required. That will be important in a second...
The Props
interface is fairly "standard" - nothing too magical there.
Inside MyTSComponent
, the first thing I'm doing is creating a new object, based on the props
, cast to the type AllPropsRequired
. In other words, in the args
object, I'm stripping out that optional ?
indicator on every one of the properties.
I'm doing this because every property either had a value passed in (if it was required), or it will have a default value added to it. So none of the properties should be undefined
, and we don't want the property's type to reflect that it could possibly be undefined
.
Inside the args
definition, the first thing I do is spread the ...props
object. I do this so I can avoid manually spelling out each one of the required properties in the object. I only want to spell out the optional properties, and spreading ...props
allows me to do that.
Then, for each of the optional properties, I'm checking to see if something was passed in. If nothing was passed in (i.e., if the property is undefined
), I set the value to its default.
This approach preserves my props.children
feature - because I've done nothing to alter/destroy the original props
object. But throughout the component, any other time when I want to reference props
, I'll use the args
object.
This code compiles, and the line:
return args.optionalString.split('');
runs just fine. It doesn't throw any errors because, in the args
object, optionalString
doesn't have a type of string | undefined
. It simply has a type of string
.
It Shouldn't Be This Hard
Maybe I'm missing something here. Maybe, in the next week or two, I'll realize how silly this whole little journey was. Someone in the comments will say something like, "Why didn't you just use setDefaultProps()
?" And I'll feel really silly for having burned several days trying to reinvent the wheel.
But I know that I'm not entirely alone in this. If you google around for things like "typescript default props functional components", you'll find a number of articles and Stack Overflow questions that (attempt to) address this same problem. And they all run into the same limitations. It just kinda feels like an... oversight to me.
And don't even get me started on the push to deprecate defaultProps
for functional components. That just feels ridiculous to me. Or maybe it's not - I dunno. It could just be that something's not "clicking" right in my brain...
[NOTE: A few days after this was posted, I came up with an improved/revised method. That's highlighted in part two of this series...]
Top comments (36)
Adam,
From this.
To this?
Here's two possible examples:
There is another way to cast Javascript into TS types. Like this:
A number of javascript folks don't like the new keyword, no problem create a factory...
Ok Adam, updated my response.
When I type:
The TS linter tells me that:
Ok make that one a class too
Or change extends to implements.
Just for reference, I'm hoping you find a way around this, or someone posts a solution. I ran into similar things and I just can't be bothered to type stuff out multiple times, it's a waste of effort for the small benefits of type safety at compile time. (I use WebStorm, it can already autocomplete everything and JSDoc will already provide me types and indication of using the wrong ones while I code). That all said, I'm a dyed in the wool C# programmer and would love to be pulling across the great parts of that to the JS world with TypeScript. But yeah, not if I'm going to spend hours of my life trying to explain to a compiler my perfectly logical structure.
Oh, yeah - I'm definitely nodding along to everything you've written here. You probably have more C# experience than me, but I've done a good bit of it and enjoy it. I also use WebStorm and find that it does a really great job of tying most things together for me - and showing me when something seems out-of-place - without using TS.
And I love the description of "trying to explain to a compiler". That really sums up some of my frustrations here. If I'm writing "bad" code, or buggy code, then of course, I'd love for any tool to be able to point that out. But it's always frustrating if you've written something that you know works perfectly well - but the compiler/linter won't stop complaining about it.
Not sure if you read to the end of the article (and I totally understand if you didn't), but for the time being, I think that last "solution" is what I'm running with for now. It's actually not too much more verbose than using
PropTypes.defaultProps
. IMHO, it's still a little "ugly" - but not so much that it makes my dev eye start twitching.I did make it to the end, but think I'd missed that you were so much closer in all of the "right!!" I was saying to the sections before haha.
Haha - I see your point.
For a class component, the correct TS syntax for default props is e.g.:
This is how you avoid having to define default values for required props -- because then of course they aren't really required, are they? In fact the React TS typedefs know how to infer that a prop provided in a defaultProp structure implies that the prop is not actually required on the component:
(FWIW I don't actually grok those typedefs, but I understand what they do.)
For a function component, if you assign defaultProps inline, TS seems to infer all the correct things, e.g.:
Hi, Craig, and thanks for the feedback. Thank you for outlining the class-based approach. I suppose I shoulda mentioned in the article that, the reason all my examples use functional components, is because the decision was made for the project that we'd be using functional React components. But I appreciate you taking the time to put those examples here.
As for the last example you give, the one that deals with functional components, there's a whole section in this article that outlines that approach - and also explains why I did not choose to use it. Primarily, there's a significant fear that
defaultProps
will be deprecated for functional components.So that's kinda what led to this whole article. There are these techniques for doing this in class-based components - but we're not going to switch everything over to class-based components over this one simple issue. Then there is this technique for doing this in a function-based component - but there's a ton of chatter that this will be deprecated - and I don't want to base the dev for a "green fields" project on something at significant risk of being deprecated. Subsequently, the search for alternate solutions becomes... ugly.
I'm with you :) I saw your post because I also think the removal of defaultProps on FCs is a mistake, and saw your link on the RFC comments
Oh, cool! I'm glad you saw my comment on the RFC.
My workaround on this weird issue
I see what you did there. By simply spreading
...defaultProps
first, those default values will be overridden by any real values that were passed in.But doesn't this leave the optional properties still defined with type
string | undefined
?? That was one of the big problems that I was trying to solve. If a prop has a default value, it should never beundefined
. And I should never have to write code that accounts for this possibility.I was wrestling with this a lot until I found this pretty sane and simple approach from typescript-cheatsheets/react:
This seems like it satisfies everything except your
defaultProps
concern, which seems like it's a really premature concern. The React 17 RC has just been released, anddefaultProps
are still here, and I'm struggling to find references to discussions about it being deprecated soon. If there were plans to deprecate it, I think we'd see something in the docs, or the usage signature would change toGreet.UNSAFE_defaultProps
in advance, as is the team's habit with things like UNSAFE_componentWillMount.FWIW, this is the open RFC thread that discusses (amongst several things) deprecating defaultProps on functional components.
github.com/reactjs/rfcs/pull/107
To be clear, it's obviously just an RFC and it may never come to pass. And I typically pay little-or-no attention to RFCs unless they become adopted and implemented. Because you can't run your programming life based on what might change in the language.
But in this case, I'd already read references to this in multiple other places. And because I was just getting into TS, I thought, "Well, if there's even a minor chance that it goes away - how do I accomplish this without the feature?" And that started my descent down the rabbit hole.
It's entirely possible that, in several years, I'll be shaking my head over the time I wasted on this, because the RFC was never adopted and defaultProps are still readily available. I hope that's the case.
Hi Adam the closest I came to a solution that could satisfy your need is following and everything is nicely typecheck
with this solution you could put the destructured props inside an args object inside your component
And typescript has a Required type that does the same as your AllPropsRequired type
hope this helps
First, thank you for showing me the Required type! I had no idea that existed. Makes a lot more sense than doing it manually with my own custom partial.
Second, I do like your approach. The only thing I find lacking in it, is the need to manually chunk those values into an
args
object (assuming you want them in a single object - like I do). But that's not really a huge objection, is it? Hmm...From looking at your example, one of the problems with my prior approaches was probably that I wasn't consistently leveraging
React.FC<>
. From many of the examples I've been looking at online, it's not at all clear that this should be used whenever creating a functional React component - but I'm starting to think that's the case.Very cool - thanks!!
Your welcome
The nice thing about the React.FC is that it defines the return type of your function and it add
children?: React.ReactNode
to your props so no need to handle that prop yourselfOIC. I think I had kinda stumbled into a different way to handle that. Cuz in my (final) example, my interface is defined as:
But I think I like the
React.FC
way better.Another "challenge" with the approach you've outlined here is that the
requiredString
andrequiredNumber
values only exist under theprops
object - butoptionalString
,optionalBoolean
, andoptionalNumber
exist as standalone variables. As you've pointed out, you can get them back into one object, but you'd have to manually addrequiredString
andrequiredNumber
to that object as well.That's not insurmountable, but I gotta play with it for a bit to see if there's a slicker way of handling that...
You could try something around these lines
whit this you would get a fully typed props object that you could use as you used to
I've actually built a helper function now that takes the existing
props
and a simple object that defines any default values and then returns it mapped to theProps
type. I'll probably outline that in a near-future post.Thanks for taking the time to point me along!
As of Nov 2022, Typescript 4.9 has a 'satifies' operator which solves this in a cleaner way, check out the stack overflow here: stackoverflow.com/questions/757665...
Check this:
Also check github.com/microsoft/TypeScript/is...
There's an entire section in my article that covers
defaultProps
. I specifically outline there why I chose to forgo that approach - because the current indication is thatdefaultProps
will be deprecated on functional components.For future readers, I think this is the best way. I wrote a short post about it dev.to/qpwo/closest-thing-to-struc...