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:
sharedPropandaccountTypeare required propsuserIdis required ifaccountTypeisuser, not permitted otherwiseadminIdis required ifaccountTypeisadmin, not permitted otherwise- at least one of
userIdandadminIdmust 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:
Implementing Dark Mode: A Server-Side Approach with Cookies
March 17, 2024
A server-and-client approach using cookies