← Back to Blog
TypeScript

Generic, type parameter, type argument: real semantics

Restoring the true definition of a generic in TypeScript: a generic type takes type parameters, replaced by type arguments that are inferred or supplied.

📅 ✍️ Antoine Coulon
typescriptgenericstype-parameterstype-inferenceeffect

“This type has three generics.” The sentence sounds harmless, you hear it in most conferences and technical discussions, and I’ve used it myself more than once, knowingly. During my recent talks around TypeScript, notably the Paris TypeScript meetup and the recording of the Hands-On podcast, I deliberately committed this abuse of language. Simplification can serve pedagogy, but it ends up planting a confusion that’s better cleared up. It’s time to restore the true definition of a “generic.”

Three generics, really?

Take the example of an Effect<R, E, A>, which I described as a data type “with three generics.” Many conclude from this that there are, here, three generics. In everyday practice, this shortcut has little consequence. But precision about terms matters, if only to avoid losing those for whom the subject isn’t yet crystal clear.

In reality, there is only one generic type: Effect. What we wrongly call “the three generics” are in fact its three type parameters.

The nuance isn’t merely cosmetic. Confusing the generic type with its parameters amounts to confusing a function with its arguments. It’s precisely this analogy that lets us put each concept back in its place.

The exact vocabulary

Three terms, three distinct roles. Once the boundary is drawn, everything else falls into place.

The generic type

A generic type is a type that takes one or more type parameters. It’s the envelope, the parameterizable template. Effect, Promise, Array are generic types. Taken on their own, they are incomplete: they lack the information about what they contain or manipulate.

The type parameters

The type parameters are the slots declared between angle brackets at the type’s definition. In Effect<R, E, A>, those are R, E, and A. Just like a function’s parameters, they can be more or less strict: from a fully permissive any to a very precise constraint, for example a template literal type. They can also be given a default value.

The type arguments

The type arguments are the concrete types that come to replace the parameters in use. When we write:

Effect<UserRepository, UserNotFound, User>

UserRepository, UserNotFound, and User are the type arguments substituted respectively for R, E, and A. The parallel with a function is total: the parameter is what you declare, the argument is what you actually pass at the call site.

The analogy with functions

This is the most effective mental bridge for anchoring the distinction. A function function f(x) { ... } declares a parameter x; at the call f(42), 42 is the argument. A generic type Type<T> declares a type parameter T; in use Type<string>, string is the type argument.

The two mechanisms even share properties that we take for granted on the function side:

When the argument is inferred, when it must be supplied

There remains the decisive question: who supplies the type argument? Either the compiler infers it, or you have to supply it explicitly. The boundary depends on the nature of what you’re typing.

Primitives that carry a runtime value (functions, classes) let TypeScript deduce the type arguments from that value. No need to write them by hand.

Conversely, purely “type-level” definitions (a type, an interface) have no runtime value to infer from. The argument must then be supplied explicitly… unless a default parameter takes over.

The code below illustrates all of these cases, from the constrained parameter to the default argument.

/**
 * -----------------------------------------------------------------
 * A generic type is a type that takes 1 or N type parameters
 * -----------------------------------------------------------------
 */

// Effect is a generic type that takes 3 type parameters.
interface Effect<R, E, A> {}

// Promise is a generic type that takes 1 type parameter.
interface Promise<T> {}

// SomeGeneric is a generic type with a fairly strong constraint on its single parameter.
type SomeGeneric<T extends `very.constrained.parameter=${string}`> = {
  readonly value: T;
};

/**
 * -----------------------------------------------------------------------
 * These parameters must then be replaced by type arguments,
 * the same way a function's parameters are replaced by
 * arguments when the function is called.
 * -----------------------------------------------------------------------
 */

// Functions are one of the primitives that enable type inference with TypeScript.
function someGenericFunction<T>(value: T): T {
  return value;
}

// "number" is inferred here thanks to the runtime value 0, so TypeScript can deduce the type.
const valueWithTypeInference = someGenericFunction(0);

// However, for purely type-level definitions, you must explicitly supply
// the argument; TypeScript cannot infer...
type SomeGenericWithExplicitType = SomeGeneric<"very.constrained.parameter=0">;

// ...unless you provide default values for the parameters, the same way
// as for functions.
type SomeGenericWithDefaultValue<TypeParameterWithDefault = string> = {
  readonly value: TypeParameterWithDefault;
};

// Possibility of not supplying an argument, in which case TypeScript uses the default type.
type ManuallyProvidedStringValue = SomeGenericWithDefaultValue;

You find the three key moments there: someGenericFunction(0) infers number from the runtime value 0, without any type argument being written; SomeGeneric<"very.constrained.parameter=0"> requires an explicit argument for lack of a value to infer; and SomeGenericWithDefaultValue, endowed with a default parameter, can be used without any argument at all.

Prefer inference

One last principle follows directly from the above: as soon as inference is possible, you should favor it. Manually supplying an argument the compiler could have deduced introduces a redundancy, and every redundancy is an opportunity for divergence, the day the runtime value changes but not the annotation. Letting TypeScript infer preserves the program’s correctness and avoids duplicating information. You only supply the argument explicitly where inference is genuinely out of reach, typically at the purely type-level.

In summary

The next time someone tells you about a type “with three generics,” you’ll know it’s actually a single generic type with three type parameters. A vocabulary detail, sure, but it’s often by naming things accurately that we truly understand them.