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:
How I Built My Blog
May 05, 2023
The stack I chose, or how I learned to stop worrying and love Next.js, mdx, prisma, and tailwind