Complete Guide | Lesson 8: Design Patterns → Lesson 9: Generics → Lesson 10: Normalization (May 28)
You've written the same sorting function three times: once for integers, once for strings, once for dates. Each one is identical except for the type. You copied and pasted because you didn't know a better way. Then the sorting logic had a bug, and you fixed it in one copy and forgot the other two.
There's a better way. There's always been a better way.
This is Lesson 9, and it maps to Netzach on the Tree of Life. Netzach means "Victory" or "Endurance." It's the force that sustains and repeats without exhaustion. Generics are code that endures because they work with any type. Write once, use with integers, strings, customers, invoices, or any type that hasn't been invented yet. The code doesn't care. It just works.
This is also the lesson where we break the language-agnostic barrier. For the first time in this series, you'll see the same concepts expressed in real syntax: C#, Java, and TypeScript side by side. Because generics prove the thesis of this entire series: the concept is universal. Only the notation changes.
The DRY Principle
DRY stands for Don't Repeat Yourself. Every piece of knowledge in a system should have a single, authoritative, unambiguous representation. When you duplicate code, you duplicate bugs. When you duplicate logic, you duplicate maintenance.
The progression from beginner to professional looks like this:
Level 1: Copy-paste. Need the same logic? Copy it. Now you have two copies. Change one, forget the other. Bugs multiply.
Level 2: Functions. Extract shared logic into a function. Call it from multiple places. One source of truth. But the function only works for one type.
Level 3: Generics. Write a function (or class) that works for ANY type. One source of truth, infinite applicability. Zero duplication.
Here's the concrete problem. You need a function that finds the maximum value in a list:
1// Without generics: you write it three times
2FUNCTION findMaxInt(list: List<Integer>): Integer
3 max = list[0]
4 FOR EACH item IN list
5 IF item > max THEN max = item
6 RETURN max
7
8FUNCTION findMaxString(list: List<String>): String
9 max = list[0]
10 FOR EACH item IN list
11 IF item > max THEN max = item
12 RETURN max
13
14FUNCTION findMaxDate(list: List<Date>): Date
15 max = list[0]
16 FOR EACH item IN list
17 IF item > max THEN max = item
18 RETURN maxThree functions. Identical logic. Three places to maintain bugs. With generics, you write it once:
1FUNCTION findMax<T>(list: List<T>): T WHERE T is Comparable
2 max = list[0]
3 FOR EACH item IN list
4 IF item > max THEN max = item
5 RETURN max
6
7// Works with ANY comparable type:
8findMax<Integer>([3, 7, 1, 9]) // Returns 9
9findMax<String>(["apple", "mango"]) // Returns "mango"
10findMax<Date>([jan1, mar15, feb28]) // Returns mar15Type Parameters: What the Angle Brackets Mean
The <T> syntax that scares beginners is simple once you understand it: T is a placeholder for a type that will be specified later.
List<T> means "a list of T, where T is whatever type you choose when you create the list."
List<int>= a list of integersList<string>= a list of stringsList<Customer>= a list of Customer objects
The definition is written once with T. The caller specifies the actual type. The compiler enforces type safety for that specific usage.
Without Generics: The Dark Ages
Before generics, languages used untyped collections that stored everything as Object:
1// Java before generics (pre-2004):
2List numbers = new ArrayList();
3numbers.add(42);
4numbers.add("not a number"); // Compiles fine!
5
6int value = (int) numbers.get(1); // Runtime crash! String isn't an int.No compiler warning. No type safety. The bug hides until production.
With Generics: The Compiler Has Your Back
1// Java with generics:
2List<Integer> numbers = new ArrayList<>();
3numbers.add(42);
4numbers.add("not a number"); // COMPILER ERROR! String is not Integer.The compiler catches the bug before the code ever runs.
First Real Syntax: Three Languages, One Concept
Here's where we prove that concepts transfer. The same generic list in three languages:
Generics Across Languages
C#:
List<int> numbers = new List<int>();
Dictionary<string, Customer> customers = new Dictionary<string, Customer>();Java:
List<Integer> numbers = new ArrayList<>();
Map<String, Customer> customers = new HashMap<>();TypeScript:
const numbers: Array<number> = [];
const customers: Map<string, Customer> = new Map();Same concept. Different notation. If you understand the concept, the syntax is a five-minute lookup.
Generic Methods, Classes, and Interfaces
Generic Methods
A function that works with any type:
1// C#
2T FindMax<T>(List<T> items) where T : IComparable<T>
3{
4 T max = items[0];
5 foreach (T item in items)
6 {
7 if (item.CompareTo(max) > 0)
8 max = item;
9 }
10 return max;
11}1// Java
2<T extends Comparable<T>> T findMax(List<T> items) {
3 T max = items.get(0);
4 for (T item : items) {
5 if (item.compareTo(max) > 0)
6 max = item;
7 }
8 return max;
9}1// TypeScript
2function findMax<T>(items: T[], compare: (a: T, b: T) => number): T {
3 let max = items[0];
4 for (const item of items) {
5 if (compare(item, max) > 0) max = item;
6 }
7 return max;
8}Three syntaxes. One idea. T is a placeholder. The constraint (IComparable, extends Comparable, or a comparison function) tells the compiler what T can do.
Generic Classes: The Repository Pattern
Here's the "aha moment" that makes senior developers fall in love with generics. Imagine you have a database with Customers, Orders, Products, and Invoices. Without generics, you write four data access classes:
1CLASS CustomerRepository
2 METHOD getById(id): Customer
3 METHOD getAll(): List<Customer>
4 METHOD add(customer: Customer)
5 METHOD update(customer: Customer)
6 METHOD delete(id)
7END CLASS
8
9CLASS OrderRepository
10 METHOD getById(id): Order
11 METHOD getAll(): List<Order>
12 METHOD add(order: Order)
13 METHOD update(order: Order)
14 METHOD delete(id)
15END CLASS
16
17// ...and ProductRepository, InvoiceRepository, etc.Identical structure. Different types. Classic DRY violation. With generics:
1CLASS Repository<T>
2 METHOD getById(id): T
3 METHOD getAll(): List<T>
4 METHOD add(entity: T)
5 METHOD update(entity: T)
6 METHOD delete(id)
7END CLASS
8
9// One class serves all entities:
10customerRepo = new Repository<Customer>()
11orderRepo = new Repository<Order>()
12productRepo = new Repository<Product>()
13invoiceRepo = new Repository<Invoice>()Generic Interfaces
Combine generics with interfaces (Lesson 6) for maximum power:
1// C#
2interface IRepository<T> where T : class
3{
4 T GetById(int id);
5 IEnumerable<T> GetAll();
6 void Add(T entity);
7 void Update(T entity);
8 void Delete(int id);
9}
10
11// Any implementation for any type:
12class SqlRepository<T> : IRepository<T> where T : class { ... }
13class InMemoryRepository<T> : IRepository<T> where T : class { ... }Now you can inject IRepository<Customer> and the caller doesn't know whether it's talking to SQL Server, MongoDB, or an in-memory test double. Generics plus interfaces plus dependency injection (Lesson 6) equals maximum flexibility with maximum type safety.
Constraints: What T Can and Can't Do
An unconstrained T can be anything: an integer, a string, a Customer, a database connection. But "anything" means you can't do much with it. You can't compare it, sort it, or call methods on it, because the compiler doesn't know what methods T has.
Constraints narrow the possibilities and tell the compiler what T is capable of:
1// C# constraints
2T FindMax<T>(List<T> items) where T : IComparable<T> // T must be comparable
3void Save<T>(T entity) where T : class // T must be a reference type
4T Create<T>() where T : new() // T must have parameterless constructor
5void Process<T>(T item) where T : ISerializable, new() // Multiple constraints1// Java constraints
2<T extends Comparable<T>> T findMax(List<T> items) // T must implement Comparable
3<T extends Serializable & Closeable> void process(T item) // Multiple bounds1// TypeScript constraints
2function process<T extends HasId>(item: T): void // T must have an id property
3function merge<T extends object>(a: T, b: Partial<T>): T // T must be an objectConstraint Cheat Sheet
"T must be comparable" → C#: where T : IComparable<T> | Java: <T extends Comparable<T>> | TS: <T extends Comparable> "T must be a class/object" → C#: where T : class | Java: <T extends Object> (default) | TS: <T extends object> "T must have a constructor" → C#: where T : new() | Java: use reflection | TS: <T extends { new(): T }> "T must implement interface" → C#: where T : IMyInterface | Java: <T extends MyInterface> | TS: <T extends MyInterface> Constraints are the guardrails. Without them, T is too generic to be useful. With them, T is exactly as flexible as you need.
"The purpose of abstraction is not to be vague, but to create a new semantic level in which one can be absolutely precise." Dijkstra's principle applies perfectly to generics:
List<T>is not vague. It's precisely "a list of exactly one type, enforced by the compiler."