DEV Community

Graham Marlow for OneSignal

Posted on • Originally published at onesignal.com on

Effective TypeScript for React Applications

Effective TypeScript for React Applications

TypeScript and React are excellent in combination. When you sprinkle React’s expressive component model with a little strong typing, you get fewer bugs and an improved developer experience.

That said, the amount of techniques and nuances baked into the TypeScript/React combo can be overwhelming. TypeScript offers so much flexibility in structuring your React components that it’s hard to know what’s best in which situation. Those familiar with JavaScript may find themselves banging their heads against stubborn compiler warnings. Others may dread the boilerplate mountain that looms over every component.​

No matter your level of experience with TypeScript, this guide is here to help you out. We’ve listed below our best practices for working with TypeScript and React, which are based on the same patterns used in our core product.

Use Interfaces Until You Need a Type

For the most part, you can use type aliases and interfaces interchangeably, as the difference between the two is subtle. You can extend existing interfaces with new properties, whereas type aliases are off-limits after declaration. Despite their similarity, it’s still useful to define a pattern so that you’re using the two styles consistently.

Here at OneSignal, we follow the heuristic “use interface until you need to use features from type". We recommend interfaces because they offer a familiar syntax for inheritance that mirrors ES2015 classes. In contrast, types are used to alias primitive values or create unions from other types.

type ButtonKind = "primary" | "secondary";

interface Props {
  kind: ButtonKind;
}
Enter fullscreen mode Exit fullscreen mode

Unions Over Enums

Unlike most TypeScript features, [enum] is not a type-level addition to JavaScript but something added to the language and runtime. Because of this, it’s a feature which you should know exists, but maybe hold off on using unless you are sure.

TypeScript Handbook

Union types and enums tend to occupy the same space in React in that they both enforce that a particular prop must be one value of a given set. However, we recommend union types over enums for a number of reasons:

  • They are compiler-only constructs, so they won't end up in your application's JS bundle.
  • They are extensible to other union types.
  • They are less verbose.
// Avoid enums:
enum ButtonKind {
  PRIMARY = "primary",
  SECONDARY = "secondary",
}

// Prefer union types:
type ButtonKind = "primary" | "secondary";

// Extensible to other union types:
type ExtendedButtonKind = ButtonKind | "tertiary";
Enter fullscreen mode Exit fullscreen mode

Now, there are a few caveats to consider with this rule.

For one, const enum is a compile-time only enum that doesn’t increase the size of your JS bundle. Unfortunately, const enum is a disabled keyword for certain build tools, like Babel or esbuild.

You should also consider that union types and enums are not syntactically the same. You can reference the value of an enum by its declaration, avoiding direct references to a string literal. If this behavior is desirable, look to regular JS objects instead. With a handy utility type from type-fest, you can achieve the same behavior.

const ButtonStyle = {
  PRIMARY: "primary",
  SECONDARY: "secondary",
} as const;

type ButtonStyleType = ValueOf<typeof ButtonStyle>;

Enter fullscreen mode Exit fullscreen mode

Extending Native HTML Elements

TypeScript ships with tons of helper types that cut down boilerplate for common React idioms. These types are particularly useful when extending native HTML elements like button or input, where you’ll want to maintain the component's original props to ensure extensibility.

Start by implementing a Button component in the two most important use-cases: clicking the button and defining its text. When typing everything manually, you get the following result:

import React from "react";

interface Props {
  children: React.ReactNode;
  onClick: () => void;
}

function Button({ children, onClick }: Props) {
  return <button onClick={onClick}>{children}</button>;
}

Enter fullscreen mode Exit fullscreen mode

The first helper type to use here is React.PropsWithChildren, which automatically adds the children prop to the component:

import React from "react";

type Props = React.PropsWithChildren<{
  onClick: () => void;
}>;

function Button({ children, onClick }: Props) {
  return <button onClick={onClick}>{children}</button>;
}

Enter fullscreen mode Exit fullscreen mode

Button is looking better, but the component still has to redefine props that are native to the HTML element, like onClick. This is a big problem for foundational components like Button that make up your application’s design system, as their props will grow wildly with their usage.

// This list tends to grow quickly!
type Props = React.PropsWithChildren<{
  onClick: () => void;
  type: "submit" | "button" | "reset" | undefined;
  disabled: boolean;
  // ...
}>;

Enter fullscreen mode Exit fullscreen mode

Luckily, TypeScript has another utility designed for this exact purpose.

ComponentPropsWithoutRef is a generic type that supplies props for built-in React handlers and native HTML attributes. By passing in "button" as the template, you specify that the component is extending the HTML button element.

import React from "react";

type Props = React.ComponentPropsWithoutRef<"button">;

function Button({ children, onClick, type }: Props) {
  return (
    <button onClick={onClick} type={type}>
      {children}
    </button>
  );
}

Enter fullscreen mode Exit fullscreen mode

The result is a component that is clean and extensible. If additional props are needed, swap the type for an interface:

import React from "react";

interface Props extends React.ComponentPropsWithoutRef<"button"> {
  specialProp: number;
}

function Button({ children, onClick, type, specialProp }: Props) {
  // ...
}

Enter fullscreen mode Exit fullscreen mode

Type Refinement and Disjoint Unions

Disjoint unions (or discriminated unions) are a powerful feature that can help you refine the component props of complex structures. In short, they allow your component to support multiple variants of a shared interface.

Consider a Button component that has several theme variations, such as "primary" and "secondary". You can express this type as a string and pass it down as props.

interface Props {
  kind: string;
}

function getStyles(kind: string) {
  switch (kind) {
    case "primary":
      return styles.primary;
    case "secondary":
      return styles.secondary;
    default:
      throw new Error("invalid button kind");
  }
}

function Button({ kind }: Props) {
  const styles = getStyles(kind);
  // ...
}

Enter fullscreen mode Exit fullscreen mode

While this implementation is simple, it presents significant problems.

For one, you can pass any string value into the component props, even though only "primary" and "secondary" are implemented by the component. TypeScript won't warn you that any other value throws an error.

// This passes compiler checks, yet throws an application error!
function App() {
  return <Button kind="not-a-style">click me!</Button>;
}

Enter fullscreen mode Exit fullscreen mode

You can instead switch string to a union type, which offers much-needed improvements. The union type informs TypeScript of all possible values for kind, preventing any unhandled cases.

type ButtonKind = "primary" | "secondary";

interface Props {
  kind: ButtonKind;
}

function getStyles(kind: ButtonKind) {
  switch (kind) {
    case "primary":
      return styles.primary;
    case "secondary":
      return styles.secondary;
    // Default case is no longer needed!
  }
}

function Button({ kind }: Props) {
  const styles = getStyles(kind);
  // ...
}

Enter fullscreen mode Exit fullscreen mode

Looking back at the component instance that is passed an invalid string literal, TypeScript now offers a helpful error:

// Error: Type '"not-a-style"' is not assignable to type 'ButtonKind'
function App() {
  return <Button kind="not-a-style">click me!</Button>;
}

Enter fullscreen mode Exit fullscreen mode

Union types are great at refining props for primitive values. But what about more complex structures?

Consider that the "primary" button requires a special method, specialPrimaryMethod, that is not supported by the “secondary” variant. The component calls this special method when handling a click.

type ButtonKind = "primary" | "secondary";

interface Props extends React.ComponentPropsWithoutRef<"button"> {
  kind: ButtonKind;
  specialPrimaryMethod?: () => void;
}

function Button({ kind, onClick, specialPrimaryMethod, children }: Props) {
  const handleClick = (e: React.MouseEvent<HTMLButtonElement>) => {
    if (kind === "primary") {
      if (specialPrimaryMethod) {
        specialPrimaryMethod();
      }
    } else {
      onClick?.(e);
    }
  };

  return <button onClick={handleClick}>{children}</button>;
}
Enter fullscreen mode Exit fullscreen mode

Although this component compiles, the type definition of props doesn’t inform the TypeScript compiler when specialPrimaryMethod is permitted. The TypeScript compiler thinks that both “primary” and “secondary” allow the method, and that the method is optional in either case.

To further demonstrate why this is problematic, take a look at the following instances of the component. The TypeScript compiler considers all of them valid, even though some of them conflict with the intended implementation.

// Correct use-case
<Button kind="primary" specialPrimaryMethod={doSpecial}>...

// Invalid use-case: specialPrimaryMethod shouldn't be optional
<Button kind="primary">...

// Invalid use-case: secondary shouldn't support specialPrimaryMethod
<Button kind="secondary" specialPrimaryMethod={doSpecial}>...

Enter fullscreen mode Exit fullscreen mode

This is where the disjoint union comes in handy. By splitting apart the interfaces for the “primary” variant and the “secondary” variant, you can achieve better compile-time type-checking.

type ButtonKind = "primary" | "secondary";

// Build separate interfaces for Primary & Secondary buttons
interface PrimaryButton {
  kind: "primary";
  specialPrimaryMethod: () => void;
}

interface SecondaryButton {
  kind: "secondary";
}

// Create a disjoint union
type Button = PrimaryButton | SecondaryButton;

// Add built-in HTML props to the disjoin union
type Props = React.ComponentPropsWithoutRef<"button"> & Button;

// You can no longer destructure props since specialPrimaryMethod
// doesn't always exist on the object.
function Button(props: Props) {
  const handleClick = (e: React.MouseEvent<HTMLButtonElement>) => {
    if (props.kind === "primary") {
      // No extra if check needed!
      props.specialPrimaryMethod();
    } else {
      props.onClick?.(e);
    }
  };

  return <button onClick={handleClick}>{props.children}</button>;
}

Enter fullscreen mode Exit fullscreen mode

The additional type refinement provided by the disjoint union now appropriately throws compile-time errors for invalid cases.

// All good!
<Button kind="primary" specialPrimaryMethod={() => {}}>foo</Button>

// Error: Property 'specialPrimaryMethod' is missing
<Button kind="primary">click me!</Button>

// Error: Type '{ ... specialPrimaryMethod: () => void; }' is not assignable
<Button kind="secondary" specialPrimaryMethod={() => {}}>click me!</Button>

Enter fullscreen mode Exit fullscreen mode

Don’t Overuse Disjoint Unions

Disjoint unions are a powerful technique that enable general-purpose components. However, take care not to overuse them as they can lead to highly complex components.

Anytime you reach for disjoint unions, pause and consider whether the single component should instead be separated into two.

Accessible Components With Polymorphism

Polymorphic components are great for tuning markup for accessibility.

Consider a Container component that applies some styles to a div. You may want to use this Container component in situations that are better described by HTML5 elements like aside or section.

Rather than duplicating the Container component for slight alterations in its JSX, create a polymorphic component. This is as simple as including a new prop, as, that accepts a union of HTML element strings.

import React from "react";

type Props = React.PropsWithChildren<{
  as: "div" | "section" | "aside";
}>;

function Container({ as: Component = "div", children }: Props) {
  return <Component className={styles.container}>{children}</Component>;
}

Enter fullscreen mode Exit fullscreen mode

The destructured alias, { as: Component }, is a convention that helps illustrate that the prop is a React component and not just a string.

The Container component now supports different HTML5 elements to better fit its use-case.

<Container as="section">
  <p>section content</p>
</Container>

Enter fullscreen mode Exit fullscreen mode

Watch Out for These Bad Practices

Using defaultProps

Defining defaultProps on function components is marked for deprecation. You should instead assign defaults with prop destructuring:

Prefer

interface Props {
  color?: string;
}

function Button({ color = "red" }: Props) {
  /* ... */
}

Enter fullscreen mode Exit fullscreen mode

Avoid

type Props = { color: string } & typeof defaultProps;

const defaultProps = { color: "red" };

function Button(props: Props) {
  /* ... */
}

Button.defaultProps = defaultProps;

Enter fullscreen mode Exit fullscreen mode

Using the Non-Null Assertion Operator

The non-null assertion operator subverts TypeScript’s ability to check null values. Despite how easy it is to type, this operator can cause a ton of harm. Let TypeScript do its job!

In this case, you should instead rely on refinement or narrowing and avoid overriding the TypeScript compiler.

Prefer

const liveSafely = (x?: number) => {
  if (typeof x !== 'number') {
    throw new Error('number expected')
  }
  return x.toFixed(2)
}}

Enter fullscreen mode Exit fullscreen mode

Avoid

const liveDangerously = (x?: number) => {
  return x!.toFixed(2);
};

Enter fullscreen mode Exit fullscreen mode

Raising Exceptions for Exhaustive Cases

There are few cases that call for raising an exception within a React component, since it renders a blank screen if handled improperly. You can avoid catastrophic failures with Error Boundaries, but in most cases throwing an exception is unnecessary.

Instead, default to the closest acceptable user interface.

Prefer

switch (props.kind) {
  case "primary":
  default:
    return { color: "red" };
}

Enter fullscreen mode Exit fullscreen mode

Avoid

switch (props.kind) {
  // ...
  default:
    throw new Error("unsupported option");
}

Enter fullscreen mode Exit fullscreen mode

At OneSignal, we love empowering developers to refine their workflow and build great software. We hope these patterns help evolve your React and TypeScript codebase.

>>We’re hiring! Check out our open positions and apply on our careers page: https://onesignal.com/careers>>

Discussion (0)