Memoji of Merlin smilingMemoji of Merlin winking

My blog.

All posts
Conditional props with TypeScript

Conditional props with TypeScript

4 mins read

When designing reusable components you can often come across use cases where certain props are only conditionally required. Let’s look at a really simple example of this with a button component and how we can handle it at the TypeScript level.

It’s fairly common for designs to have something that looks like a button be used for both button behaviours (any operation that updates frontend or backend state) and for link behaviours (pure navigation that doesn’t update any state). For accessibility reasons we should use the correct HTML element. It makes sense that we might want to keep everything that looks like a button all within a single component.

This produces some challenges we need to solve though, we don’t want consumers of our component (other engineers or even ourselves) to use it incorrectly.

💩 Handling the conditions at runtime

import { type PropsWithChildren } from "react";

type Props = PropsWithChildren<{
    as: "button" | "a";
    type?: "button" | "submit";
    onClick?: () => void;
    href?: string;
}>;

const Button = ({ children, as, type, onClick, href }: Props) => {
    const styles = "inline-block py-2 px-6 bg-blue-600 text-white rounded";

    if (as === "button") {
        if (onClick === undefined) {
            throw new Error("You must provide an onClick handler");
        }

        return (
            <button
                className={styles}
                type={type === "button" ? "button" : "submit"}
                onClick={onClick}
            >
                {children}
            </button>
        );
    }

    if (href === undefined) {
        throw new Error("You must provide an href");
    }

    return (
        <a className={styles} href={href}>
            {children}
        </a>
    );
};

This approach works, but trying to handle the different combinations of props in our runtime code means things get pretty complicated pretty quickly. It can also lead to frustrating developer experience, passing an href through to a button would just fail silently here and we have no feedback to what we’re doing wrong. There must be a better way to do this?

😻 Handling the conditions at compile time

import { type PropsWithChildren } from "react";

type Props = PropsWithChildren &
    (
        | {
              as: "button";
              type: "button" | "submit";
              onClick: () => void;
          }
        | {
              as: "a";
              href: string;
          }
    );

const Button = ({ children, as: El, ...restProps }: Props) => (
    <El
        className="inline-block py-2 px-6 bg-blue-600 text-white rounded"
        {...restProps}
    >
        {children}
    </El>
);

We can use a TypeScript pattern called Discriminating Unions to create types which are conditional based on a field with a literal value (in our case as). Our type here Props creates a union of children with either button and its props or a and its props.

This gives us confidence that consumers of our component will send through a correct combination of props, so our runtime code can be hugely simplified.

Our code will work as expected for the following use cases.

// ✅ Valid as "a" has href
<Button as="a" href="/">
    Hello world!
</Button>

// ✅ Valid as "button" has onClick
<Button as="button" onClick={() => console.log("clicked")}>
    Hello world!
</Button>

If we try and attempt passing an incorrect combination of props we get errors in our IDE explaining what we’ve done wrong.

// 🚫 Error as "a" doesn't have onClick
<Button as="a" onClick={() => console.log("clicked")}>
    Hello world!
</Button>

// 🚫 Error as "button" doesn't have href
<Button as="button" href="/">
    Hello world!
</Button>

// 🚫 Error as "button" requires onClick
<Button as="button">
    Hello world!
</Button>

// 🚫 Error as "pizza" is not valid
<Button as="pizza">
    Hello world!
</Button>

TypeScript types for all HTML elements

We can also access a full list of available HTML elements using the types shipped in @types/react-dom via JSX.IntrinsicElements.

import { type PropsWithChildren } from "react";

type Props = PropsWithChildren<{
    className?: string;
}> &
    (
        | {
              el?: "label";
              htmlFor: string;
          }
        | {
              el: keyof Omit<JSX.IntrinsicElements, "label">;
          }
    );

const Label = ({ htmlFor, el: El = "label", className, children }: Props) => (
    <El
        className={`text-lg font-bold select-none ${className}`}
        {...(htmlFor ? { htmlFor } : {})} // ensure this attribute is only rendered if it exists
    >
        {children}
    </El>
);

In this example we have a typographic label component that we may want to use with form components or elsewhere… be default it renders as a <label> element and we must provide an htmlFor attribute for accessibility reasons. However if we choose to render elsewhere, say as a <legend> or <dt> it is unable to accept the htmlFor prop. The keyof Omit<JSX.IntrinsicElements, "label"> creates a union of every HTML element name, but omitting “label”. This ensures that the value passed to el is actually a valid HTML element name.

Summary

Given that you’re operating entirely in a type safe environment, I think it’s good wherever possible to push data validation like this to the TypeScript level rather than runtime level. TypeScript a really great tool for this, and if you’re type checking in CI with tsc this will ensure any breaking changes to your types are picked up before you deploy. It also means you’re runtime code is simpler and you ship less JavaScript to the browser. Finally, it’s also a far more direct and efficient developer experience to get errors in your IDE than in the browser.

Loading likes...