Before You Begin

  • Node.js 18+ installed
  • TypeScript 5.x installed globally (npm install -g typescript)
  • A terminal or command prompt
  • Basic familiarity with TypeScript syntax (interfaces, functions, types)

Step 1: Why Generics Over Union Types?

You've used union types to accept multiple types: string | number. But that approach forces you to check types at runtime and loses the original type information. Compare this:

// Without generics — you lose the original type
function getGenreName(genre: string | number): string | number {
  return genre; // return type is always union, not the specific input
}

const result = getGenreName("Jazz"); // result is string | number, not string

With generics, the return type matches the input type exactly:

// With generics — type is preserved
function getGenreName<T>(genre: T): T {
  return genre;
}

const result = getGenreName("Jazz"); // result is "Jazz" (literal type, not just string)

This is the first comparison: union types discard specificity; generics preserve it. We'll use this principle to build a CLI tool that analyzes music genre data.

Step 2: Project Setup

Create a new directory and initialize a TypeScript project:

mkdir genre-analyzer-cli
cd genre-analyzer-cli
npm init -y
npm install typescript @types/node ts-node
npx tsc --init

Open tsconfig.json and set "strict": true. Create src/index.ts. This will be our CLI entry point.

Step 3: Generic Function for Genre Search

Let's build a function that searches an array of genre-related items by a key. Instead of writing separate functions for string[] and number[], we write one generic function.

// src/index.ts

type GenreRecord<T> = {
  name: string;
  value: T;
};

function findGenre<T>(records: GenreRecord<T>[], searchName: string): GenreRecord<T> | undefined {
  return records.find(record => record.name === searchName);
}

const genreCounts: GenreRecord<number>[] = [
  { name: "Jazz", value: 42 },
  { name: "Blues", value: 18 },
  { name: "Electronic", value: 73 }
];

const genreDescriptions: GenreRecord<string>[] = [
  { name: "Jazz", value: "A genre that loves improvisation" },
  { name: "Blues", value: "Rooted in African American history" }
];

console.log(findGenre(genreCounts, "Jazz")); // { name: 'Jazz', value: 42 }
console.log(findGenre(genreDescriptions, "Blues")); // { name: 'Blues', value: 'Rooted in African American history' }

Run with npx ts-node src/index.ts. One function handles both number and string values without type casting.

Step 4: Constraining Generics with Extends

metimes you need to restrict what types can be used. For example, we want to calculate an average only for numeric genre data. Use extends to constrain the generic:

function averageGenreValue<T extends number>(records: GenreRecord<T>[]): number {
  if (records.length === 0) return 0;
  const sum = records.reduce((acc, record) => acc + record.value, 0);
  return sum / records.length;
}

console.log(averageGenreValue(genreCounts)); // Works: (42+18+73)/3 = 44.33
// console.log(averageGenreValue(genreDescriptions)); // Error: type 'string' does not satisfy constraint 'number'

The extends keyword acts like a filter. Compare this to using anyany would allow strings and crash at runtime. Generics with constraints catch errors at compile time.

Step 5: Generic Classes for the CLI Tool

Now we'll build a CLI that processes different genre data types. Create a class that manages a collection of genres, but the value type is generic:

class GenreManager<T> {
  private items: GenreRecord<T>[] = [];

  add(record: GenreRecord<T>): void {
    this.items.push(record);
  }

  getAll(): GenreRecord<T>[] {
    return this.items;
  }

  map<U>(fn: (record: GenreRecord<T>) => U): U[] {
    return this.items.map(fn);
  }
}

// Usage in CLI
const stringManager = new GenreManager<string>();
stringManager.add({ name: "Reggae", value: "Slower tempo" });
stringManager.add({ name: "Ska", value: "Fast brass sections" });

const numberManager = new GenreManager<number>();
numberManager.add({ name: "Reggae", value: 1968 });
numberManager.add({ name: "Ska", value: 1959 });

// map returns an array of whatever type the callback returns
const years = numberManager.map(record => record.value + 10); // number[]
const descriptions = stringManager.map(record => record.value.toUpperCase()); // string[]

console.log(years); // [1978, 1969]
console.log(descriptions); // ['SLOWER TEMPO', 'FAST BRASS SECTIONS']

Notice the map method has its own generic <U>. This is called a generic method — it introduces a new type variable independent of the class's <T>.

Step 6: Conditional Types and Infer

Let's add a feature that extracts the value type from a GenreRecord. This is where conditional types shine:

type ExtractValueType<T> = T extends GenreRecord<infer V> ? V : never;

type TestType = ExtractValueType<GenreRecord<boolean>>; // boolean
type TestType2 = ExtractValueType<string>; // never

// Utility for our CLI: convert GenreRecord to just the value type
function unwrapRecord<T>(record: GenreRecord<T>): T {
  return record.value;
}

console.log(unwrapRecord({ name: "Funk", value: true })); // true (boolean)

Conditional types act like ternary operators for types. The infer keyword lets you capture a type from a pattern. Compare this to function overloading — overloading requires manual declarations for each case; conditional types derive the type automatically.

Step 7: Full CLI Implementation

Now combine everything into a working CLI with command-line arguments:

// src/index.ts (final)

import * as fs from 'fs';
import * as path from 'path';

// --- Generic utilities ---

type GenreRecord<T> = {
  name: string;
  value: T;
};

class GenreManager<T> {
  private items: GenreRecord<T>[] = [];

  add(record: GenreRecord<T>): void {
    this.items.push(record);
  }

  getAll(): GenreRecord<T>[] {
    return this.items;
  }

  map<U>(fn: (record: GenreRecord<T>) => U): U[] {
    return this.items.map(fn);
  }

  filterByValue(predicate: (value: T) => boolean): GenreRecord<T>[] {
    return this.items.filter(record => predicate(record.value));
  }
}

// --- CLI logic ---

function readData<T>(filePath: string, parser: (raw: string) => T): GenreManager<T> {
  const content = fs.readFileSync(filePath, 'utf-8');
  const lines = content.split('\n').filter(line => line.trim() !== '');
  const manager = new GenreManager<T>();
  for (const line of lines) {
    const [name, valueRaw] = line.split(',');
    const value = parser(valueRaw.trim());
    manager.add({ name: name.trim(), value });
  }
  return manager;
}

// --- Entry point ---

const args = process.argv.slice(2);
const command = args[0];

if (command === 'analyze-numbers') {
  const filePath = args[1] || 'genres-numbers.txt';
  const manager = readData<number>(filePath, (raw) => parseFloat(raw));
  const all = manager.getAll();
  console.log('All records:', all);
  const filtered = manager.filterByValue(v => v > 50);
  console.log('Values > 50:', filtered);
} else if (command === 'analyze-strings') {
  const filePath = args[1] || 'genres-strings.txt';
  const manager = readData<string>(filePath, (raw) => raw);
  const upperDescriptions = manager.map(r => r.value.toUpperCase());
  console.log('Uppercased descriptions:', upperDescriptions);
} else {
  console.log('Usage: npx ts-node src/index.ts <command> [file]');
  console.log('Commands: analyze-numbers, analyze-strings');
}

Create sample data files:

genres-numbers.txt:

Jazz, 42
Blues, 18
Electronic, 73

genres-strings.txt:

Jazz, Improvisation is key
Blues, Emotional storytelling
Electronic, Synthesizers dominate

Run:

npx ts-node src/index.ts analyze-numbers genres-numbers.txt
npx ts-node src/index.ts analyze-strings genres-strings.txt

The CLI reads files, parses values using a generic parser function, and processes data without any any type escapes.

Sponsored Deal

Step 8: Comparison With Non-Generic Approach

If you'd written this tool without generics, you'd have two choices:

  1. Duplicate code: A GenreManagerNumber class and a GenreManagerString class with identical logic.
  2. Use any: A single class with any values, losing type safety and requiring runtime checks.

Both approaches increase maintenance cost. Generics give you the best of both: one implementation with full type safety.

Common Issues

Issue 1: Generic type inference failure When calling readData<number>, TypeScript might infer the wrong type if the parser function returns something else. Always explicitly annotate the generic call if the parser is complex.

Issue 2: Cannot use generic types in static members Static properties cannot reference the class's generic type parameter. Move static logic to standalone functions or use a different pattern.

Issue 3: Conditional type never matches If ExtractValueType returns never, double-check that your input type actually matches the pattern. Use // @ts-expect-error comments to test edge cases.

Summary

By comparing generic approaches with union types and any, you've seen how generics preserve type information, reduce code duplication, and catch errors at compile time. The CLI tool demonstrates practical application: one GenreManager class handles both numeric and string data, with custom filter and map methods that adapt to the stored type.