Understanding Generic Types in TypeScript

Understanding Generic Types in TypeScript

Learn how to use TypeScript generics with practical examples like paginated data, reusable React components, and Next.js page components.

TypeScript is a powerhouse when it comes to building scalable, maintainable applications. One of its standout features is the ability to define generic types, a tool that provides flexibility and reusability across various scenarios. In this blog, we’ll explore the concept of generics using some practical examples that illustrate their power and utility.


What Are Generics?

Generics allow you to define components, functions, or types that can work with a variety of data types while maintaining type safety. Instead of hardcoding a specific type, generics let you use placeholders (like T, Data, or Props) to represent types, which are then replaced with actual types at runtime.

In essence, generics provide type parameters that make your code adaptable and reusable.


Example 1: Creating a Paginated Type

Pagination is a common feature in applications. A generic type can help define a consistent shape for paginated responses, regardless of the data type being paginated. Here's how you can use generics for this purpose:

type Paginated<Data> = {
  data: Data[]
  page: number
  take: number
  total: number
}

Breaking It Down:

  • Data is the generic type placeholder that represents the type of the paginated data.
  • data: Data[] ensures that the data property is always an array of the specified type.
  • Other properties like page, take, and total are standard fields in a paginated response.

Example Usage:

Imagine you’re paginating a list of users and posts. With the Paginated type, you can handle both scenarios without duplicating code:

type User = {
  id: number
  name: string
}

type Post = {
  id: number
  title: string
  content: string
}

// Paginated users
const paginatedUsers: Paginated<User> = {
  data: [
    { id: 1, name: "Alice" },
    { id: 2, name: "Bob" }
  ],
  page: 1,
  take: 10,
  total: 50
}

// Paginated posts
const paginatedPosts: Paginated<Post> = {
  data: [
    { id: 1, title: "Intro to TypeScript", content: "TypeScript is awesome!" },
    { id: 2, title: "Generics 101", content: "Learn how to use generics." }
  ],
  page: 2,
  take: 5,
  total: 20
}

By using generics, the Paginated type adapts seamlessly to different data types, maintaining strict type safety.


Example 2: A Parent Component Type for React

In React, many components accept children as props. You can define a reusable type that ensures type safety for both the component's props and its children:

type ParentComponent<Props = object> = Props & {
  children: React.ReactNode
}

Breaking It Down:

  • Props = object defines a generic type with a default value (object). If no specific type is provided, Props defaults to object.
  • The & operator merges Props with { children: React.ReactNode }, ensuring that any component using this type can handle additional props and children.

Example Usage:

Here’s how you can use ParentComponent in a real-world scenario:

type CardProps = {
  title: string
}

const Card: React.FC<ParentComponent<CardProps>> = ({
  title, children
}) => 
  <div className="card">
    <h2>{title}</h2>
    <div>{children}</div>
  </div>

// Using the Card component
<Card title="Welcome">
  <p>This is a reusable card component with generics.</p>
</Card>

This type ensures that:

  1. The title prop is required for the Card component.
  2. The children prop is always valid and type-safe.

Example 3: Next.js Page Component with Context

Neneric types can make page components more robust by defining the structure of context and search parameters. Here’s a generic type to manage them:

type PageComponent<Context = object> = {
  params: Promise<Context>
  searchParams: Promise<Record<string, string | undefined>>
}

Promises are required after version 15 in Next.js

Breaking It Down:

  • Context = object allows developers to specify the shape of the context object for the page.
  • params: Promise<Context> ensures that params will always resolve to a value of type Context.
  • searchParams: Promise<Record<string, string | undefined>> handles query parameters, mapping keys to strings or undefined.

Example Usage:

Suppose you’re building a page that displays a blog post list based on its category slug. You can define a page component type like this:

type CategoryContext = {
  category: string
}

// For the URL /blogs/software?sort=desc&filter=popular
const CategoryPage: React.FC<PageComponent<CategoryContext>> = async ({ 
  params, searchParams
}) => {
  const resolvedParams = await params
  const resolvedSearchParams = await searchParams

  console.log(resolvedParams) // { category: "typescript-generics" }
  console.log(resolvedSearchParams) // { sort: "desc", filter: "popular" }

  return <div>
    <h1>Slug: {resolvedParams.slug}</h1>
    <p>Sort: {resolvedSearchParams.sort}</p>
  </div>
}

This ensures type safety when accessing the params and searchParams, reducing runtime errors and improving code readability.


Why Use Generics?

  • Reusability: Generic types can be applied to various data structures or components, reducing redundancy.
  • Type Safety: They provide stricter type checking, catching potential errors during development.
  • Flexibility: Generics allow you to handle diverse use cases without sacrificing maintainability.

Conclusion

Generics are a fundamental part of TypeScript that make your code more reusable, flexible, and maintainable. Whether you're handling paginated data, building reusable React components, or managing complex contexts in Next.js, generics can simplify and strengthen your codebase.

The examples above highlight the versatility of generics in TypeScript. By embracing this feature, you can write cleaner, safer, and more adaptable code that scales with your application’s needs.