Topic 7.4
Generic Interfaces, Type Aliases and Classes
In one line
Interfaces, type aliases and classes can have type parameters too, so one definition describes a Box<number>, a Box<string>, a Result<User, Error>, a Stack<Order>. Defaults let callers leave a blank empty.
Think of it like this
A cupcake tray. The tray has twelve identical cups. It is the same tray whether you bake chocolate, vanilla or carrot cupcakes. When you buy the tray you don't ask for "a chocolate tray": you buy one tray shape and decide the flavour when you fill it. A generic type is the tray: the shape (a value field, a list, a success-or-failure pair) is written once, and the flavour is the type you plug in.
Words you'll meet
New words in this topic, in plain English. Come back here whenever one feels fuzzy.
- Generic interface
- An interface with type parameters:
interface Box<T> { value: T }. - Generic type alias
- A
typename with type parameters:type Pair<A, B> = [A, B]. - Generic class
- A class with type parameters, so each instance can work with a different type.
- Instantiate
- Fill in the blanks of a generic type with real types, as in
Box<number>. - Default type parameter
- A value for a type parameter that is used when the caller writes none:
<T = string>. - Result type
- A union of success and failure,
{ ok: true; value: T } | { ok: false; error: E }, that makes callers handle both. - Instance side
- What belongs to each object made by
new. A class's type parameters live here, not onstaticmembers. - Utility type
- A generic type from the standard library, like
Partial<T>orRecord<K, V>, built from type parameters.
Step by step
01A generic interface
Box<T> is a shape with a blank. Box<number> and Box<string> are two different filled-in shapes made from the same definition.
interface Box<T> {
value: T;
}
const a: Box<number> = { value: 42 };
const b: Box<string> = { value: 42 };
// error TS2322: Type 'number' is not assignable to type 'string'.02A generic type alias for success or failure
Result<T, E> is a union with two shapes, tagged by the literal ok. T is the success value type and E the error type, here defaulting to Error.
After if (r.ok), TypeScript narrows r to the success shape, so r.value exists. In the else branch it narrows to the failure, so r.error exists. Read r.value in the wrong branch and it is an error.
type Result<T, E = Error> =
| { ok: true; value: T }
| { ok: false; error: E };
function parsePort(text: string): Result<number, string> {
const n = Number(text);
if (Number.isInteger(n) && n > 0 && n < 65536) return { ok: true, value: n };
return { ok: false, error: `"${text}" is not a port` };
}03A generic class
The class declares T once after its name. Every member can use it. new Stack<number>() makes a stack of numbers, and pushing text into it is an error.
class Stack<T> {
private items: T[] = [];
push(item: T): void { this.items.push(item); }
pop(): T | undefined { return this.items.pop(); }
}
const nums = new Stack<number>();
nums.push(1);
nums.push("three"); // error TS234504Defaults: leaving a blank empty
interface ApiResponse<T = unknown> lets you write plain ApiResponse when you don't care what is inside, and ApiResponse<User> when you do. With unknown as the default, the data must be checked before use, which is the safe choice.
type Pair<A, B = A> shows a default that uses an earlier parameter: Pair<number> is [number, number].
05Where type parameters can't go
static members cannot use the class's T, because they exist once for the class and not once per instance. A static factory can have its own type parameter instead: static of<U>(item: U): Stack<U>.
Try it yourself
- 1
Remove a default
In the last example delete
= stringfromclass Queue<T = string>. Predict the type ofqand whetherq.add(2)is still an error. (Queue<unknown>, and no error.) - 2
Use the default error type
In the first example, change
Result<number, string>toResult<number>and the error text tonew Error("bad port"). The defaultE = Erroris used. - 3
Read the wrong branch
In the loop, change
console.log("port", r.value)toconsole.log("port", r.error). Read the error: insideif (r.ok)the value is the success shape, which has noerror(TS2339: Property 'error' does not exist on type '{ ok: true; value: number; }'). You have to checkokbefore choosing betweenvalueanderror.
Code & diagrams
Line 10 puts a number in a Box<string>. Line 22 uses Box with no type argument, which a plain generic does not allow. In the loop, r.value is only reachable after the ok check.
Console
(nothing was printed)Expected console output
port 8080
problem: "http" is not a port
42Each instance has its own T. The Stack<number> rejects text (line 20), though the program still runs and pops the text. pop() is number | undefined: an empty stack has nothing to pop.
Console
(nothing was printed)Expected console output
three 2 aPlain ApiResponse means ApiResponse<unknown>, so raw.data must be checked before use (the last line is an error). Pair<number> uses B = A and becomes [number, number].
Console
(nothing was printed)Expected console output
anything 7 [ 1, 2 ] [ 'a', 1 ]
ANYTHINGnew Queue() has no arguments to read T from, so the default string is used. Remove = string and q becomes Queue<unknown>, which accepts the number too.
Console
(nothing was printed)Expected console output
job-1 2Break it on purpose
Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.
Break #1
Forget the type argument
In the first example, const bare: Box = { value: 1 }; uses Box without <T>.
Break #2
Wrong type of value in the box
In the first example, const textBox: Box<string> = { value: 42 };.
Myth vs fact
Myth
A generic class makes a new class for every type.
Fact
There is one class at runtime. Stack<number> and Stack<string> are the same JavaScript class; the <T> only exists for the compiler.
Myth
Type aliases can't be generic, only interfaces can.
Fact
Both can. Type aliases are often the better fit for unions like Result<T, E>, which interfaces can't express.
Myth
A default type parameter means TypeScript will infer the type for you.
Fact
A default is used only when nothing else is known. If the constructor or call provides a candidate, inference wins and the default is ignored.
Pro corner
Extra depth for experienced readers. New to this? Skip it for now and come back later.
- ▸
Generic interfaces and aliases are lazy:
Box<number>is not copied into a new type. The compiler stores the arguments and substitutes them when it needs a member. Type identity is by structure, soBox<number>and{ value: number }are the same type for assignment. - ▸
Variance of a generic type is worked out from how
Tis used: only in output positions (areadonly value: T) it is covariant, only in input positions contravariant, in both invariant. Topic 7.7 covers this, including theinandoutannotations (4.7) that make it explicit (and can sometimes help the compiler with deep generics). - ▸
A class with a
privateorprotectedmember is nominal in practice: an object with the same public members is not interchangeable with it. Theprivate items: T[]inStack<T>is why a plain object withpushandpopcan't be passed where aStack<number>is expected (the error isProperty 'items' is missing).
Remember this
- 1
The problem this solves: A container such as a box, a stack or a result should work for any content type. Writing a copy for each type repeats code, and
anyloses the content's type. In Topic 7.1 only functions had a blank. Everything that names a type can have one. Generic interface:interface Box<T> { value: T }. Generic type alias:type Pair<A, B> = [A, B]. Generic class:class Stack<T> { ... }. You've already used generic types without knowing:Array<T>(T[]),Promise<T>,Map<K, V>,Set<T>,Record<K, V>andPartial<T>are all written this way in TypeScript's own library. - 2
To use a generic type, give it type arguments in angle brackets:
Box<number>is "the Box withTreplaced bynumber", so itsvalueis anumber. This is called instantiating the generic. For types, TypeScript does not infer the arguments from nothing:const b: Box = { value: 1 }is an error (TS2314, "requires 1 type argument(s)"), unless the parameter has a default. For a class,new Stack<number>()writes the argument, andnew Stack(initialItems)can infer it from the constructor arguments. - 3
A very useful generic type alias is **
Result<T, E>**: it models "either success with aTor failure with anE" as a discriminated union (Topic 4.5), so code is forced to checkokbefore it touchesvalueorerror. Errors become data instead of exceptions that fly out of nowhere. One definition servesResult<number, string>,Result<User, ApiError>and every other combination. - 4
A generic class declares the type parameter after the name and uses it in fields, parameters and return types:
class Stack<T> { private items: T[] = []; push(item: T): void ... pop(): T | undefined ... }. Each instance has its ownT: aStack<number>rejects text, aStack<string>rejects numbers. TheTexists on the instance side, sostaticmembers can't use the class's type parameter (they belong to the class, not to any instance). - 5
Default type parameters give a blank a value to use when the caller leaves it out:
interface ApiResponse<T = unknown>makes plainApiResponsemeanApiResponse<unknown>, andtype Pair<A, B = A>makesPair<number>mean[number, number]. Defaults can refer to earlier parameters. Rule: parameters with defaults must come after the ones without. Also, with defaults you may give fewer type arguments than parameters, which regular generics can't do. - 6
Defaults also apply to a class with no inference source:
class Queue<T = string>andnew Queue()givesQueue<string>. Without the default, anew Queue()with no arguments would have no way to findTand would beQueue<unknown>. Choose a default that is the most common use, and preferunknownoveranyfor a safe "don't know" default. - 7
Constraints work on generic types the same way as on functions:
interface Repository<T extends { id: number }> { find(id: number): T | undefined }. And generic types can be nested:Box<Box<number>>,Result<Box<string>, Error>,Promise<Result<User>>. Read from the outside in.
Explain it without notes
How do generic interfaces, type aliases and classes differ in how you supply type arguments?
What is a Result<T, E> type and why is it a generic type alias?
What do default type parameters do and what rules do they follow?
Why can't a static member of a generic class use the class's type parameter?
Practice
Write interface Page<T> { items: T[]; total: number } and a function emptyPage<T>(): Page<T>. Call it so that the result is Page<string>.
Write a generic class Pair<A, B> with a method swap(): Pair<B, A>. Create a Pair<string, number> and swap it.
Write type Option<T> = { some: true; value: T } | { some: false } and a function unwrapOr<T>(o: Option<T>, fallback: T): T.
Trade-offs
- ↔
Generic types are the most reusable but also the hardest to read in error messages (
Result<Box<Map<string, User[]>>, ApiError>). Name intermediate aliases to keep messages short. - ↔
A
Resulttype makes errors explicit but costs boilerplate (every caller must check). Exceptions are shorter but invisible in signatures. Many teams useResultfor expected failures such as parsing and validation, and exceptions for bugs.
Done when you can
I can write a generic interface, a generic alias and a generic class.
I can explain why
const b: Box = ...is an error and how to fix it.I can write and use a
Result<T, E>and narrow it withok.I can write a default type parameter and say when it is used.
I can say why static members can't use the class's type parameter.