DEV Community

Cover image for TypeScript Narrowing.
Ashal Farhan
Ashal Farhan

Posted on • Updated on

TypeScript Narrowing.

In this post I'm going to show you how to use Discriminated Unions in TypeScript, and the realworld usecase. Discriminated Unions is really useful when we working with complex object structure.

Example

Imagine that we have a function that will call an ability of an animal. Bird will have the ability to fly, and Fish have the ability to swim. We will use a field called kind to determine which animal we're dealing with. Take a look at our first attempt to declare an interface of Animal.

interface Animal {
  kind: 'bird' | 'fish';
  fly?: () => void; // Optional, because fish cannot fly
  swim?: () => void; // Optional, because bird cannot swim
}
Enter fullscreen mode Exit fullscreen mode

If you're wondering the type of kind property, it's called String Literals, basically is a string but the value must be that exact string. This will prevent us from typo or misspelling issue.

We can write the callAbility function that will applies the logic to determining of calling the right ability between fish and bird.

function callAbility(animal: Animal) {
  if (animal.kind === 'bird') {
    animal.fly();
    // If you're using vscode, you will notice above code will have red squiggly lines that says:
    // `Cannot invoke an object which is possibly 'undefined'.`
  }
}
Enter fullscreen mode Exit fullscreen mode

It looks like TypeScript still doesn't know what todo there. At this point we feel like we know more about our values than the type checker does. To simplify this issue, we can just use non-null assertion (a ! after null or undefined property) to tell TypeScript that "Nope, this will never be undefined or null."

function callAbility(animal: Animal) {
  if (animal.kind === 'bird') {
    animal.fly!();
  }
}
Enter fullscreen mode Exit fullscreen mode

Note that non-null assertion must not oftenly be used since those assertions are error-prone. It would be better if we always check if something null or undefined.

So this still would be a problem, we need to somehow communicate with TypeScript to give a better idea of what animal looks like. Let's rewrite the Animal interface.

interface Bird {
  kind: 'bird';
  fly: () => void;
}

interface Fish {
  kind: 'fish';
  swim: () => void;
}

type Animal = Bird | Fish;
Enter fullscreen mode Exit fullscreen mode

Now we've separated the Animal out into two types with different values for the kind property and both methods now becomes required. If you check again the first callAbility function declaration, you will notice that there's no red squiggly line when we call the fly method.

function callAbility(animal: Animal) {
  if (animal.kind === 'bird') {
    animal.fly();
    // Hover over the animal inside the if block, and it should says:
    // `(parameter) animal: Bird`
  }
}
Enter fullscreen mode Exit fullscreen mode

Cool, Now the error is gone! When every type in a union contains a common property with literal types, TypeScript considers that to be a discriminated union, and can narrow out the members of the union.

In this case, kind was that common property (which is what’s considered a discriminant property of Animal). Checking whether the kind property was "bird" got rid of every type in Animal that didn’t have a kind property with the type "bird". That narrowed shape down to the type Bird.

Btw, you can try this out with the switch statement.

The keynote thing here is that Bird and Fish were really two separate types but both have specific kind field.

Realworld Examples

I'm going to give you two realworld example how to implement Discriminated Unions on your code.

Narrowing API Response

This example will implement Discriminated Unions in API Response with axios. Consider your backend have consistent shape of response, take a look at the following:

type SuccessResponse<T> = {
  data: T;
  success: true;
};

type ErrorResponse = {
  error: { message: string };
  success: false;
};

type BaseResponse<T> = SuccessResponse<T> | ErrorResponse;
Enter fullscreen mode Exit fullscreen mode

We create the generic BaseResponse type that will take a single type parameter to represent what the data looks like if it was successful. Now here's what we would implement the discriminated unions:

import { axios, BaseResponse } from 'somewhere';

type Todo = {
  id: string;
  name: string;
  done: boolean;
};

const getTodos = async () => {
  const { data: response } = await axios.get<BaseResponse<Todo[]>>('/todos');
  if (response.success) {
    // Do something with `response.data` in this block
  } else {
    // Handle error because the response is not success
    // Uncomment below code and you will notice a squiggly line when we access the `data` property
    // console.log(response.data);
    // `Property 'data' does not exist on type 'ErrorResponse'.`
  }
};
Enter fullscreen mode Exit fullscreen mode

I love using the above pattern because we can make sure that we only access the data if the response was successful.

Narrowing dispatch from useReducer

If you need to manage complex state but only in specific component, and the rest of the application does not need to know, you can use useReducer instead of useState or redux. Let's take a look at below example:

The source code of this example is in here

Create the todo.ts file that will handle all the logic of our todo list.

// todo.ts
import { nanoid } from "nanoid";

export type Todo = {
  done: boolean;
  name: string;
  id: string;
};

export type TodoState = {
  todos: Todo[];
};

// You can also extract each action into dedicated type
export type TodoAction =
  | {
      type: "addTodo";
      // Takes name of the todo
      payload: string;
    }
  | {
      type: "removeTodo";
      // Takes id of the todo
      payload: string;
    }
  | {
      type: "resetTodo";
    }
  | {
      type: "toggleTodo";
      // Takes id of the todo
      payload: string;
    };

export function todoReducer(state: TodoState, action: TodoAction): TodoState {
  switch (action.type) {
    case "addTodo":
      return {
        ...state,
        todos: [
          ...state.todos,
          { id: nanoid(), done: false, name: action.payload }
        ]
      };
    case "removeTodo":
      return {
        ...state,
        todos: [...state.todos.filter(val => val.id !== action.payload)]
      };
    case "toggleTodo":
      return {
        ...state,
        todos: [
          ...state.todos.map(val => ({
            ...val,
            done: val.id === action.payload ? !val.done : val.done
          }))
        ]
      };
    case "resetTodo":
      return {
        ...state,
        todos: []
      };
    default:
      // The default case can just return the state 
      // if we don't know the action type.
      // But we can also throw an error if we will,
      // so that we can make debugging easier 
      // if we pass incorrect action.
      return state;
  }
}
Enter fullscreen mode Exit fullscreen mode

Then create the CreateTodoForm.tsx component for creating a new todo

// CreateTodoForm.tsx
import { FormEvent, useState } from "react";

export function CreateTodoForm({
  onSubmit
}: {
  onSubmit: (name: string) => void;
}) {
  const [name, setName] = useState("");
  const handleSubmit = (e: FormEvent) => {
    e.preventDefault();
    onSubmit(name);
    setName("");
  };
  return (
    <form onSubmit={handleSubmit}>
      <label>
        <span>Name: </span>
        <input
          onChange={e => setName(e.target.value)}
          value={name}
          type="text"
        />
      </label>
      <button type="submit">Create</button>
    </form>
  );
}
Enter fullscreen mode Exit fullscreen mode

Now the App.tsx is where we call the useReducer hook

import { useReducer } from "react";
import "./styles.css";
import { todoReducer, TodoState } from "./todo";
import { CreateTodoForm } from "./CreateTodoForm";

const initialState: TodoState = {
  todos: []
};

export default function App() {
  const [state, dispatch] = useReducer(todoReducer, initialState);

  return (
    <div className="App">
      <h1>Todo List</h1>
      <CreateTodoForm
        onSubmit={name => dispatch({ type: "addTodo", payload: name })}
      />
      <ul>
        {state.todos.map(todo => (
          <li key={todo.id}>
            <span>{todo.name}</span>
            <input
              type="checkbox"
              checked={todo.done}
              onChange={() =>
                dispatch({ type: "toggleTodo", payload: todo.id })
              }
            />
            <button
              // Try to remove the property 'payload' below when calling dispatch
              // and you will get TypeScript error that says:
              // `Property 'payload' is missing in type '{ type: "removeTodo"; }'`
              onClick={() => dispatch({ type: "removeTodo", payload: todo.id })}
            >
              Remove
            </button>
          </li>
        ))}
      </ul>
      <button onClick={() => dispatch({ type: "resetTodo" })}>Reset</button>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

When we dispatch any action to our reducer, we will get the better hints of what the payload type of the specific action type should we passed, this will prevent us from passing wrong payload to an action.

If you revisit the todoReducer function, inside of each case of the action.type, the action.payload will be the correct type of what we declared in TodoAction type. So inside of the "resetTodo" action, the action object doesn't contain the payload property anymore, since we've already handle all of the possible case of action.type.

Thank you

I hope this is a useful knowledge to share, let me know if I missed something. This is my first time to write an article 😅.

References

Top comments (0)