Generics let you write reusable code that works with many types without falling back to any. (Source: Generics.)
Hello world: identity
A specific version only works for numbers:
function identity(arg: number): number {
return arg;
}
const n = identity(42);
console.log(n); // 42
// identity("hi"); // Error: string is not assignable to number
This version is precise but inflexible. You can call identity(42) and get 42 back, but you cannot pass a string. For strings or objects you would need more copies of the same function.
Using any accepts everything but loses the output type:
function identity(arg: any): any {
return arg;
}
const a = identity("hi");
const b = identity(10);
console.log(a, b); // "hi" 10
// a.toUpperCase(); // allowed by any, but unsafe if a were not a string
Callers can pass anything, and the values print fine at runtime. The problem is TypeScript treats both results as any, so you lose autocomplete and safety after the call.
A type variable captures the argument type and reuses it for the return type:
function identity<Type>(arg: Type): Type {
return arg;
}
const output1 = identity<string>("myString");
console.log(output1.toUpperCase()); // "MYSTRING"
const output2 = identity("myString"); // inferred as string
console.log(output2.length); // 8
const output3 = identity(99);
console.log(output3 + 1); // 100
<Type> is a placeholder filled in at each call. Pass a string, get a string back (so .toUpperCase() works). Pass a number, get a number back (so + 1 works). You can write the type explicitly (identity<string>(...)) or let TypeScript infer it from the argument.
Working with type variables
You cannot assume properties that every possible Type might not have:
function loggingIdentity<Type>(arg: Type): Type {
console.log(arg.length);
// Error: Property 'length' does not exist on type 'Type'.
return arg;
}
// loggingIdentity(true); // would also be unsafe even if length were allowed
Type could be number, boolean, or anything else. Those do not all have .length, so TypeScript blocks the access. A bare type variable is opaque until you constrain it or wrap it in something with known members.
If you mean “array of Type”:
function loggingIdentity<Type>(arg: Type[]): Type[] {
console.log(arg.length);
return arg;
}
function loggingIdentity2<Type>(arg: Array<Type>): Array<Type> {
console.log(arg.length);
return arg;
}
const tags = loggingIdentity(["html", "css", "js"]);
console.log(tags); // ["html", "css", "js"] (and logs length 3)
const scores = loggingIdentity2([10, 20, 30]);
console.log(scores[0]); // 10
console.log(scores.length); // 3
Now arg is an array, and every array has .length. Calling loggingIdentity(["html", "css", "js"]) logs 3 and returns the same array typed as string[]. loggingIdentity2([10, 20, 30]) works the same for numbers. Type[] and Array<Type> mean the same thing.
Generic types and interfaces
function identity<Type>(arg: Type): Type {
return arg;
}
const myIdentity: <Type>(arg: Type) => Type = identity;
console.log(myIdentity("ok")); // "ok"
console.log(myIdentity(7)); // 7
interface GenericIdentityFn {
<Type>(arg: Type): Type;
}
const flexible: GenericIdentityFn = identity;
console.log(flexible(true)); // true
console.log(flexible({ id: 1 })); // { id: 1 }
interface GenericIdentityFnLocked<Type> {
(arg: Type): Type;
}
const locked: GenericIdentityFnLocked<number> = identity;
console.log(locked(42)); // 42
// locked("nope"); // Error: only number is allowed
You can assign identity to a typed variable and call it (myIdentity("ok")). GenericIdentityFn keeps the type parameter on each call, so flexible accepts different types per call. GenericIdentityFnLocked<number> locks the type on the interface, so locked(42) works and locked("nope") does not.
Generic classes
class GenericNumber<NumType> {
zeroValue: NumType;
add: (x: NumType, y: NumType) => NumType;
}
const myGenericNumber = new GenericNumber<number>();
myGenericNumber.zeroValue = 0;
myGenericNumber.add = function (x, y) {
return x + y;
};
console.log(myGenericNumber.zeroValue); // 0
console.log(myGenericNumber.add(5, 10)); // 15
console.log(myGenericNumber.add(myGenericNumber.zeroValue, 3)); // 3
const stringNumeric = new GenericNumber<string>();
stringNumeric.zeroValue = "";
stringNumeric.add = function (x, y) {
return x + y;
};
console.log(stringNumeric.add("Hello, ", "world")); // "Hello, world"
console.log(stringNumeric.add(stringNumeric.zeroValue, "!")); // "!"
Create a number instance with new GenericNumber<number>(), set zeroValue and add, then call add(5, 10). Create a separate string instance the same way. Each object keeps its own typed fields: the number box cannot mix in strings, and the string box concatenates instead of adding.
Or a simple box:
class Box<Type> {
contents: Type;
constructor(value: Type) {
this.contents = value;
}
}
const stringBox = new Box("hello!");
console.log(stringBox.contents); // "hello!"
console.log(stringBox.contents.toUpperCase()); // "HELLO!"
const numberBox = new Box(42);
console.log(numberBox.contents + 8); // 50
const userBox = new Box({ name: "Ada" });
console.log(userBox.contents.name); // "Ada"
new Box("hello!") infers Box<string>, so .contents is a string with string methods. new Box(42) and new Box({ name: "Ada" }) work the same way for other types. One class, many typed instances.
Generic constraints
Constrain Type so it must have certain members:
interface Lengthwise {
length: number;
}
function loggingIdentity<Type extends Lengthwise>(arg: Type): Type {
console.log(arg.length);
return arg;
}
// loggingIdentity(3); // Error: number has no length
const fromObject = loggingIdentity({ length: 10, value: 3 });
console.log(fromObject.value); // 3 (and logs length 10)
const fromString = loggingIdentity("hello");
console.log(fromString.toUpperCase()); // "HELLO" (and logs length 5)
const fromArray = loggingIdentity([1, 2, 3, 4]);
console.log(fromArray[0]); // 1 (and logs length 4)
Type extends Lengthwise means: accept only types that have a length: number. Call it with an object, a string, or an array, then keep using the returned value with its original type (value, toUpperCase(), index access). Numbers fail the constraint.
Type parameters constraining each other
function getProperty<Type, Key extends keyof Type>(obj: Type, key: Key) {
return obj[key];
}
const x = { a: 1, b: 2, c: 3, d: 4 };
const a = getProperty(x, "a");
console.log(a); // 1
const c = getProperty(x, "c");
console.log(c); // 3
// getProperty(x, "m"); // Error: '"m"' is not assignable to '"a" | "b" | "c" | "d"'
Pass a real object and a real key. getProperty(x, "a") returns 1 typed as number. Invalid keys like "m" are rejected at compile time. The return type is Type[Key], so it stays precise for each key.
Class types in generics
Factories often take a constructor type:
function create<Type>(c: { new (): Type }): Type {
return new c();
}
class BeeKeeper {
hasMask = true;
}
class ZooKeeper {
nametag = "Mikle";
}
class Animal {
numLegs = 4;
}
class Bee extends Animal {
keeper = new BeeKeeper();
}
class Lion extends Animal {
keeper = new ZooKeeper();
}
function createInstance<A extends Animal>(c: new () => A): A {
return new c();
}
const plain = create(Animal);
console.log(plain.numLegs); // 4
const lion = createInstance(Lion);
console.log(lion.numLegs); // 4
console.log(lion.keeper.nametag); // "Mikle"
const bee = createInstance(Bee);
console.log(bee.keeper.hasMask); // true
Pass the class itself (the constructor), not an instance: createInstance(Lion). That returns a live Lion you can use immediately (lion.keeper.nametag). createInstance(Bee) returns a Bee with hasMask. The generic ties the constructor you pass to the instance you get back.
Defaults
Type parameters can have defaults so callers may omit them:
interface Container<T, U = T[]> {
element: T;
children: U;
}
const textBox: Container<string> = {
element: "root",
children: ["left", "right"],
};
console.log(textBox.element); // "root"
console.log(textBox.children[0]); // "left"
const custom: Container<string, Set<string>> = {
element: "root",
children: new Set(["a", "b"]),
};
console.log(custom.children.has("a")); // true
If you write Container<string>, U defaults to string[], so children is a string array you can index. Override U when you need something else, like Set<string>, then use set methods on children. Defaults make common cases shorter without losing flexibility.
Everyday generics you already use
You have been using generics already:
const names: Array<string> = ["a", "b"];
console.log(names.join("-")); // "a-b"
const promise: Promise<number> = Promise.resolve(42);
promise.then((value) => {
console.log(value + 1); // 43
});
function first<T>(items: T[]): T | undefined {
return items[0];
}
console.log(first(["x", "y"])); // "x"
console.log(first([10, 20, 30])); // 10
console.log(first([])); // undefined
Array<string> lets you call string-array methods like join. Promise<number> means .then receives a number. first(["x", "y"]) returns "x" typed as string; first([10, 20, 30]) returns 10 typed as number. Utility types like Partial<T>, Pick<T, K>, and Record<K, V> are also generic : explore them in the Utility Types reference.
Practice
- Write
wrapInArray<T>(value: T): T[]. Call it with a string and a number and log the results. - Write
pluck<T, K extends keyof T>(items: T[], key: K): T[K][]. Use it on an array of objects. - Make a generic
Repo<T extends { id: number }>class withgetById(id: number): T | undefined. Create an instance, add items, and fetch one by id.