last updated: November 11, 2024
7 minute read
Conditional Props in React Using Type Discrimination
When writing reusable components in React, you'll sometimes want to dynamically require certain
props based on the value of another. For example, say we have a component with a prop accountType
.
If accountType
is "admin"
, we'll want to require the consumer to pass in an adminId
prop, and
if accountType
is "user"
, a userId
prop. The most straightforward implementation of an
interface to represent our props would look something like the following:
interface TProps {sharedProp: boolean;accountType: "user" | "admin";adminId?: number;userId?: number;}
Of course, there's nothing to prevent the consumer from passing neither adminId
or userId
- and
nothing to prevent them from passing both! How can we define TProps
to be a bit smarter?
Note: although this post focuses on React component props, the same principles apply to a vanilla javascript function as well
Skip to the solution here!
First Attempt: Generics
As a disclaimer, a generic-based solution comes with a number of unfortunate drawbacks and isn't a great fit for our problem. It's still interesting to explore, though, so let's take a look.
First, let's construct a union of our account types: TAccountType
:
type TAccountType = "admin" | "user";
And an interface of the props that we'll always require:
interface TProps {sharedProp: boolean;accountType: TAccountType;}
Next, let's conditionally intersect our props with either { adminId: number}
or
{ userId: number }
depending on the type of TAccountTypeGeneric
:
type TProps<TAccountTypeGeneric extends TAccountType> = {sharedProp: boolean;accountType: TAccountType;} & (TAccountTypeGeneric extends "admin" ? { adminId: number } : { userId: number });
You can think of our type TProps
as representing a type function - the "argument" is
TAccountTypeGeneric
, and the "return value" is the type we construct with our intersection.
First, we note that our TAccountTypeGeneric
argument must be a type that extends TAccountType
.
Then we specify that if the TAccountTypeGeneric
argument extends "admin"
, we'll intersect our
shared props with { adminId: number }
, else with { userId: number }
. extends
is another way of
saying "is a subset of" - which in our case just means "is equal to", since "admin"
is a set of
just one item.
If we manually pass in the TAccountTypeGeneric
, we can validate that our generics are working.
type TAdminProps = TProps<"admin">;// type TAdminProps = { sharedProp: boolean; accountType: "admin"; } & { adminId: number; }type TUserProps = TProps<"user">;// type TUserProps = { sharedProp: boolean; accountType: "user"; } & { userId: number; }
But instead of manually passing in TAccountTypeGeneric
, we can simplify our type function to
infer its value by specifying the type of accountType
as TAccountTypeGeneric
instead of
TAccountType
.
type TProps<TAccountTypeGeneric extends TAccountType> = {sharedProp: boolean;accountType: TAccountTypeGeneric;} & (TAccountTypeGeneric extends "admin" ? { adminId: number } : { userId: number });
This looks like a great solution, but it doesn't work too well in practice. If we construct a
component that takes in a generic TAccountTypeGeneric
and passes it along to TProps
, we can see
that typescript isn't able to narrow the type of props
based on the value of props.accountType
.
function Component<TAccountTypeGeneric extends TAccountType>(props: TProps<TAccountTypeGeneric>) {if (props.accountType === "admin") {props.adminId;// Property 'adminId' does not exist on type '{ sharedProp: boolean; accountType: TAccountTypeGeneric; } & ({ adminId: number; } | { userId: number; })'.// Property 'adminId' does not exist on type '{ sharedProp: boolean; accountType: TAccountTypeGeneric; } & { userId: number; }'.} else {props.userId;// Property 'userId' does not exist on type '{ sharedProp: boolean; accountType: TAccountTypeGeneric; } & ({ adminId: number; } | { userId: number; })'.// Property 'userId' does not exist on type '{ sharedProp: boolean; accountType: TAccountTypeGeneric; } & { adminId: number; }'.}return null;}
Even though our TProps
type conditionally intersects the shared props with an object of adminId
or userId
based on accountType
, typescript can't apply the same logic to our if
check - it
can't figure out that TProps
should be TProps<'admin'>
if if (props.accountType === 'admin')
is true
. There's a few ways to get around this. First, we can assert the type of props
with a
custom type guard:
function isAdminProps(props: TProps<TAccountType>): props is TProps<"admin"> {return props.accountType === "admin";}function isUserProps(props: TProps<TAccountType>): props is TProps<"user"> {return props.accountType === "user";}function Component<TAccountTypeGeneric extends TAccountType>(props: TProps<TAccountTypeGeneric>) {if (isAdminProps(props)) {props.adminId; // No error!} else if (isUserProps(props)) {props.userId; // No error!}return null;}
or manually assert the type of props
with the as
keyword:
function Component<TAccountTypeGeneric extends TAccountType>(props: TProps<TAccountTypeGeneric>) {if (props.accountType === "admin") {(props as TProps<"admin">).adminId; // No error!} else {(props as TProps<"user">).userId; // No error!}return null;}
But in that case, we don't need the if
check at all!
function Component<TAccountTypeGeneric extends TAccountType>(props: TProps<TAccountTypeGeneric>) {(props as TProps<"admin">).adminId; // No error!(props as TProps<"user">).userId; // No error!return null;}
From the point of view of the component author, there's little typesafety gained by all our generic shenanigans. However, it's worth noting that the consumer does in fact benefit from the typesafety we were originally aiming for.
When rendering Component
:
sharedProp
andaccountType
are required propsuserId
is required ifaccountType
isuser
, not permitted otherwiseadminId
is required ifaccountType
isadmin
, not permitted otherwise- at least one of
userId
andadminId
must be passed, but not both
<Component sharedProp accountType="admin" />;// Property 'adminId' is missing in type '{ sharedProp: true; accountType: "admin"; }' but required in type '{ adminId: number; }'.typescript(2741)<Component sharedProp accountType="admin" userId={456} />;// Type '{ sharedProp: true; accountType: "admin"; userId: number; }' is not assignable to type 'IntrinsicAttributes & { sharedProp: boolean; accountType: "admin"; } & { adminId: number; }'.// Property 'userId' does not exist on type 'IntrinsicAttributes & { sharedProp: boolean; accountType: "admin"; } & { adminId: number; }'.typescript(2322)<Component sharedProp accountType="admin" adminId={123} userId={456} />;// Type '{ sharedProp: true; accountType: "admin"; adminId: number; userId: number; }' is not assignable to type 'IntrinsicAttributes & { sharedProp: boolean; accountType: "admin"; } & { adminId: number; }'.// Property 'userId' does not exist on type 'IntrinsicAttributes & { sharedProp: boolean; accountType: "admin"; } & { adminId: number; }'.typescript(2322)<Component sharedProp accountType="admin" adminId={123} />;// No errors!
So how can we maintain this typesafety for the consumer, but also gain a measure of typesafety for the component author as well?
Second Attempt: Type Discrimination
Type discrimination turns out to be a far better solution. Let's start by creating two different variants of our props:
interface TAdminProps {sharedProp: boolean;accountType: "admin";adminId: number;userId?: never;}interface TUserProps {sharedProp: boolean;accountType: "user";adminId?: never;userId: number;}type TProps = TAdminProps | TUserProps;
In the code above, we specify TAdminProps
as having an accountType
of type "admin"
, adminId
of number
, and userId
of never
. never
ensures that typescript will throw an error if we
attempt to pass a userId
prop if our component's props are typed as TAdminProps
- and we make
userId
optional so we're not required to pass it and throw a TS error! We follow the same
pattern for TUserProps
, and construct TProps
as a union of the two.
If you have several shared props, you can create a BaseProps
interface and update TAdminProps
and TUserProps
to extend it:
interface SharedProps {sharedProp: boolean;// other props here}interface TAdminProps extends SharedProps {accountType: "admin";adminId: number;userId?: never;}interface TUserProps extends SharedProps {accountType: "user";adminId?: never;userId: number;}type TProps = TAdminProps | TUserProps;
This looks great, but if we type our component's props
as TProps
, how is typescript supposed to
know which of the two interfaces in our TProps
union it should use?
The key is the accountType
field. Since accountType
is present in both interfaces with different
types (i.e. the literal string types "admin"
and "user"
), typescript is able to determine the
type of props
by analyzing your code's control flow. If the value of props.accountType
is
determined by your code to be "admin"
, typescript will narrow props
from TProps
down to
TAdminProps
, otherwise to TUserProps
.
Let's try it out:
function Component(props: TProps) {if (props.accountType === "admin") {props; // (parameter) props: TAdminPropsprops.adminId; // (property) TAdminProps.adminId: number} else {props; // (parameter) props: TUserPropsprops.userId; // (property) TUserProps.userId: number}return null;}
If you're interested in more info, the typescript docs have some great examples on using type discrimination
This solution is great because it provides typesafety for the component author to ensure they perform any necessary checks before accessing a prop, and it requires that the consumer pass in different props based on the value of a discriminator prop. The errors are a bit different from before, but the typesafety is just as good as our generic-based solution:
<Component sharedProp accountType="admin" />;// Type '{ sharedProp: true; accountType: "admin"; }' is not assignable to type 'IntrinsicAttributes & TProps'.// Property 'adminId' is missing in type '{ sharedProp: true; accountType: "admin"; }' but required in type 'TAdminProps'.typescript(2322)<Component sharedProp accountType="admin" userId={456} />;// Type '{ sharedProp: true; accountType: "admin"; userId: number; }' is not assignable to type 'IntrinsicAttributes & TProps'.// Type '{ sharedProp: true; accountType: "admin"; userId: number; }' is not assignable to type 'TUserProps'.// Types of property 'accountType' are incompatible.// Type '"admin"' is not assignable to type '"user"'.typescript(2322)<Component sharedProp accountType="admin" adminId={123} userId={456} />;// Type '{ sharedProp: true; accountType: "admin"; adminId: number; userId: number; }' is not assignable to type 'IntrinsicAttributes & TProps'.// Type '{ sharedProp: true; accountType: "admin"; adminId: number; userId: number; }' is not assignable to type 'TUserProps'.// Types of property 'accountType' are incompatible.// Type '"admin"' is not assignable to type '"user"'.typescript(2322)<Component sharedProp accountType="admin" adminId={123} />;// No errors!
Thanks for reading!
you might also like:
Revamping Data Fetching Patterns on the Web Platform at Wealthfront
August 5, 2024
Using React Query to find a hybrid between global and local data fetching