A Faster Approach to Enum Props in React

Create quick interfaces for your React components through powerful TypeScript definitions
TypeScript
React
Enums
Practices
Picture of John Wright Stanly, author of this blog article
John Wright Stanly
Jan 10, 2022

-

-

Enums are one of the few features TypeScript adds to JavaScript beyond type checking. Combined with React, enums are extremely useful in designing component interfaces.

image

Let's say we're designing a text component in React. We'll want different types of text, so we'll add a prop to our component called type. Only certain types of text will be supported. In traditional JavaScript, we might have worked with with magic strings or frozen objects. But now with TypeScript, this is a perfect use case for enums. Let's define our enum below.

export enum TextType {
  h1,
  h2,
  h3,
  h4,
  h5,
  h6,
  body,
  bodySmall,
}

We can now implement TextType in our component's interface, TextProps.

interface TextProps {
  type: TextType;
  children: React.ReactNode;
}

export function Text(props: TextProps) {
  return (
    <span
      style={{
        fontSize: fontSizeMap[props.type],
        fontWeight: fontWeightMap[props.type],
      }}
    >
      {props.children}
    </span>
  );
}

Now let's implement another component that uses <Text>.

image

This works fine, and is perfectly good practice. Yet, it feels overkill. Having to manually spell out (and import) the enum value and pass it in as a prop is a nightmare. Especially when the sprit of enums is to provide simplified and self documenting code.

image

Converting enums to mapped types

Given that enums are just a tool to enumerate over a small set of members, we can use this to build smarter component interfaces.

Instead of having our component have a prop that inputs a TextType, we can add the definition of TextType itself to our component's interface. TypeScript has powerful operators that enable us to create type definitions that accomplish this.

First we'll create an object type that's indexed by our TextType enum members.

type TextOptions<T = boolean> = { [key in keyof typeof TextType]: T };

There's a lot going on here, so let's explain how this type works. First let's explain TypeScript's typeof and keyof operators.

The typeof operator already exists in JavaScript, but only for expressions, like for evaluating console.log(typeof 5) // "number". TypeScript adds functionality to infer types, like for extracting properties from an object. For example, typeof {hello: 'world', foo: 'bar'} would return the type {hello: string; foo: string}. This is good news, because TypeScript compiles enums to objects at runtime. Therefore, the typeof operator treats enums the same as objects.

The keyof operator, unique to TypeScript, returns a union of literal types of the properties of its input. Literal types are specific subsets of collective types. For example, 'hello' is a string literal. Union types are just combinations of existing types using the | operator. So keyof enables us to convert an object's list of properties into a set of usable strings. For example, keyof {hello: 'world', foo: 'bar'} returns the type 'hello' | 'foo'.

However, if you run keyof TextType, you get 'toFixed' | 'toExponential' | 'toPrecision' | .... This is the union literal of a number, not TextType. By combining keyof with typeof, we can get a union literal of enum members, not the underlying type. So keyof typeof TextType equals 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6' | 'body' | 'bodySmall'. Noticeably, this will always return an enum's keys not values. If you defined an enum with initialized values, like enum TextType {h1 = 'headerOne', h2 = 'headerTwo', ...}, then keyof typeof will still return 'h1' | 'h2' | ....

We can now place this union literal type inside a mapped type with the in operator. This enables us to declare an object that only allows properties with keys in the union literal. Note that you can name these with any identifier, but we'll stick to naming them key for simplicity.

Lastly, TextOptions contains a generic. Since this is a dictonary, there's both keys and values, each with their own types. The key is already typed from our union literal, but we haven't given the values a type yet. We'll leave this to a generic, so future use cases can determine the type. As a backup though, we'll make booleans the default type via <T = boolean>. Choosing boolean will simplify things once we return to React.

Using enum defined object types

Now with our TextOptions type, we can begin to chip away at our <Text> component's verboseness. Rather than having a type prop, we can now "flatten" our types into the interface itself.

interface TextProps extends Partial<TextOptions> {
  children: React.ReactNode;
}
// {children: React.ReactNode; h1?: boolean; h2?: boolean; h3?: boolean; h4?: boolean; h5?: boolean; h6?: boolean; body?: boolean; bodySmall?: boolean; }

React enables implicit boolean props, so h1={true} is equivalent to h1. The latter is much easier to write, so we'll "flatten" our enum as boolean props for our component's interface. Since we defined TextOptions to default to boolean values, we're spared from manually specifying the generic as TextOptions<boolean>. Shorter code all around!

Since we only want one type rather than all of them, we'll use TypeScript's Partial utility type which converts all properties to optional.

Our implementation now looks tons faster and developer friendly! It also removes the import for TextType.

export default function Example() {
  return (
    <div>
      {/* Old: <Text type={TextType.h1}>Not so neat!</Text> */}
      <Text h1>So neat!</Text>
    </div>
  );
}

However, there's two problems still left to solve. First, we have to implement the logic to find which prop was actually used. Since there's no direct props.type to read, finding the chosen type is a bit more involved. Second, we need to enforce that only one enum is chosen. Right now, because we defined all the enum members as optional, we can include any amount of them instead of one, such as zero, two, five, so forth.

1. Determining the chosen enum

Instead of reading a prop like props.type, we'll have to hunt for the type chosen ourselves. We can find the type and assign it to a variable textType by iterating through all the prop keys until we find a key that is an enum member.

Iterating through prop and enum keys is an efficiency concern though. Although props and enums are usually both small in size, it should still be addressed. Therefore, we run this in a React callback. We'll add props to our dependency array. That way, if re-renders occur for other reasons, such as a state change, this function will not re-execute. Since we always need textType, we'll run the callback as an IIFE, invoking it immediately after definition with () appended at the end.

const textType = React.useCallback(
  () => Object.keys(props)
    .find(prop => Object.keys(TextType).includes(prop)),
  [props],
)();

Now with the type known, we can now start customizing our component. Although styles could be implemented with syntax like switches or ifs, objects are best for this because of their ability to have type safety. We can refer back to our TextOptions type to enforce styling. Now, when you change the TextType enum later on, say to add h7, you'll be notified that your <Text> component doesn't style the new member via a TypeScript compile error.

const fontSizeMap: TextOptions<number> = {
  h1: 48,
  h2: 42,
  h3: 36,
  h4: 32,
  h5: 28,
  h6: 24,
  body: 14,
  bodySmall: 12,
};

export function Text(props: TextProps) {
  const textType = React.useCallback(
    () => Object.keys(props).find(prop => Object.keys(TextType).includes(prop)),
    [props],
  )();

  return (
    <span
      style={{
        fontSize: fontSizeMap[textType],
      }}
    >
      {props.children}
    </span>
  );
}
2. Enforcing only one enum

We can create a utility type to enable us to pick only one of the properties of a type. This RequireOnlyOne implementation was written by a StackOverflow user here. Using this, we can require that one—and only one—TextType is used as a prop.

type RequireOnlyOne<T, Keys extends keyof T = keyof T> = Pick<
  T,
  Exclude<keyof T, Keys>
> &
  {
    [K in Keys]-?: Required<Pick<T, K>> &
      Partial<Record<Exclude<Keys, K>, undefined>>;
  }[Keys];

However, the following code below does not work. According to TS rule 2312, "An interface can only extend an object type or intersection of object types with statically known members."

interface TextProps extends RequireOnlyOne<TextOptions> {
  children: React.ReactNode;
};

Therefore, we'll redefine TextProps as a type rather than an interface. Usually TypeScript interfaces are used for React props since inheritance is typically useful, but here we need to create an intersection type. Intersection types are another way to combine types, but intersection types use the & operator and require all the properties from all types. Think of union and intersection types as boolean OR and AND operations (hence the shared operators).

Because you cannot extend a union type using an interface, you must use type with an intersection. So we'll reconstruct TextProps as below. A helpful analogy between interfaces and types is that interfaces = inheritance while types = composition.

type TextProps = RequireOnlyOne<TextOptions> & {
  children: React.ReactNode;
};

Conclusion

Congrats! You now have a component that uses enums as its prop interface.

image

You could argue that this implementation is also overkill. It requires a lot of overhead to work. It's less performant than a traditional approach due to there not being a central source of truth for the chosen type. It's also might be less intuitive to other developers first learning about your component.

But this is the beauty of React. If you believe that the development speed gained from this technique outweighs its cons, then by all means use it. As a React developer, you are left free to implement your own practices.

import React from 'react';

export enum TextType {
  h1,
  h2,
  h3,
  h4,
  h5,
  h6,
  body,
  bodySmall,
}

type TextOptions<T = boolean> = { [key in keyof typeof TextType]: T };

const fontSizeMap: TextOptions<number> = {
  h1: 48,
  h2: 42,
  h3: 36,
  h4: 32,
  h5: 28,
  h6: 24,
  body: 14,
  bodySmall: 12,
};

type RequireOnlyOne<T, Keys extends keyof T = keyof T> = Pick<
  T,
  Exclude<keyof T, Keys>
> &
  {
    [K in Keys]-?: Required<Pick<T, K>> &
      Partial<Record<Exclude<Keys, K>, undefined>>;
  }[Keys];

type TextProps = RequireOnlyOne<TextOptions> & {
  children: React.ReactNode;
};

export function Text(props: TextProps) {
  const textType = React.useCallback(
    () => Object.keys(props).find(prop => Object.keys(TextType).includes(prop)),
    [props],
  )();

  return (
    <span
      style={{
        fontSize: fontSizeMap[textType],
      }}
    >
      {props.children}
    </span>
  );
}

Comments

Be the first to add a comment!

Add Comment

Post