DEV Community

Cover image for Typescript Generics
Prasanth M
Prasanth M

Posted on

Typescript Generics

Here In this article, I wanted to explain on how typescript generics works for a react component with an example. Before going to that, lets understand what exactly is Generics.

What are Generics ?

Generics are a way to write reusable code that works with multiple types, rather than just one specific type.

This means that instead of writing separate functions or components for each type we want to work with, we can write a single generic function or component that can handle any type we specify when we use it.

For example, let's say you want to write a function that returns the first element of an array. You could write a separate function for each type of array you might encounter, like an array of numbers, an array of strings, and so on. But with generics, you can write a single function that works with any type of array.

Generics are indicated using angle brackets (< >) and a placeholder name for the type. For example, you might define a generic function like this:

function firstItem<T>(arr: T[]): T {
  return arr[0];
}
Enter fullscreen mode Exit fullscreen mode

The <T> in the function signature indicates that this is a generic function, and T is the placeholder name for the type. Now you can use this function with any type of array:

const numbers = [1, 2, 3];
const strings = ['a', 'b', 'c'];

console.log(firstItem(numbers)); // 1
console.log(firstItem(strings)); // 'a'
Enter fullscreen mode Exit fullscreen mode

This is just a simple example, but generics can be used in much more complex scenarios to write highly flexible and reusable code.

How can we use it for react components ?

Now let's look at the code below.

interface Animal<T extends string> {
  species: T
  name: string
  // Define other properties as needed
}

// Define custom types that extend the Animal type
interface Cat extends Animal<'cat'> {
  color: string
}

interface Dog extends Animal<'dog'> {
  breed: string
}

Enter fullscreen mode Exit fullscreen mode

The first thing we see is the definition of the Animal interface, which is a generic type that extends multiple custom types. The T type parameter is used to specify the species of the animal, which can be either 'cat' or 'dog'. The Cat and Dog interfaces are custom types that extend the Animal type and add additional properties like color and breed.

// Define the state type as an object with a "data" property of type Animal or null
interface State<T extends Animal<string> | null> {
  data: T
}


// Define the action types for useReducer
type Action<T extends Animal<string>> =
  | { type: 'SET_DATA'; payload: T }
  | { type: 'CLEAR_DATA' }
  | { type: 'CHANGE_SPECIES'; payload: string }

// Define the reducer function for useReducer
function reducer<T extends Animal<string>>(state: State<T>, action: Action<T>): State<T> {
  switch (action.type) {
    case 'SET_DATA':
      return { ...state, data: action.payload }
    case 'CLEAR_DATA':
      return { ...state, data: null }
    case 'CHANGE_SPECIES':
      if (state.data) {
        return {
          ...state,
          data: { ...state.data, species: action.payload },
        }
      }
      return state
    default:
      throw new Error(`Unhandled action : ${action}`)
  }
}

Enter fullscreen mode Exit fullscreen mode

Next, we see the definition of the State interface, which is also a generic type that accepts any type that extends the Animal type or null. This interface defines an object with a single property data, which can be either of the generic type or null.

After that, we define the Action type, which is also a generic type that accepts any type that extends the Animal type. This type specifies the different actions that can be dispatched by the reducer function. In this case, there are three possible actions: SET_DATA, CLEAR_DATA, and CHANGE_SPECIES.

The reducer function itself is also a generic function that accepts two parameters: the state object of type State<T> and the action object of type Action<T>. The T type parameter is used to specify the generic type that is passed to the State and Action interfaces. The reducer function is responsible for handling the different actions and updating the state accordingly.


// Create a context for the state and dispatch functions to be passed down to child components
interface AnimalContextType<T extends Animal<string> | null> {
  state: State<T>
  dispatch: React.Dispatch<Action<T>>
}

const AnimalContext = createContext<AnimalContextType<Cat | Dog | null>>({
  state: { data: null },
  dispatch: () => {},
})

interface AnimalProviderProps {
  children: React.ReactNode
}

// Define a component that fetches data from an API and updates the state
function AnimalProvider({ children }: AnimalProviderProps) {
  const [state, dispatch] = useReducer(reducer, { data: null } as State<Cat | Dog | null>)

  useEffect(() => {
    // Fetch data from the API and update the state
    // You'll need to replace the URL with the actual API endpoint
    fetch('https://example.com/api/animal')
      .then((response) => response.json())
      .then((data: Cat | Dog) => {
        // Update the state with the fetched data
        dispatch({ type: 'SET_DATA', payload: data })
      })
      .catch((error) => {
        console.error(error)
      })
  }, [])

  return (
    <AnimalContext.Provider value={{ state, dispatch }}>
      {/* Render child components */}
      {children}
    </AnimalContext.Provider>
  )
}

Enter fullscreen mode Exit fullscreen mode

The AnimalContextType interface is another generic interface that specifies the shape of the context object. It accepts any type that extends the Animal type or null. This interface defines two properties: state and dispatch, which are used to manage the state and dispatch actions.

The AnimalProvider component is a regular React component that wraps the AnimalComponent component and provides access to the context object. It uses the useReducer hook to manage the state and the useEffect hook to fetch data from an API and update the state accordingly.

function AnimalComponent<T extends Animal<string>>() {
  const { state, dispatch } = useContext(AnimalContext)

  const handleChangeSpecies = (event: React.ChangeEvent<HTMLSelectElement>) => {
    dispatch({ type: 'CHANGE_SPECIES', payload: event.target.value })
  }

  return (
    <div>
      {state.data && (
        <div>
          <h2>{state.data.name}</h2>
          <p>Species: {state.data.species}</p>
          {state.data.species === 'cat' && <p>Color: {(state.data as Cat).color}</p>}
          {state.data.species === 'dog' && <p>Breed: {(state.data as Dog).breed}</p>}
          <select value={state.data.species} onChange={handleChangeSpecies}>
            <option value='cat'>Cat</option>
            <option value='dog'>Dog</option>
          </select>
        </div>
      )}
    </div>
  )
}

Enter fullscreen mode Exit fullscreen mode

Finally, the AnimalComponent component is also a generic component that accepts any type that extends the Animal type. It uses the useContext hook to access the context object and render the state data. It also provides a dropdown menu to allow the user to change the species of the animal and dispatch the CHANGE_SPECIES action.

In summary, what I demonstrated is how TypeScript generics can be used to write reusable code that can work with different types. By using generics, we can write a single component that can handle any type of animal, rather than writing separate components for each species.

Full code below:


import React, { createContext, useReducer, useEffect, useContext } from 'react'

// Define the Animal type as a generic type that extends multiple custom types

interface Animal<T extends string> {
  species: T
  name: string
  // Define other properties as needed
}

// Define custom types that extend the Animal type
interface Cat extends Animal<'cat'> {
  color: string
}

interface Dog extends Animal<'dog'> {
  breed: string
}

// Define the state type as an object with a "data" property of type Animal or null
interface State<T extends Animal<string> | null> {
  data: T
}

// Define the action types for useReducer
type Action<T extends Animal<string>> =
  | { type: 'SET_DATA'; payload: T }
  | { type: 'CLEAR_DATA' }
  | { type: 'CHANGE_SPECIES'; payload: string }

// Define the reducer function for useReducer
function reducer<T extends Animal<string>>(state: State<T>, action: Action<T>): State<T> {
  switch (action.type) {
    case 'SET_DATA':
      return { ...state, data: action.payload }
    case 'CLEAR_DATA':
      return { ...state, data: null }
    case 'CHANGE_SPECIES':
      if (state.data) {
        return {
          ...state,
          data: { ...state.data, species: action.payload },
        }
      }
      return state
    default:
      throw new Error(`Unhandled action : ${action}`)
  }
}

// Create a context for the state and dispatch functions to be passed down to child components
interface AnimalContextType<T extends Animal<string> | null> {
  state: State<T>
  dispatch: React.Dispatch<Action<T>>
}

const AnimalContext = createContext<AnimalContextType<Cat | Dog | null>>({
  state: { data: null },
  dispatch: () => {},
})

interface AnimalProviderProps {
  children: React.ReactNode
}

// Define a component that fetches data from an API and updates the state
function AnimalProvider({ children }: AnimalProviderProps) {
  const [state, dispatch] = useReducer(reducer, { data: null } as State<Cat | Dog | null>)

  useEffect(() => {
    // Fetch data from the API and update the state
    // You'll need to replace the URL with the actual API endpoint
    fetch('https://example.com/api/animal')
      .then((response) => response.json())
      .then((data: Cat | Dog) => {
        // Update the state with the fetched data
        dispatch({ type: 'SET_DATA', payload: data })
      })
      .catch((error) => {
        console.error(error)
      })
  }, [])

  return (
    <AnimalContext.Provider value={{ state, dispatch }}>
      {/* Render child components */}
      {children}
    </AnimalContext.Provider>
  )
}

function AnimalComponent<T extends Animal<string>>() {
  const { state, dispatch } = useContext(AnimalContext)

  const handleChangeSpecies = (event: React.ChangeEvent<HTMLSelectElement>) => {
    dispatch({ type: 'CHANGE_SPECIES', payload: event.target.value })
  }

  return (
    <div>
      {state.data && (
        <div>
          <h2>{state.data.name}</h2>
          <p>Species: {state.data.species}</p>
          {state.data.species === 'cat' && <p>Color: {(state.data as Cat).color}</p>}
          {state.data.species === 'dog' && <p>Breed: {(state.data as Dog).breed}</p>}
          <select value={state.data.species} onChange={handleChangeSpecies}>
            <option value='cat'>Cat</option>
            <option value='dog'>Dog</option>
          </select>
        </div>
      )}
    </div>
  )
}

// Finally, use the AnimalProvider component to wrap child components and provide access to the state and dispatch functions

function App() {
  return (
    <AnimalProvider>
      <AnimalComponent />
    </AnimalProvider>
  )
}

Enter fullscreen mode Exit fullscreen mode

Top comments (0)