A placeholder for a type
Think of a labeled storage box. The box works the same whatever you put in, but the label says what’s inside: “Box of screws”, “Box of cables”. If the label says screws, nobody is allowed to put cables in, and whoever opens it knows they’ll get screws.
A generic type has a type parameter, usually called T (or a better name like Item), that works like that label:
- you write the type once, with
Twherever “the thing inside” goes, - each use fills in the label:
Page<Product>,Page<Order>, - the compiler checks every use: you can’t put an
Orderinto aPage<Product>, and you never need a cast to get aProductout.
Generics let types and functions abstract over types while keeping static checking. The four languages implement them differently, and that’s where the senior questions are: Swift generics are reified and can be specialized (no boxing, full type information at runtime), with existentials (any P) as the separate, dynamically dispatched alternative; Java and Kotlin use type erasureType erasureRemoving generic type arguments after compilation, so List<String> and List<Int> are the same class at runtime.Open in glossary → (List<String> and List<Int> are the same class at runtime), with Kotlin adding reified type parameters for inline functions. VarianceVarianceThe rules for whether a generic type of a subtype counts as a generic type of its supertype.Open in glossary → differs too: declaration-site in Kotlin (out/in), use-site wildcards in Java (? extends/? super), invariance with special-cased covariant collections in Swift. TypeScript checks generics structurally and erases them along with every other type. The Detailed version covers all of it.
struct Page<Item> {
let items: [Item]
let page: Int
let hasMore: Bool
/// Same paging information, different item type.
func map<NewItem>(_ transform: (Item) -> NewItem) -> Page<NewItem> {
Page<NewItem>(items: items.map(transform), page: page, hasMore: hasMore)
}
}
/// Works for any type whose values can be compared with < and >.
func largest<T: Comparable>(_ items: [T]) -> T? {
guard var best = items.first else { return nil }
for item in items.dropFirst() where item > best { best = item }
return best
}
let products = Page(items: [Product(name: "Mug", price: 9)], page: 1, hasMore: false)
let names: Page<String> = products.map(\.name)
print(largest([3, 9, 4]) ?? 0) // 9
print(largest(["pear", "apple"])!) // peardata class Page<T>(val items: List<T>, val page: Int, val hasMore: Boolean) {
/** Same paging information, different item type. */
fun <R> map(transform: (T) -> R): Page<R> = Page(items.map(transform), page, hasMore)
}
/** Works for any type whose values can be compared. */
fun <T : Comparable<T>> largest(items: List<T>): T? {
var best = items.firstOrNull() ?: return null
for (item in items.drop(1)) if (item > best) best = item
return best
}
val products = Page(listOf(Product("Mug", 9.0)), page = 1, hasMore = false)
val names: Page<String> = products.map { it.name }
println(largest(listOf(3, 9, 4))) // 9
println(largest(listOf("pear", "apple"))) // pearrecord Page<T>(List<T> items, int page, boolean hasMore) {
/** Same paging information, different item type. */
<R> Page<R> map(Function<? super T, ? extends R> transform) {
return new Page<>(items.stream().<R>map(transform).toList(), page, hasMore);
}
}
/** Works for any type whose values can be compared. */
static <T extends Comparable<? super T>> Optional<T> largest(List<T> items) {
return items.stream().max(Comparator.naturalOrder());
}
Page<Product> products = new Page<>(List.of(new Product("Mug", 9)), 1, false);
Page<String> names = products.map(Product::name);
System.out.println(largest(List.of(3, 9, 4)).orElse(0)); // 9
System.out.println(largest(List.of("pear", "apple")).get()); // pearinterface Page<Item> {
readonly items: Item[];
readonly page: number;
readonly hasMore: boolean;
}
/** Same paging information, different item type. */
function mapPage<Item, NewItem>(page: Page<Item>, transform: (item: Item) => NewItem): Page<NewItem> {
return { ...page, items: page.items.map(transform) };
}
/** Works for any type that > can compare: numbers or strings. */
function largest<T extends number | string>(items: T[]): T | undefined {
let best = items[0];
for (const item of items.slice(1)) if (item > best) best = item;
return best;
}
const products: Page<Product> = { items: [{ name: "Mug", price: 9 }], page: 1, hasMore: false };
const names: Page<string> = mapPage(products, (p) => p.name);
console.log(largest([3, 9, 4])); // 9
console.log(largest(["pear", "apple"])); // pearTwo kinds of generics are in that code:
- A generic type:
Page<Item>is written once and used asPage<Product>,Page<String>, and so on. - A generic function:
largestworks for numbers, strings, dates, anything comparable. The constraint (T: Comparable,T : Comparable<T>,T extends Comparable,T extends number | string) is what lets the code use>: without it, the compiler doesn’t know that twoTvalues can be compared.
You’ll rarely need to write the type in angle brackets when using generics: Page(items: …), listOf(3, 9, 4) and largest([3, 9, 4]) let the compiler work it out from the arguments.
Practice: a generic in-memory store
Tests and previews often need a fake repository. Instead of one per type, let’s write one generic store, step by step:
- Name the type parameters: the item type, and the id type.
- Constrain the item so the store can read its id:
Identifiablein Swift, a smallEntity<ID>interface in Kotlin, Java and TypeScript. - Store items in a map from id to item.
- Use it for products, orders, anything with an id, with full type checking.
final class InMemoryStore<Item: Identifiable> {
private var items: [Item.ID: Item] = [:]
func save(_ item: Item) { items[item.id] = item }
func find(_ id: Item.ID) -> Item? { items[id] }
func all() -> [Item] { Array(items.values) }
}
struct Product: Identifiable { let id: Int; let name: String }
let store = InMemoryStore<Product>()
store.save(Product(id: 1, name: "Mug"))
print(store.find(1)?.name ?? "missing") // Muginterface Entity<ID> { val id: ID }
class InMemoryStore<ID, T : Entity<ID>> {
private val items = LinkedHashMap<ID, T>()
fun save(item: T) { items[item.id] = item }
fun find(id: ID): T? = items[id]
fun all(): List<T> = items.values.toList()
}
data class Product(override val id: Int, val name: String) : Entity<Int>
val store = InMemoryStore<Int, Product>()
store.save(Product(1, "Mug"))
println(store.find(1)?.name ?: "missing") // Muginterface Entity<ID> { ID id(); }
class InMemoryStore<ID, T extends Entity<ID>> {
private final Map<ID, T> items = new LinkedHashMap<>();
void save(T item) { items.put(item.id(), item); }
Optional<T> find(ID id) { return Optional.ofNullable(items.get(id)); }
List<T> all() { return List.copyOf(items.values()); }
}
record Product(Integer id, String name) implements Entity<Integer> {}
InMemoryStore<Integer, Product> store = new InMemoryStore<>();
store.save(new Product(1, "Mug"));
System.out.println(store.find(1).map(Product::name).orElse("missing")); // Muginterface Entity<ID> { readonly id: ID }
class InMemoryStore<ID, T extends Entity<ID>> {
private readonly items = new Map<ID, T>();
save(item: T) { this.items.set(item.id, item); }
find(id: ID): T | undefined { return this.items.get(id); }
all(): T[] { return [...this.items.values()]; }
}
interface Product extends Entity<number> { name: string }
const store = new InMemoryStore<number, Product>();
store.save({ id: 1, name: "Mug" });
console.log(store.find(1)?.name ?? "missing"); // MugSwift’s Identifiable already carries the id’s type (Item.ID, an associated type), so one type parameter is enough. Kotlin, Java and TypeScript pass both explicitly. (In TypeScript, any object with a matching id fits Entity<ID>, without declaring it: generics are checked by shape.)
One more generic trap, specific to Java:
Tap the line with the bug.
static <T> boolean containsValue(List<T> list, T value) {
for (T item : list) {
if (item == value) return true;
}
return false;
}
System.out.println(containsValue(List.of(5, 1000), 5)); // true
System.out.println(containsValue(List.of(5, 1000), 1000)); // false!Hint: Inside a generic method, T is an object type. What does == compare for objects?
T is always an object type, so 1000 is a boxed Integer, and == compares references. Java caches Integer objects from -128 to 127, so 5 == 5 happens to be the same object and works, while two separately boxed 1000s are different objects. The method passes every test with small numbers and fails in production with real ids.
Compare values with Objects.equals(item, value) (null-safe), never ==, for anything generic or boxed.
Swift note
Swift won’t compile item == value for an unconstrained T: you must write <T: Equatable>, and then == compares values. The constraint makes the meaning explicit.
Kotlin note
Kotlin’s == always calls equals, so the same code is correct in Kotlin. Reference comparison is the separate === operator.
Paginate anything
Write a generic function paginate(items, pageSize) that splits a list into pages using the Page type from the start of the tutorial: page numbers start at 1, hasMore is true on every page except the last, and an empty list gives no pages. It must work for any item type, and reject a page size below 1.
paginate(["a", "b", "c", "d", "e"], 2)
→ Page(["a", "b"], 1, true), Page(["c", "d"], 2, true), Page(["e"], 3, false)›
The function needs one type parameter for the items. The result type is a list of Page of that same type.
›
Most standard libraries can split a list into chunks: chunked(size) in Kotlin; in Swift, stride over the indices; in Java, use subList in a loop; in TypeScript, slice in a loop.
SolutionTry it yourself firstTime O(n)Space O(n)
func paginate<Item>(_ items: [Item], pageSize: Int) -> [Page<Item>] {
precondition(pageSize >= 1, "pageSize must be at least 1")
let starts = Array(stride(from: 0, to: items.count, by: pageSize))
return starts.enumerated().map { index, start in
let end = min(start + pageSize, items.count)
return Page(items: Array(items[start..<end]), page: index + 1, hasMore: end < items.count)
}
}fun <T> paginate(items: List<T>, pageSize: Int): List<Page<T>> {
require(pageSize >= 1) { "pageSize must be at least 1" }
val chunks = items.chunked(pageSize)
return chunks.mapIndexed { index, chunk ->
Page(chunk, page = index + 1, hasMore = index < chunks.lastIndex)
}
}static <T> List<Page<T>> paginate(List<T> items, int pageSize) {
if (pageSize < 1) throw new IllegalArgumentException("pageSize must be at least 1");
List<Page<T>> pages = new ArrayList<>();
for (int start = 0, number = 1; start < items.size(); start += pageSize, number++) {
int end = Math.min(start + pageSize, items.size());
pages.add(new Page<>(List.copyOf(items.subList(start, end)), number, end < items.size()));
}
return pages;
}function paginate<T>(items: T[], pageSize: number): Page<T>[] {
if (pageSize < 1) throw new RangeError("pageSize must be at least 1");
const pages: Page<T>[] = [];
for (let start = 0; start < items.length; start += pageSize) {
const end = Math.min(start + pageSize, items.length);
pages.push({ items: items.slice(start, end), page: pages.length + 1, hasMore: end < items.length });
}
return pages;
}The function never looks inside an item, so it needs no constraint at all: T can be anything. That’s a good sign: the fewer constraints a generic function needs, the more widely it’s reusable. Note List.copyOf in Java: subList is a view of the original list, and a page built on it would change if the original did.
Variance
If Cat is an Animal, is a list of cats a list of animals? For a read-only list, yes (covariance); for a writable one, no, or you could add a Dog to a list of cats.
- Kotlin declares variance on the type:
interface List<out E>is covariant (aList<Cat>is aList<Animal>),MutableList<E>is invariant, andinmarks consumers (Comparator<in T>). Use-site projections (MutableList<out Animal>) exist for the rest. - Java decides at each use with wildcards: PECS, “producer extends, consumer super”:
List<? extends Animal>to read animals from,List<? super Cat>to add cats to (Collections.copy(List<? super T> dest, List<? extends T> src)). - TypeScript compares types by shape, and infers variance from how a type parameter is used (or takes explicit
in/outannotations since 4.7). Arrays are treated as covariant although they’re mutable, a deliberate hole for convenience: aCat[]is accepted as anAnimal[], and nothing stops you from pushing aDoginto it. - Swift generics are invariant, but the standard collections are special-cased:
[Cat]converts to[Animal](a copy, thanks to value semantics, so it’s safe). Your ownBox<Cat>is not aBox<Animal>.
Is a list of cats a list of animals?
Predict the output
Cat is a subclass of Animal. Does this compile?
class Animal {}
class Cat: Animal {}
var animals: [Animal] = [Cat]()open class Animal
class Cat : Animal()
val animals: MutableList<Animal> = mutableListOf<Cat>()class Animal {}
class Cat extends Animal {}
List<Animal> animals = new ArrayList<Cat>();class Animal { name = ""; }
class Cat extends Animal { meow() {} }
const cats: Cat[] = [];
const animals: Animal[] = cats;Swift allows converting [Cat] to [Animal]: arrays are values, so animals is an independent copy and appending a Dog to it can't put a dog into the cats array. This is a special case for standard collections; a custom generic type would be invariant.
MutableList<E> is invariant: if this compiled, animals.add(Dog()) would put a dog into the list of cats. A read-only List<Animal> would accept it, because List<out E> is covariant.
Java generics are invariant: ArrayList<Cat> is not a List<Animal>, for the same reason. Use List<? extends Animal> to read animals from a list of cats (and lose the ability to add).
It compiles, and that's a known unsoundness: TypeScript treats arrays as covariant even though they're mutable. animals.push(new Dog()) also compiles, and now cats contains a dog. Use readonly Animal[] for parameters that only read.
| Swift | Kotlin | Java | TypeScript | |
|---|---|---|---|---|
| Generic type / function | struct Box<T>, func f<T>(_:) | class Box<T>, fun <T> f() | class Box<T>, static <T> void f() | interface Box<T>, function f<T>() |
| Constraint | T: Comparable, where clauses | T : Comparable<T>, where | T extends Comparable<? super T> | T extends { id: string } (structural) |
| Variance | invariant (std collections covariant) | out / in at declaration, projections | wildcards: ? extends read, ? super write (PECS) | structural; arrays covariant (unsound); in / out annotations (4.7) |
Swift
Prefer generics and some P (one hidden concrete type, checked statically) over any P (a box with dynamic dispatch, heap-allocated when the value is larger than three words) in hot code. [Cat] converts to [Animal] because arrays are values; your own Box<Cat> doesn’t.
Kotlin
List<out E> is covariant (a List<Cat> is a List<Animal>), MutableList<E> invariant, Comparator<in T> contravariant; use-site projections cover the rest.
Java
PECS: “producer extends, consumer super”: copy(List<? super T> dst, List<? extends T> src). ArrayList<Cat> is not a List<Animal>.
TypeScript
Generics are checked structurally: any type with the right shape satisfies a constraint. Arrays are covariant even though they’re mutable, so const animals: Animal[] = cats; animals.push(dog) compiles: a known hole.
Check this part
What does
Page<Product>guarantee?Why does
largestneed a constraint on its type parameter?Catis a subclass ofAnimal. Doesvar animals: [Animal] = [Cat]()compile?Catis a subclass ofAnimal. Doesval animals: MutableList<Animal> = mutableListOf<Cat>()compile?Catis a subclass ofAnimal. DoesList<Animal> animals = new ArrayList<Cat>();compile?CatextendsAnimal. Doesconst animals: Animal[] = cats(aCat[]) compile?What's the difference between
some Shapeandany Shape?What doesoutininterface List<out E>mean?A method copies animals from one list to another. Which signature follows PECS?What doinandouton a type parameter (TypeScript 4.7) do?