“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:
- Constraints. A function parameter can be typed (
x: number); a type parameter can be constrained (T extends string). - Default values. A function can have
function f(x = 10); a generic type can haveType<T = string>. - Substitution in use. In both cases, the parameter is just a slot, filled at the moment you consume the function or the type.
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
- We describe a generic type as a type that takes one or more type parameters.
- Each type parameter can be more or less strict with respect to the type arguments that will come to replace it, and can be given a default value.
- These type arguments are inferred by the compiler when a runtime value allows it, or supplied manually when inference is impossible, with inference remaining the option to favor in order to preserve correctness and avoid redundancy.
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.