A deep dive into TypeScript generics and how they can make your code more reusable and type-safe. TypeScript 5.8 beta notes are available on the official Microsoft dev blog.

Benefits of TypeScript

1. Static Typing

TypeScript provides static type checking, which helps catch type-related errors during development rather than at runtime. It improves code quality and reduces bugs, especially in large codebases.

2. Improved Code Readability and Maintainability

Types serve as documentation for your code, making it easier to understand. Explicit types help developers understand data structures and APIs, improving collaboration in teams.

3. Enhanced IDE Support

TypeScript enhances code editors (e.g., VSCode) with features like:

  • IntelliSense (auto-completion)
  • Type inference
  • Code navigation (go-to-definition, find references)
  • Refactoring tools

4. Better Refactoring

With TypeScript's type system, refactoring becomes safer because the compiler can point out areas affected by changes. Large-scale refactors are easier since type errors are immediately visible.

5. Early Error Detection

TypeScript detects common errors (e.g., misspelled variables, incorrect function calls) during development, reducing runtime exceptions and leading to more robust applications.

6. Supports Modern JavaScript Features

TypeScript supports ESNext features (e.g., async/await, modules, destructuring) and compiles them to older JavaScript versions, allowing developers to use the latest JavaScript features while ensuring compatibility with older environments.

7. Type Inference

TypeScript can infer types based on context, reducing the need for explicit type annotations while still providing type safety.

8. Enhanced Object-Oriented Programming

TypeScript supports advanced OOP concepts like interfaces, abstract classes, access modifiers (public, private, protected), and generics — helping enforce design patterns and principles in complex applications.

9. Better Integration with Libraries

TypeScript offers a vast collection of type definitions for third-party JavaScript libraries (@types). Even if a library is written in plain JavaScript, you can use its types to get strong typing and IDE support.

10. Scalable Codebases

As codebases grow, maintaining plain JavaScript becomes harder. TypeScript's type system, modularization features, and tooling help in managing large projects effectively.

11. Community and Ecosystem

Strong community support and widespread adoption by major frameworks (React, Angular, Vue, etc.). Many modern frameworks and libraries come with built-in TypeScript support or type definitions.

12. Interoperability with JavaScript

TypeScript is a superset of JavaScript, meaning any valid JavaScript is valid TypeScript. You can gradually adopt TypeScript in an existing JavaScript project without rewriting everything.

13. Improved Debugging Experience

TypeScript can catch errors before running the code, making debugging faster. The generated JavaScript code is often cleaner and easier to debug.

14. Optional Static Typing

While TypeScript enforces types, it also allows dynamic typing, giving flexibility where strict types are unnecessary.

Use Cases

  • Enterprise Applications: TypeScript's type safety and maintainability make it ideal for large, complex applications.
  • Open-Source Libraries: Many popular libraries use TypeScript for better developer experience and reliability.
  • Cross-Platform Apps: TypeScript is used in frameworks like React Native, Angular, and Ionic to build cross-platform applications.

Key Concepts in Type Manipulation

1. Mapped Types

Mapped types create new types by transforming properties of an existing type.

type User = {
  id: number;
  name: string;
  isAdmin: boolean;
};

// Create a type with all properties optional
type PartialUser = {
  [K in keyof User]?: User[K];
};

Explanation:

  • keyof User extracts the keys of User ('id' | 'name' | 'isAdmin').
  • [K in keyof User] iterates over each key.
  • User[K] represents the type of each key.

Built-in Utility Example:

type PartialUser = Partial<User>; // Built-in mapped type

2. Keyof Operator

keyof extracts the keys of a type as a union of string literals.

type User = {
  id: number;
  name: string;
};

type UserKeys = keyof User; // 'id' | 'name'

This is useful when you want to create dynamic property access or enforce strict key checks.

3. Conditional Types

Conditional types allow defining types based on conditions.

type IsString<T> = T extends string ? true : false;

type A = IsString<string>; // true
type B = IsString<number>; // false

Conditional types are useful for creating type-level logic, such as filtering types or enforcing constraints.

4. Infer Keyword

infer is used within conditional types to extract a part of a type.

type ReturnType<T> = T extends (...args: any[]) => infer R ? R : never;

type MyFunction = () => number;
type Result = ReturnType<MyFunction>; // number

Here, infer R extracts the return type of a function.

5. Utility Types

TypeScript provides built-in utility types that simplify common type manipulations.

  • Partial<T>: Makes all properties of T optional.
  • Required<T>: Makes all properties of T required.
  • Readonly<T>: Makes all properties of T read-only.
  • Pick<T, K>: Picks a subset of properties from T.
  • Omit<T, K>: Omits a subset of properties from T.
type User = {
  id: number;
  name: string;
  email: string;
};

type UserWithoutEmail = Omit<User, "email">;

6. Template Literal Types

You can create string literal types using template literals.

type Status = "loading" | "success" | "error";
type StatusMessage = `Status is ${Status}`;

const message: StatusMessage = "Status is success";

7. Recursive Types

Recursive types are types that reference themselves, useful for defining complex structures like trees or nested arrays.

type NestedArray<T> = T | NestedArray<T>[];

const example: NestedArray<number> = [1, [2, [3, 4]], 5];

Practical Example: Deep Partial

Let's create a utility type DeepPartial<T> that makes all properties and nested properties optional.

type DeepPartial<T> = {
  [K in keyof T]?: T[K] extends object ? DeepPartial<T[K]> : T[K];
};

type User = {
  id: number;
  profile: {
    name: string;
    address: {
      street: string;
      city: string;
    };
  };
};

type PartialUser = DeepPartial<User>;

Use Cases of Type Manipulation

  • Dynamic API Responses: Manipulate API response types based on specific conditions (e.g., partial updates).
  • Form Builders: Create types for form fields dynamically based on the data model.
  • Data Validation: Define strict validation types using conditional types and inference.

Best Practices for Adding Types in a Next.js Project

In a Next.js project, TypeScript types can be applied at various levels, such as pages, API routes, components, props, hooks, and more.

1. Adding Types to Page Components

// pages/index.tsx
import { NextPage } from "next";

type HomeProps = {
  title: string;
};

const Home: NextPage<HomeProps> = ({ title }) => {
  return <h1>{title}</h1>;
};

export default Home;

Best Practice: Use NextPage<Props> for page components, define Props as a separate type, and skip the generic argument for pages without props.

2. Adding Types to getStaticProps and getServerSideProps

// pages/blog.tsx
import { GetStaticProps, NextPage } from "next";

type BlogPost = {
  id: number;
  title: string;
};

type BlogProps = {
  posts: BlogPost[];
};

export const getStaticProps: GetStaticProps<BlogProps> = async () => {
  const posts: BlogPost[] = await fetch("https://api.example.com/posts").then(
    (res) => res.json()
  );
  return { props: { posts } };
};

const Blog: NextPage<BlogProps> = ({ posts }) => (
  <div>
    {posts.map((post) => (
      <h2 key={post.id}>{post.title}</h2>
    ))}
  </div>
);

export default Blog;

Best Practice: Use GetStaticProps<T> or GetServerSideProps<T> to ensure type safety for returned props.

3. Adding Types to API Routes

// pages/api/hello.ts
import { NextApiRequest, NextApiResponse } from "next";

type Data = {
  message: string;
};

export default function handler(
  req: NextApiRequest,
  res: NextApiResponse<Data>
) {
  res.status(200).json({ message: "Hello World" });
}

Best Practice: Use NextApiRequest for request typing and NextApiResponse<T> where T is the response data type.

4. Adding Types to Custom Hooks

// hooks/useUser.ts
import { useState } from "react";

type User = {
  id: number;
  name: string;
};

export function useUser() {
  const [user, setUser] = useState<User | null>(null);

  return { user, setUser };
}

Best Practice: Always type the state and return values explicitly, and use generics when the hook needs to be reusable.

5. Adding Types to Components and Props

// components/Button.tsx
import React from "react";

type ButtonProps = {
  label: string;
  onClick: () => void;
  disabled?: boolean;
};

const Button: React.FC<ButtonProps> = ({ label, onClick, disabled }) => (
  <button onClick={onClick} disabled={disabled}>
    {label}
  </button>
);

export default Button;

Best Practice: Use React.FC<Props> for functional components and define optional props using ?.

6. Adding Types to Context

// context/AuthContext.tsx
import React, { createContext, useContext, useState, ReactNode } from "react";

type AuthContextType = {
  user: string | null;
  login: (username: string) => void;
};

const AuthContext = createContext<AuthContextType | undefined>(undefined);

export const AuthProvider = ({ children }: { children: ReactNode }) => {
  const [user, setUser] = useState<string | null>(null);

  const login = (username: string) => {
    setUser(username);
  };

  return (
    <AuthContext.Provider value={{ user, login }}>
      {children}
    </AuthContext.Provider>
  );
};

export const useAuth = () => {
  const context = useContext(AuthContext);
  if (!context) throw new Error("useAuth must be used within an AuthProvider");
  return context;
};

Best Practice: Type both the context value and the children prop, and guard useContext to avoid runtime errors.

7. Using Utility Types for Props

type User = {
  id: number;
  name: string;
  email: string;
};

// Pick only id and name for a component
type UserCardProps = Pick<User, "id" | "name">;

const UserCard: React.FC<UserCardProps> = ({ id, name }) => (
  <div>
    <p>ID: {id}</p>
    <p>Name: {name}</p>
  </div>
);

8. Adding Types for External Libraries

// types/custom-lib.d.ts
declare module "custom-lib" {
  export function customFunction(param: string): boolean;
}

9. Adding Types to Configuration Files

// next.config.js
/** @type {import('next').NextConfig} */
const nextConfig = {
  reactStrictMode: true,
  swcMinify: true,
};

module.exports = nextConfig;

General Best Practices

  • Enable Strict Mode — turn on strict in tsconfig.json to enforce strict type checking.
  • Use interface vs type appropriatelyinterface for object shapes that might be extended, type for unions and utility types.
  • Leverage Generics — for reusable, type-safe components, hooks, and functions.
  • Avoid any — prefer unknown when the type is not yet known.
{
  "compilerOptions": {
    "strict": true,
    "noImplicitAny": true,
    "strictNullChecks": true
  }
}

TypeScript Generics and Utility Types

TypeScript provides powerful features like Generics and Utility Types to create flexible, reusable, and type-safe code.

Generics

Generics allow you to define reusable code with type parameters, making your code flexible and type-safe.

function identity<T>(value: T): T {
  return value;
}

console.log(identity<string>("Hello")); // Output: Hello
console.log(identity<number>(42));      // Output: 42

Generic Functions

function getArray<T>(items: T[]): T[] {
  return new Array<T>().concat(items);
}

let numArray = getArray<number>([1, 2, 3]);
let strArray = getArray<string>(["A", "B", "C"]);

Generic Interfaces

interface KeyValuePair<K, V> {
  key: K;
  value: V;
}

let obj: KeyValuePair<string, number> = { key: "Age", value: 30 };

Generic Classes

class Box<T> {
  private _value: T;

  constructor(value: T) {
    this._value = value;
  }

  getValue(): T {
    return this._value;
  }
}

let numberBox = new Box<number>(100);
console.log(numberBox.getValue()); // Output: 100

Generic Constraints

interface Lengthwise {
  length: number;
}

function logLength<T extends Lengthwise>(item: T): void {
  console.log(item.length);
}

logLength("Hello");   // Valid
logLength([1, 2, 3]); // Valid
// logLength(10);      // ❌ Error: number does not have length

Record Utility Type

The Record type allows you to create an object type with specific keys and values.

Record<Keys, Type>
  • Keys: A union of keys that the object will have.
  • Type: The type of the values corresponding to those keys.
type RolePermissions = Record<"admin" | "editor" | "viewer", boolean>;

const permissions: RolePermissions = {
  admin: true,
  editor: false,
  viewer: true,
};

Other Utility Types

1. Partial<T> — makes all properties optional:

type User = { name: string; age: number };
const partialUser: Partial<User> = { name: "Alice" };

2. Readonly<T> — makes all properties read-only:

type User = { name: string; age: number };
const readonlyUser: Readonly<User> = { name: "Alice", age: 25 };
// readonlyUser.name = "Bob"; // ❌ Error

3. Pick<T, Keys> — creates a type by picking specific keys from T:

type User = { name: string; age: number; email: string };

type UserPreview = Pick<User, "name" | "email">;
const userPreview: UserPreview = { name: "Alice", email: "alice@example.com" };

4. Omit<T, Keys> — creates a type by omitting specific keys from T:

type User = { name: string; age: number; email: string };

type WithoutEmail = Omit<User, "email">;
const user: WithoutEmail = { name: "Alice", age: 25 };

5. Required<T> — makes all properties required:

type User = { name?: string; age?: number };
const user: Required<User> = { name: "Alice", age: 25 };

6. ReturnType<T> — extracts the return type of a function:

function getUser() {
  return { name: "Alice", age: 25 };
}

type User = ReturnType<typeof getUser>; // { name: string; age: number }

7. Exclude<T, U> — excludes types from T that are assignable to U:

type Status = "active" | "inactive" | "archived";
type ActiveStatus = Exclude<Status, "archived">;

8. Extract<T, U> — extracts types from T that are assignable to U:

type Status = "active" | "inactive" | "archived";
type OnlyArchived = Extract<Status, "archived">;

9. NonNullable<T> — removes null and undefined from T:

type User = string | null | undefined;
type NonNullUser = NonNullable<User>; // string

10. Parameters<T> — gets the parameter types of a function type as a tuple:

type Func = (name: string, age: number) => void;
type Params = Parameters<Func>; // [string, number]

Generics and utility types make TypeScript a robust and flexible language for building scalable applications. By mastering these tools, you can create reusable, type-safe, and clean code.

Difference Between any, unknown, and never

These are three special types that serve very different purposes:

Type Meaning Usage Safety Level
any Can be anything; disables type checking. Used when you don't care about type safety. Unsafe ❌
unknown Can be anything, but must be type-checked before use. Used when dealing with uncertain types. Safer than any
never Represents values that never occur. Functions that never return, or exhaustive checks. Strictest ✅✅

1️⃣ any (No Type Safety)

The any type allows anything and effectively disables TypeScript's type checking.

let value: any;

value = 42;          // ✅ Allowed
value = "hello";     // ✅ Allowed
value = true;        // ✅ Allowed

// You can do anything with `any`, but it's dangerous
value.toUpperCase(); // ✅ No error (even if value is a number)

Drawbacks: no compile-time safety, can lead to unexpected runtime errors — TypeScript does not check what you're doing with any.

2️⃣ unknown (Safer Alternative to any)

The unknown type accepts any value, but unlike any, you must type-check it before using it.

let value: unknown;

value = 42;          // ✅ Allowed
value = "hello";     // ✅ Allowed

// ❌ Error: TypeScript doesn't know the actual type
// value.toUpperCase();

// ✅ Must type-check first
if (typeof value === "string") {
  console.log(value.toUpperCase()); // ✅ Safe
}

Using unknown prevents unsafe operations, encourages type-checking before use, and ensures better type safety in applications.

3️⃣ never (Impossible Values)

The never type is used for values that never occur. It usually appears in functions that throw errors or enter infinite loops.

function throwError(message: string): never {
  throw new Error(message);
}

function infiniteLoop(): never {
  while (true) {
    console.log("Running forever...");
  }
}

Exhaustive type checking in a switch statement:

type Shape = "circle" | "square";

function getShape(shape: Shape): string {
  switch (shape) {
    case "circle":
      return "It's a circle!";
    case "square":
      return "It's a square!";
    default:
      const _exhaustiveCheck: never = shape; // Ensures all cases are handled
      throw new Error(`Unexpected shape: ${_exhaustiveCheck}`);
  }
}

If a new shape is added ("triangle"), TypeScript will force you to update this function.

Key Differences Summarized

Feature any unknown never
Accepts any value? ✅ Yes ✅ Yes ❌ No
Type-check required? ❌ No ✅ Yes ✅ N/A
Safe to use? ❌ No ✅ Yes ✅ Yes
Used in function return types? ✅ Yes ✅ Yes ✅ Yes, but only when the function never returns

When should you use each?

  • ✅ Use any when you don't care about type safety (e.g., third-party libraries).
  • ✅ Use unknown when you receive unknown data (e.g., API responses) but want type safety.
  • ✅ Use never for functions that never return, or for exhaustive type checking.