Generic interfaces

Parameterise an interface so one declaration describes a family of shapes over any element type.

Signature
interface GenericIdentityFn<Type> {
  (arg: Type): Type;
}

Parameters

ParameterTypeDescription
$Typetype parameterA type variable declared after the interface name. It is in scope for every member of the interface, and must be supplied (or defaulted) wherever the interface is referenced.

Errors

Nothing at runtime — an interface emits no JavaScript at all. tsc reports error 2314 when you reference a generic interface without its required type arguments.

Changelog

  • PHP 2.3Type parameters may declare defaults, so `interface Result<T, E = Error>` can be referenced as `Result<number>`.
  • PHP 4.7Variance annotations `in` and `out` may be written on interface type parameters to speed up and pin down assignability checking.

An interface can take type parameters just as a function can. The parameter list sits after the interface name and is in scope for every member, so interface Box<T> { value: T } describes a whole family of shapes with one declaration.

Where the parameter goes matters

There are two places to put a type variable on a callable interface, and they behave differently:

  • interface Fn { <T>(arg: T): T } — the call signature is generic. Each call picks its own T, and the interface is referenced as plain Fn.
  • interface Fn<T> { (arg: T): T } — the interface is generic. T is fixed when you write Fn<number>, and every use must supply it.

When to reach for it

Use a generic interface for containers, repositories, result wrappers and event maps — anything where the surrounding structure is fixed but the payload varies. Prefer an interface over a type alias when consumers may want declaration merging or when the shape is an object; a type alias is the only option for unions and conditional types.

What trips people up

Interfaces are erased. There is no runtime value to inspect, no way to branch on T, and a variable typed Box<string> is indistinguishable at runtime from Box<number>.

Example

Example · typescript
interface Box<T> {
  value: T;
  map<U>(fn: (value: T) => U): Box<U>;
}

interface Comparer<T> {
  (a: T, b: T): number;
}

declare const numberBox: Box<number>;
declare const byLength: Comparer<string>;

// map is generic per call; Box is generic per reference
const labelled: Box<string> = numberBox.map((n) => n.toFixed(2));

console.log(labelled.value, byLength("aa", "b"));

When to use it

  • Describe a repository or cache contract once and reuse it per entity type
  • Type an API envelope such as Result<T, E> that wraps any payload
  • Define an event map interface whose handler payload varies per event name
  • Give a comparator or serialiser a named, reusable callable type

Frequently asked questions

What is the difference between a generic interface and a generic type alias?

They express the same parameterised shape, but interfaces support declaration merging and are limited to object shapes, while type aliases can also name unions, conditional types, mapped types and tuples. Choose an interface for public object contracts that consumers may extend, and a type alias for anything that is not an object shape.

Can a generic interface have a default type argument?

Yes, since TypeScript 2.3: interface Result<T, E = Error> can be referenced as Result<number>, with E resolving to Error. Defaults must satisfy the parameter's constraint and cannot be followed by required type parameters.

Why must I write the type argument every time I reference the interface?

Because a type parameter without a default is required — referencing Box on its own is error 2314, "Generic type 'Box<T>' requires 1 type argument(s)". Either supply the argument, give the parameter a default, or move the parameter down onto the individual call signature so the interface itself is not generic.

More examples

A repository contract reused for two entities

One interface serves every entity that has an id. The output is "[email protected] 1". Note that the parameters of find and add need no annotations inside makeRepository — they are contextually typed by Repository<T>, which is the main practical payoff of naming the contract.

Example · typescript
interface Identified {
  id: string;
}

interface Repository<T extends Identified> {
  readonly all: ReadonlyArray<T>;
  find(id: string): T | undefined;
  add(entity: T): void;
}

interface User extends Identified {
  email: string;
}

function makeRepository<T extends Identified>(): Repository<T> {
  const items: T[] = [];
  return {
    get all() {
      return items;
    },
    find: (id) => items.find((item) => item.id === id),
    add: (entity) => {
      items.push(entity);
    },
  };
}

const users: Repository<User> = makeRepository<User>();
users.add({ id: "u1", email: "[email protected]" });
console.log(users.find("u1")?.email, users.all.length);

Generic call signature versus generic interface

identity accepts both a string and a number because each call binds its own T; numberIdentity would reject "text" because T was fixed to number in the type annotation. The output is "text 1 42" — identical at runtime, which is exactly why the distinction has to be made in the type.

Example · typescript
// The call signature is generic: one interface, many T per call
interface IdentityFn {
  <T>(arg: T): T;
}

// The interface is generic: T is chosen when the type is written
interface TypedFn<T> {
  (arg: T): T;
}

const identity: IdentityFn = (arg) => arg;
const numberIdentity: TypedFn<number> = (arg) => arg;

console.log(identity("text"), identity(1), numberIdentity(42));

Discussion

  • Be the first to comment on this lesson.