-
-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.
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>
.
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.
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.
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.
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>
);
}
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;
};
Congrats! You now have a component that uses enums as its prop interface.
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>
);
}