Lisa Tassone

Replacing Switch statements with a component map

I regularly encounter data models that encompass data structures that are similar, but not the same. I primarily program in TypeScript and often think about types before I implement any code. In order to compose the types for these structures, I'll often create a base type and then individual types for each of the other variants like so:

type BaseQuestion = {
  id: string
  title: string
  description?: string
}

type OpenQuestion = BaseQuestion & {
  type: "open"
}

type SelectQuestion = BaseQuestion & {
  type: "select"
  options: string[]
}

type RatingQuestion = BaseQuestion & {
  type: "rating"
  options: number[]
}

You may wonder why I haven't included the type key on BaseQuestion since all questions in this case, have a type. The reason for this is because I want to take advantage of the type narrowing that occurs from a discriminated union.

Each question type has its own React component that handles the behaviour and logic for that question type.

We want a type that encompasses all our question types, so we create a discriminated union type called Question:

type Question = OpenQuestion | SelectQuestion | RatingQuestion

Now when we are defining data for a question, depending on what type it is, TypeScript will be able to narrow down the other properties allowed for that type.

const question: Question = {
  type: "open",
  // will error for options
  options: [],
}

Say we have a QuestionsRenderer component that takes a list of questions we fetch from the server and maps through them rendering the appropriate component:

// QuestionsRenderer.tsx
<div>{fetchedQuestions.map(Question)}</div>

...

const Question = ({question}: { question: Question }) => {
  switch(question.type) {
    case 'open': return <OpenQuestion key={...} {...question}/>
    case 'select': return <SelectQuestion key={...} {...question}/>
    case 'rating': return <RatingQuestion key={...} {...question}/>
  } question satisfies never
}

This is great for a small set of question types, but it can become tedious when you have many.

Note: I have used question satisfies never to ensure we are covering all question types and because the error is more succinct than the TypeScript rule of forcing all code paths.

Creating a component map

Another approach is to create a component map where you map a component to it's type. Then our Question component can use that to lookup and return the component:

// questionComponents.ts
const componentMap = {
  open: OpenQuestion,
  select: SelectQuestion,
  rating: RatingQuestion,
}

// question.tsx
import { componentMap } from "./questionComponents.ts"
const Question = (props: Question) => {
  const Component = componentMap[props.type]

  if (!Component) {
    return "hmmm we don't know how to answer that question"
  }

  return <Component {...question} />
}

There's a problem here though. TypeScript will error on <Component ...> because property type has conflicting types in some constituents. Classic TypeScript error. The error is essentially saying, TypeScript doesn't know how to narrow down what Component could be. It sees the Component signature as:

const Component:
  | ((props: OpenQuestion) => React.JSX.Element)
  | ((props: SelectQuestion) => React.JSX.Element)
  | ((props: RatingQuestion) => React.JSX.Element)

instead of the individual component we've looked up. This makes sense, because the value is a runtime one that TypeScript can't possibly know about and for the purposes of this component, we don't care about.

So we can tell TypeScript what it will be by casting it:

import { componentMap } from "./questionComponents.ts"
const Question = (props: Question) => {
  const Component = componentMap[
    props.type
  ] as React.FunctionComponent<Question>

  if (!Component) {
    return "hmmm we don't know how to answer that question"
  }

  return <Component {...question} />
}

An improvement to this solution would be to make the component map type-safe since there's nothing stopping us from accidentally putting the OpenQuestion component for the key rating.

// questionComponents.ts
const componentMap = {
  open: OpenQuestion,
  select: SelectQuestion,
  rating: OpenQuestion, // All good! YOLO
}

Typing the component map

Let's start by creating a union of our question types which we build from our Question type:

type QuestionTypes = Question["type"]

And a QuestionComponentType that all our question components can use:

type QuestionComponentType<Q extends Question> = React.FunctionComponent<Q>

Creating this type (even though its just a FunctionComponent signature at the moment), allows us to add additional props that we want all components to account for in the future as our app evolves.

Now we have the pieces to create the type for the component map with the help of the Extract utility type.

type QuestionComponentMap = {
  [Key in QuestionTypes]: QuestionComponentType<
    Extract<Question, { type: Key }>
  >
}

Here we are using a mapped object type, whereby we iterate over the values in the QuestionTypes union, make that our key and make our value of type QuestionComponentType restricted to the Question that matches the type key.

Now we can annotate our componentMap variable with this type and ensure that not only are we have a component for each question type, but that it accepts that question as its props!

const componentMap: QuestionComponentMap = {
  open: OpenQuestion,
  select: SelectQuestion,
  rating: RatingQuestion,
}

Are component maps better?

There's nothing wrong with using Switches! Component maps are another way to do the same thing if you prefer that abstraction. One could argue that the component map adds complexity and just moves the lines of the switch statement into an object format.

I've used this abstraction in the past to improve readability especially when the Switch cases have had more logic to them and ran over many lines.