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 stringWith 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 --initOpen 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 any — any 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, 73genres-strings.txt:
Jazz, Improvisation is key
Blues, Emotional storytelling
Electronic, Synthesizers dominateRun:
npx ts-node src/index.ts analyze-numbers genres-numbers.txt
npx ts-node src/index.ts analyze-strings genres-strings.txtThe CLI reads files, parses values using a generic parser function, and processes data without any any type escapes.
Step 8: Comparison With Non-Generic Approach
If you'd written this tool without generics, you'd have two choices:
- Duplicate code: A
GenreManagerNumberclass and aGenreManagerStringclass with identical logic. - Use
any: A single class withanyvalues, 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.

Memuat komentar...