Generic Interfaces, Types and Classes in TypeScript: Box, Result and Stack
We move type parameters from functions to types: Box<T> as a generic type alias, a paginated list as a generic interface, Result<T, E> for success and failure, and generic Stack and Registry classes.
Version note: The code in this post targets TypeScript 7.0 and Node 26.
Predict First
This time we're giving the type parameter to a class instead of a function. We've written a simple stack:
class Stack<T> {
#items: T[] = [];
push(item: T): void {
this.#items.push(item);
}
pop(): T | undefined {
return this.#items.pop();
}
}
const numbers = new Stack<number>();
numbers.push(1);
numbers.push("2");
const anything = new Stack();
anything.push(1);
anything.push("2");
const last = anything.pop();
Make a guess: which push lines error? What is the type of last on the final line? The answer is in the section on generic classes.
You've Already Met Generic Types
In the first two posts we gave type parameters to functions. The same idea applies to types themselves, and you've been using it without noticing. The Array<string> syntax we saw earlier is nothing more than passing the type argument string to a generic interface called Array. The same goes for Map<string, number>, Set<string> and ReadonlyArray<T>. In this post we'll write generic types of our own.
Here's the mental model: Array on its own isn't a type, it's a mold that produces types. Array<string> is a real type that came out of that mold. A generic function took a type as a parameter while producing a value; a generic type produces a type directly.
Generic Type Alias: Box<T>
Let's start with the simplest example, a box that holds a single value:
type Box<T> = { value: T };
const age: Box<number> = { value: 36 };
const label: Box<string> = { value: "Ada" };
// Error: Generic type 'Box' requires 1 type argument(s).
const broken: Box = { value: true };
The <T> after the type alias name declares a type parameter, just as it does for functions. There's one important difference: when you call a generic function, TypeScript infers T from the argument, but when you write Box as an annotation on a variable, no inference happens. You have to say which type fills the mold; if you don't, you get the requires 1 type argument(s) error (TS2314).
If you want the convenience of inference back, combine the generic type with a generic function:
type Box<T> = { value: T };
function box<T>(value: T): Box<T> {
return { value };
}
const boxedName = box("Ada"); // Box<string>
const boxedFlag = box(true); // Box<boolean>
T is inferred on the function side, then passed on to Box<T>. We get a box of the right type without writing a type.
Generic Interfaces
Interfaces can take type parameters too. Paginated lists coming from APIs are a good example: the list structure is always the same, only the type of the elements inside changes.
interface ApiList<T> {
items: T[];
total: number;
page: number;
}
interface Product {
id: string;
title: string;
price: number;
}
const products: ApiList<Product> = {
items: [{ id: "p1", title: "Mechanical Keyboard", price: 120 }],
total: 1,
page: 1,
};
const titles = products.items.map((product) => product.title); // string[]
As soon as we write ApiList<Product>, it's known that items is a Product[], and that's where the type of the product parameter in map comes from. Tomorrow you won't need a separate interface for a user list; ApiList<User> is enough. The choice between type alias and interface that we saw earlier is the same here: for object shapes, both work.
Result<T, E>: Writing Success and Failure into the Type
One of the most useful generic types is Result, which expresses that an operation can either succeed or fail. We combine the discriminated union we saw earlier with two type parameters:
type Result<T, E = Error> =
| { ok: true; value: T }
| { ok: false; error: E };
function parseAge(input: string): Result<number, string> {
const age = Number(input);
if (Number.isNaN(age)) {
return { ok: false, error: `Not a number: ${input}` };
}
return { ok: true, value: age };
}
const result = parseAge("36");
if (result.ok) {
console.log(result.value + 1); // 37
} else {
console.log(result.error.toUpperCase());
}
T carries the type of a successful result, E the type of the error. The ok property acts as the discriminant: inside the if (result.ok) block TypeScript knows that value exists, and in the else block it knows that error exists. If you try to access result.value without checking, TypeScript stops you, because the result might be a failure.
The E = Error part is the default type parameter we saw in the previous post. Where you don't care to name the error type, writing Result<User> is enough, and E automatically becomes Error. This, in generic types, is where default type parameters really pay off. We'll come back to the details of modeling errors in the post on error handling.
Generic Classes
Let's return to the Stack<T> from the start. The <T> after the class name declares a type parameter that applies to all of the class's instance properties and methods. When you say new Stack<number>(), that instance's T becomes number and push now accepts only numbers. That's why the numbers.push("2") line gives the error Argument of type 'string' is not assignable to parameter of type 'number'.
What about new Stack()? Here we gave no type argument, and there's no constructor argument TypeScript could infer from either. In that situation, T becomes unknown. anything is a Stack<unknown>, it accepts everything, and both push calls compile without errors. In return, the type of last is unknown; you can't use it without knowing what came out. So the only line that errors is numbers.push("2").
If the class's constructor takes a parameter of type T, the inference we saw with generic functions works here too:
class Stack<T> {
#items: T[];
constructor(initial: T[] = []) {
this.#items = [...initial];
}
push(item: T): void {
this.#items.push(item);
}
peek(): T | undefined {
return this.#items.at(-1);
}
get size(): number {
return this.#items.length;
}
}
const words = new Stack(["Ada", "Grace"]); // Stack<string>
const scores = new Stack<number>(); // Stack<number>
words.push("Alan");
console.log(words.peek(), words.size); // Alan 3
In the new Stack(["Ada", "Grace"]) call, T is inferred as string from the array passed to the constructor. For a stack that starts empty there's nothing to infer from, so we write the type explicitly. In short: if you pass a value, TypeScript figures it out; if you don't, you tell it. The #items we used is the private field from the JavaScript series; we'll talk about how it differs from TypeScript's own private keyword in the next post.
More Than One Type Parameter
When a structure depends on more than one type, you separate the type parameters with commas. Result<T, E> was one example. Let's write a small registry whose keys are limited to specific values and whose values are of another type:
class Registry<K extends string, V> {
#entries = new Map<K, V>();
set(key: K, value: V): void {
this.#entries.set(key, value);
}
get(key: K): V | undefined {
return this.#entries.get(key);
}
}
const prices = new Registry<"basic" | "pro", number>();
prices.set("pro", 49);
console.log(prices.get("pro")); // 49
// Error: Argument of type '"enterprise"' is not assignable to parameter of type '"basic" | "pro"'.
prices.set("enterprise", 99);
We gave K a constraint and left V free; the two work independently. The Map<K, V> we use inside is itself a generic with two type parameters, and our K and V are passed on to it. This is how generics grow: by nesting inside one another.
A note on naming: with one or two parameters, single letters like T, K, V and E stay readable. As the number of parameters grows, descriptive names like TInput and TOutput make the code much easier to follow.
Common Pitfalls
The first pitfall is passing only some of the type arguments. If you give a single argument to a class with two type parameters, TypeScript doesn't try to infer the missing one:
class Registry<K extends string, V> {
#entries = new Map<K, V>();
}
// Error: Expected 2 type arguments, but got 1.
const plans = new Registry<"basic">();
The rule: you pass either all of the type arguments or none of them. Parameters with defaults are the only exception; you can leave those out.
The second pitfall is trying to use T in static members:
class Settings<T> {
// Error: Static members cannot reference class type parameters.
static defaults: T;
}
The logic is simple: static members belong to the class itself, not to an instance. Settings<string> and Settings<number> instances share the same class, so there's only one defaults. Since it's unclear which T it would belong to, TypeScript doesn't allow it.
The third pitfall is the silent unknown we saw in the opening example. When you write new Stack() or new Map() and forget the type argument, you get no error, but the structure carries unknown, and the problem only surfaces when you try to read a value out of it. For generic structures that start empty, write the type argument up front.
A Small Experiment
★☆☆ Predict whether it compiles: const failed: Result<number> = { ok: false, error: "oops" };. If it errors, explain why by thinking about E's default.
★★☆ Using ApiList<T>, define a list that carries users of type { name: string; email: string }. Then try adding a user without an email property to items, and read the error message.
★★★ Add a generic method to Stack<T>: map<U>(fn: (item: T) => U): Stack<U>. How do the class's T and the method's U work together? Confirm that the type of the words.map((word) => word.length) call is Stack<number>.
In this post we used classes as carriers for generics, but what TypeScript adds to classes doesn't stop there. In the next post we'll look at public, private and protected, how they differ from JavaScript's #private, and at readonly, abstract, implements and override.