adnenre
#TypeScript

TypeScript Generics

Generics allow developers to write reusable and type-safe code.

A full guide to mastering generics in TypeScript, from fundamentals to advanced patterns.


#1 - What is Generics ?

Generics are a fundamental feature in TypeScript (and many other statically typed languages) that allow you to write reusable, type‑safe code.

When you see <T> (or <T, U> etc.) placed right after a function name and before the parameter list, it means the function is generic. The angle brackets declare type parameters that can be used throughout the function’s signature and body.

#2 - What is that <T> ?

  • <T> appears right after the function name, before the parentheses. It tells TypeScript “this function is generic, with a type parameter called T.”

  • Inside the function, T is used to type the parameter value and the return type.

Example generic declaration

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

The <T> tells TypeScript: “This function has a type parameter T.”

The parameter value and the return type are both T, so the function preserves the exact type passed in.

#3 - What Generics solve ?

Without generics, you’re forced to choose between:

  • Using any – flexible but throws away all type information, making your code error‑prone.

  • Duplicating code – writing separate versions for each type, which is tedious and hard to maintain.

  • Overloading – acceptable for a few types, but doesn’t scale well.

Generics let you write a single, type‑safe piece of code that adapts to the types you use it with.

#4 - Rusable functions with Generics

Description: Here is a reusable functions that work with multiple types.

// ❌ Duplicate functions for each type
function identityNumber(value: number) {
  return value;
}
function identityString(value: string) {
  return value;
}

console.log(identityNumber(10));
// result: 10
console.log(identityString('hi'));
// result: "hi"

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

console.log(identity<number>(10));
// result: 10
console.log(identity<string>('hi'));
// result: "hi"
console.log(identity<boolean>(true));
// result: true

#5 - Generic Fetch Function

Description: Use generics to type REST API responses, avoiding repetitive fetch functions.

interface IUser {
  id: number;
  name: string;
}
interface IProduct {
  id: number;
  title: string;
}
interface IApiResponse<T> {
  data: T;
  status: number;
}

// ✅ Generic fetch function
async function fetchData<T>(url: string): Promise<IApiResponse<T>> {
  const res = await fetch(url);
  const data: T = await res.json();
  return { data, status: res.status };
}

// Real-world usage
const usersResponse = await fetchData<IUser[]>('/v1/api/users');
console.log(usersResponse.data);
// result: [{id:1,name:"Jhon"}, {id:2,name:"Sara"}]

const productsResponse = await fetchData<IProduct[]>('/v1/api/products');
console.log(productsResponse.data);
// result: [{id:1,title:"Laptop"}, {id:2,title:"Phone"}]

#6 - Generic Functions

Description: Reuse functions for different data types without repeating code.

// ✅ Generic function
function firstElement<T>(arr: T[]): T {
  return arr[0];
}

const firstUserItem = firstElement(usersResponse.data);
console.log(firstUserItem);
// result: {id:1,name:"Jhon"}

const firstProductItem = firstElement(productsResponse.data);
console.log(firstProductItem);
// result: {id:1,title:"Laptop"}

#7 - Type Inference with Generics

Description: TypeScript can infer types automatically from generic functions, reducing annotations.

const users = await fetchData<IUser[]>('/v1/api/users');
console.log(users.data[0].name);
// result: "Jhon"

#8 - Multiple Generic Types

Description: Create functions that can accept multiple types, useful for key-value pairs.

function pair<K, V>(key: K, value: V) {
  return { key, value };
}

const userPair = pair<string, IUser>('admin', users.data[0]);
console.log(userPair);
// result: {key:"admin", value:{id:1,name:"Jhon"}}

const productPair = pair<number, IProduct>(1, productsResponse.data[0]);
console.log(productPair);
// result: {key:1, value:{id:1,title:"Laptop"}}

#9 - Generic Constraints

Description: Limit generics to types with specific properties, like length.

function logLength<T extends { length: number }>(value: T) {
  console.log(value.length);
}

logLength(usersResponse.data);
// result: 2
logLength('Hello World');
// result: 11

#10 - Generic Classes

Description: Use generics in classes for reusable storage or container patterns.

class Storage<T> {
  private items: T[] = [];
  add(item: T) {
    this.items.push(item);
  }
  getAll(): T[] {
    return this.items;
  }
}

const userStorage = new Storage<IUser>();
userStorage.add(usersResponse.data[0]);
console.log(userStorage.getAll());
// result: [{id:1,name:"Jhon"}]

const productStorage = new Storage<IProduct>();
productStorage.add(productsResponse.data[0]);
console.log(productStorage.getAll());
// result: [{id:1,title:"Laptop"}]

#11 - Default Generic Types

Description: Provide default types for generics to simplify usage when a type is not specified.

interface IApiResponseDefault<T = string> {
  data: T;
  status: number;
}

const response1: IApiResponseDefault = { data: 'ok', status: 200 };
console.log(response1.data);
// result: "ok"

const response2: IApiResponseDefault<IUser> = { data: usersResponse.data[0], status: 200 };
console.log(response2.data);
// result: {id:1,name:"Jhon"}

#12 - Conditional Generics

Description: Use conditional types to select different types based on input types.

type IsString<T> = T extends string ? true : false;
type A = IsString<string>; // true
type B = IsString<IUser>; // false

#13 - Mapped & Utility Types

Description: Modify existing types or create new types dynamically using mapped and utility types.

type PartialUser = Partial<IUser>;
type ReadonlyUser = Readonly<IUser>;
type UserMap = Record<string, IUser>;

const userMap: UserMap = {
  u1: usersResponse.data[0],
  u2: usersResponse.data[1],
};
console.log(userMap);
// result: {u1:{id:1,name:"Jhon"}, u2:{id:2,name:"Sara"}}

#14 - Recursive & Deep Generics

Description: Define recursive types, useful for trees or nested structures.

interface ITree<T> {
  value: T;
  children?: ITree<T>[];
}

const userTree: ITree<IUser> = {
  value: usersResponse.data[0],
  children: [{ value: usersResponse.data[1] }],
};

console.log(userTree);

// result: {value:{id:1,name:"Jhon"}, children:[{value:{id:2,name:"Sara"}}]}

#15 - Repository Pattern

Description: Create reusable repository classes to manage collections of items.

class Repository<T> {
  private items: T[] = [];
  add(item: T) {
    this.items.push(item);
  }
  getAll(): T[] {
    return this.items;
  }
}

const userRepo = new Repository<IUser>();
userRepo.add(usersResponse.data[0]);
console.log(userRepo.getAll());
// result: [{id:1,name:"Jhon"}]

#16 - Generic Cache

Description: Implement a generic cache class to store and retrieve any type safely.

class Cache<T> {
  private store = new Map<string, T>();
  set(key: string, value: T) {
    this.store.set(key, value);
  }
  get(key: string): T | undefined {
    return this.store.get(key);
  }
}

const userCache = new Cache<IUser>();
userCache.set('user1', usersResponse.data[0]);
console.log(userCache.get('user1'));
// result: {id:1,name:"Jhon"}

#17 - Real REST API Examples (Production-ready Singleton + Resource Wrappers)

Description: Perform typed REST API calls using a singleton generic REST client and resource-specific API wrappers, making endpoints clean, type-safe, and maintainable.

// --- Types ---
interface IUser {
  id: number;
  name: string;
}
interface IProduct {
  id: number;
  title: string;
}

interface IApiResponse<T> {
  data: T;
  status: number;
}

// --- Generic REST Client ---
class RestClient {
  constructor(private baseUrl: string) {}

  // Helper to build full URL
  private buildUrl(endpoint: string) {
    return `${this.baseUrl}${endpoint}`;
  }

  async get<T>(endpoint: string): Promise<IApiResponse<T>> {
    const res = await fetch(this.buildUrl(endpoint));
    const data: T = await res.json();
    return { data, status: res.status };
  }

  async post<U, T>(endpoint: string, body: U): Promise<IApiResponse<T>> {
    const res = await fetch(this.buildUrl(endpoint), {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(body),
    });
    const data: T = await res.json();
    return { data, status: res.status };
  }

  async put<U, T>(endpoint: string, body: U): Promise<IApiResponse<T>> {
    const res = await fetch(this.buildUrl(endpoint), {
      method: 'PUT',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(body),
    });
    const data: T = await res.json();
    return { data, status: res.status };
  }

  async delete<T>(endpoint: string): Promise<IApiResponse<T>> {
    const res = await fetch(this.buildUrl(endpoint), { method: 'DELETE' });
    const data: T = await res.json();
    return { data, status: res.status };
  }
}

// --- Singleton Client ---
// ✅ baseUrl could come from a config or .env file, e.g. process.env.API_BASE_URL
export const apiClient = new RestClient(
  process.env.API_BASE_URL || 'https://api.example.com/v1/api'
);

// --- User API Wrapper ---
export class UserApi {
  constructor(private client = apiClient) {}
  getAll() {
    return this.client.get<IUser[]>('/users');
  }
  create(user: Omit<IUser, 'id'>) {
    return this.client.post<Omit<IUser, 'id'>, IUser>('/users', user);
  }
  update(user: IUser) {
    return this.client.put<IUser, IUser>(`/users/${user.id}`, user);
  }
  delete(id: number) {
    return this.client.delete<{ message: string }>(`/users/${id}`);
  }
}

// --- Product API Wrapper ---
export class ProductApi {
  constructor(private client = apiClient) {}
  getAll() {
    return this.client.get<IProduct[]>('/products');
  }
  create(product: Omit<IProduct, 'id'>) {
    return this.client.post<Omit<IProduct, 'id'>, IProduct>('/products', product);
  }
  update(product: IProduct) {
    return this.client.put<IProduct, IProduct>(`/products/${product.id}`, product);
  }
  delete(id: number) {
    return this.client.delete<{ message: string }>(`/products/${id}`);
  }
}

// --- Instantiate APIs ---
const usersApi = new UserApi();
const productsApi = new ProductApi();

// --- GET ---
const allUsers = await usersApi.getAll();
console.log(allUsers.data);
// result: [{id:1,name:"Jhon"}, {id:2,name:"Sara"}]

const allProducts = await productsApi.getAll();
console.log(allProducts.data);
// result: [{id:1,title:"Laptop"}, {id:2,title:"Phone"}]

// --- POST ---
const newUser = await usersApi.create({ name: 'Jhon' });
console.log(newUser.data);
// result: {id:3,name:"Jhon"}

const newProduct = await productsApi.create({ title: 'Tablet' });
console.log(newProduct.data);
// result: {id:3,title:"Tablet"}

// --- PUT ---
const updatedUser = await usersApi.update({ id: 3, name: 'Jhon Updated' });
console.log(updatedUser.data);
// result: {id:3,name:"Jhon Updated"}

const updatedProduct = await productsApi.update({ id: 3, title: 'Tablet Pro' });
console.log(updatedProduct.data);
// result: {id:3,title:"Tablet Pro"}

// --- DELETE ---
const deleteUser = await usersApi.delete(3);
console.log(deleteUser.data);
// result: { message: "User deleted successfully" }

const deleteProduct = await productsApi.delete(3);
console.log(deleteProduct.data);
// result: { message: "Product deleted successfully" }

#18 - Best Practices

Description: Guidelines for writing clean, reusable, and safe generic code.

function mapArray<TItem, UResult>(arr: TItem[], fn: (item: TItem) => UResult): UResult[] {
  return arr.map(fn);
}

function logLength<T extends { length: number }>(value: T) {
  console.log(value.length);
}

type Partial<T> = { [K in keyof T]?: T[K] };

#19 - Summary

Description: Generics make TypeScript code reusable, type-safe, and maintainable, especially for REST API and data handling.

  • ❌ Without generics → repetitive, verbose, error-prone
  • ✅ With generics → reusable, type-safe, maintainable

Examples included: fetch, storage, cache, repository, arrays, trees.

Share this post