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

  1. Write wrapInArray<T>(value: T): T[]. Call it with a string and a number and log the results.
  2. Write pluck<T, K extends keyof T>(items: T[], key: K): T[K][]. Use it on an array of objects.
  3. Make a generic Repo<T extends { id: number }> class with getById(id: number): T | undefined. Create an instance, add items, and fetch one by id.