Skip to main content

TS Ep 29: Introduction to Generics — Type Parameters ()

Rachmat Hidayat
Author
Rachmat Hidayat
Learn & sharing insights on TypeScript, Go, Kubernetes, DevOps, DevSecOps, SRE, Platform Engineering, AI/ML Engineering, and MLOps.
typescript - This article is part of a series.
Part 29: This Article
If standard functions allow you to pass values as parameters at runtime, Generics allow you to pass types as parameters at compile time. Generics make functions, interfaces, and classes fully reusable without sacrificing strict type safety or resorting to any.

1. The Core Problem: Reusability vs Type Safety
#

Imagine you want to build an identity function that returns whatever value is passed into it.

Attempt 1: Separate Overloaded Functions (No Code Reuse)
#

function identityString(val: string): string { return val; }
function identityNumber(val: number): number { return val; }

Attempt 2: Using any (Type Erasure & Zero Safety)
#

function identityAny(val: any): any { return val; }

// 'result' is typed as 'any', destroying type safety downstream!
const result = identityAny("Hello World");
result.nonExistentMethod(); // ❌ Runtime Error! No compiler protection.

2. The Solution: Type Parameters (<T>)
#

Generics introduce Type Parameters inside angle brackets <T> placed before the function parameter list.

A Type Parameter acts as a variable that captures the type supplied by the caller (or inferred from the argument), allowing you to reuse that exact type signature for return values or internal logic:

// 'T' captures the type of 'val'
function identity<T>(val: T): T {
  return val;
}

// 1. Explicit Generic Invocation (Passing <string> manually):
const strResult = identity<string>("Hello TypeScript"); // Return Type: string

// 2. Implicit Generic Inference (TypeScript automatically infers T = number):
const numResult = identity(42); // Return Type: number

// 🟢 Type safety is 100% preserved!
console.log(strResult.toUpperCase()); 
// ❌ Compiler Error: Property 'toUpperCase' does not exist on type 'number'.
// numResult.toUpperCase(); 

3. Generic Interfaces & Type Aliases
#

Generics are not limited to functions. You can create generic data contracts for API responses, state wrappers, or storage containers.

Generic Interface Example (ApiResponse<T>)
#

interface ApiResponse<TData> {
  status: number;
  message: string;
  timestamp: number;
  data: TData; // Type parameter passed from caller!
}

interface UserProfile {
  id: string;
  name: string;
  email: string;
}

interface ProductItem {
  sku: string;
  price: number;
}

// 🟢 Reusing ApiResponse for User data:
const userResponse: ApiResponse<UserProfile> = {
  status: 200,
  message: "Success",
  timestamp: Date.now(),
  data: { id: "usr_101", name: "Alice", email: "[email protected]" },
};

// 🟢 Reusing ApiResponse for Product data:
const productResponse: ApiResponse<ProductItem> = {
  status: 200,
  message: "Success",
  timestamp: Date.now(),
  data: { sku: "LAPTOP_PRO", price: 1299.99 },
};

console.log(userResponse.data.email.toLowerCase());
console.log(productResponse.data.price.toFixed(2));

Generic Type Alias Example (Result<T, E>)
#

Generics can accept multiple type parameters:

// Algebraic Result pattern (Success or Failure)
type Result<TData, TError = Error> =
  | { success: true; data: TData }
  | { success: false; error: TError };

function parseInteger(input: string): Result<number, string> {
  const parsed = parseInt(input, 10);
  if (isNaN(parsed)) {
    return { success: false, error: `Failed to parse '${input}' as integer.` };
  }
  return { success: true, data: parsed };
}

const res = parseInteger("100");
if (res.success) {
  console.log(`Parsed Number: ${res.data.toFixed(0)}`);
} else {
  console.error(`Error: ${res.error}`);
}

4. Generic Classes
#

Generics can also be applied to class definitions, allowing data structure containers (like Queues, Stacks, or Caches) to handle any data type while preserving instance type safety:

class DataQueue<TElement> {
  private elements: TElement[] = [];

  public enqueue(item: TElement): void {
    this.elements.push(item);
  }

  public dequeue(): TElement | undefined {
    return this.elements.shift();
  }

  public get size(): number {
    return this.elements.length;
  }
}

// 1. Create a queue strictly for numbers
const numberQueue = new DataQueue<number>();
numberQueue.enqueue(10);
numberQueue.enqueue(20);
const firstNum = numberQueue.dequeue(); // Inferred Type: number | undefined

// ❌ Compiler Error: Argument of type 'string' is not assignable to parameter of type 'number'.
// numberQueue.enqueue("hello");

// 2. Create a queue strictly for string tuples
const commandQueue = new DataQueue<[cmd: string, payload: string]>();
commandQueue.enqueue(["SAVE", "file.txt"]);

5. Naming Conventions for Type Parameters
#

While <T> (Type) is the standard default name for single generic parameters, using descriptive names is recommended when a signature contains multiple parameters or complex domain logic:

Short NameDescriptive NameCommon Convention
TTItem, TDataPrimary Type Parameter
U, VTOutput, TResultSecondary / Output Type Parameters
ETErrorException / Error Type Parameter
KTKeyObject Key Type Parameter
VTValueObject Value Type Parameter

Summary & Next Steps
#

In this episode:

  • We defined Generics as compile-time type parameters (<T>).
  • We proved how generics eliminate repetitive functions and replace unsafe any types.
  • We constructed generic interfaces (ApiResponse<TData>), generic type aliases (Result<T, E>), and generic classes (DataQueue<T>).
  • We explored type parameter inference and naming conventions.

In Episode 30: Generic Constraints (extends), we will learn how to restrict type parameters using the extends keyword (<T extends HasID>)!

typescript - This article is part of a series.
Part 29: This Article