Command Palette

Search for a command to run...

Hectal
PHASE 7Intermediate ~27 min· topic 4 of 7

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 type name 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 on static members.
Utility type
A generic type from the standard library, like Partial<T> or Record<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.

box.tswhole filets
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.

result.tswhole filets
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` };
}
A generic type alias for success or failurediagram
Rendering diagram…

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.

stack.tswhole filets
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 TS2345

04Defaults: 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. 1

    Remove a default

    In the last example delete = string from class Queue<T = string>. Predict the type of q and whether q.add(2) is still an error. (Queue<unknown>, and no error.)

  2. 2

    Use the default error type

    In the first example, change Result<number, string> to Result<number> and the error text to new Error("bad port"). The default E = Error is used.

  3. 3

    Read the wrong branch

    In the loop, change console.log("port", r.value) to console.log("port", r.error). Read the error: inside if (r.ok) the value is the success shape, which has no error (TS2339: Property 'error' does not exist on type '{ ok: true; value: number; }'). You have to check ok before choosing between value and error.

Code & diagrams

Box and Result: one definition, many types

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
42
A generic class

Each 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 a
Default type parameters

Plain 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 ]
ANYTHING
A default makes a class with nothing to infer from safe

new 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 2

Break 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>.

terminal
$ Types panel
── what you'll see ──
index.ts(22,13): error TS2314: Generic type 'Box<T>' requires 1 type argument(s).

Break #2

Wrong type of value in the box

In the first example, const textBox: Box<string> = { value: 42 };.

terminal
$ Types panel
── what you'll see ──
index.ts(9,32): error TS2322: Type 'number' is not assignable to type 'string'.

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, so Box<number> and { value: number } are the same type for assignment.

  • ▸

    Variance of a generic type is worked out from how T is used: only in output positions (a readonly value: T) it is covariant, only in input positions contravariant, in both invariant. Topic 7.7 covers this, including the in and out annotations (4.7) that make it explicit (and can sometimes help the compiler with deep generics).

  • ▸

    A class with a private or protected member is nominal in practice: an object with the same public members is not interchangeable with it. The private items: T[] in Stack<T> is why a plain object with push and pop can't be passed where a Stack<number> is expected (the error is Property 'items' is missing).

Remember this

  1. 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 any loses 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> and Partial<T> are all written this way in TypeScript's own library.

  2. 2

    To use a generic type, give it type arguments in angle brackets: Box<number> is "the Box with T replaced by number", so its value is a number. 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, and new Stack(initialItems) can infer it from the constructor arguments.

  3. 3

    A very useful generic type alias is **Result<T, E>**: it models "either success with a T or failure with an E" as a discriminated union (Topic 4.5), so code is forced to check ok before it touches value or error. Errors become data instead of exceptions that fly out of nowhere. One definition serves Result<number, string>, Result<User, ApiError> and every other combination.

  4. 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 own T: a Stack<number> rejects text, a Stack<string> rejects numbers. The T exists on the instance side, so static members can't use the class's type parameter (they belong to the class, not to any instance).

  5. 5

    Default type parameters give a blank a value to use when the caller leaves it out: interface ApiResponse<T = unknown> makes plain ApiResponse mean ApiResponse<unknown>, and type Pair<A, B = A> makes Pair<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. 6

    Defaults also apply to a class with no inference source: class Queue<T = string> and new Queue() gives Queue<string>. Without the default, a new Queue() with no arguments would have no way to find T and would be Queue<unknown>. Choose a default that is the most common use, and prefer unknown over any for a safe "don't know" default.

  7. 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

01

How do generic interfaces, type aliases and classes differ in how you supply type arguments?

02

What is a Result<T, E> type and why is it a generic type alias?

03

What do default type parameters do and what rules do they follow?

04

Why can't a static member of a generic class use the class's type parameter?

Practice

01

Write interface Page<T> { items: T[]; total: number } and a function emptyPage<T>(): Page<T>. Call it so that the result is Page<string>.

02

Write a generic class Pair<A, B> with a method swap(): Pair<B, A>. Create a Pair<string, number> and swap it.

03

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 Result type makes errors explicit but costs boilerplate (every caller must check). Exceptions are shorter but invisible in signatures. Many teams use Result for 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 with ok.

  • 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.