Generics

Explains generics in CruftScript: type parameters like <T>, extends constraints, and how a parameter is filled either by inference or by an explicit type argument. The defining rule is that inference which cannot resolve a parameter is a hard error asking you to supply the argument, never a silent widening.

A generic lets you write a function or type once and reuse it across many types. A type parameter, written in angle brackets like <T>, is a placeholder for a type that gets filled in at each place you use the thing.

function process<T>(x: T): T { ... }
function first<T extends User>(x: T): T { ... }
type Box<T> = ...

The parameter T stands in for whatever type shows up at the call site. If you have used generics in TypeScript, Java, or Rust, this is the same idea: a single definition that works uniformly for every type you hand it, without giving up what the type system knows about that type.

Filling in a type parameter

There are two ways a parameter gets its concrete type.

The usual way is inference: the checker looks at the arguments you pass and works out what T must be. It collects the candidate types structurally and resolves the parameter to their best common type.

identity(42)               // T is number

The other way is to apply the type yourself, in angle brackets, and skip inference:

type R = Result<User>      // T is User, given explicitly

Explicit application is arity-checked. If a type takes one parameter and you supply two, or none, that is an error rather than a silent fit.

Constraints

A bare <T> accepts any type. When you need the parameter to have a particular shape, constrain it with extends:

function first<T extends User>(x: T): T { ... }

Now T may only be a type assignable to User, and inside the function you can use x as a User. Passing something that is not a User is an error:

first(nonUser)             // error: not assignable to `extends User`

Two constraint shapes are rejected outright. There is no any in CruftScript, so any cannot appear as a parameter, a constraint, or a return type. And T extends never is rejected, because never is the empty type: a parameter constrained to it could never be satisfied by any real value, so the constraint is meaningless.

Inference that cannot resolve is an error

If the checker cannot work out a parameter from the call, it stops and asks you. This is the single most important thing to know about generics here. A parameter with no candidates, or a return type that still mentions an unresolved parameter, is a hard error. It is never widened to a catch-all behind your back.

function pick<T, U>(a: T): U    // error: U has no candidates; supply it explicitly

The fix is to apply the type argument yourself. TypeScript in this situation would fall back to unknown (older versions, to any) and carry on; CruftScript refuses, because a silent widen is exactly the kind of guess that hides a real mistake.

Generics that leave your program

When a generic is exported and used on a value entering or leaving your program from outside it, the parameter has to become a concrete type at that point. The value crossing out has a real runtime shape, so T is resolved to a concrete type and the crossing is validated against it.

If a parameter cannot be inferred there, or the inferred type violates its constraint, the crossing is refused. A generic that stays fully inside your program can lean on inference; one that reaches the outside has to resolve completely or it does not go through.

Why it works this way

A generic is a promise that the same code is correct for every type you plug in. That promise only holds if the checker actually knows which type it is dealing with at each use. Most of the design follows from taking that seriously: no any in any generic position, because any is precisely the type the checker has stopped reasoning about; inference failure as a hard error, because a guessed parameter is an unchecked assumption; constraints enforced rather than advisory, because a T extends User you cannot rely on is worth nothing. The cost is that you sometimes have to write a type argument by hand. The return is that a generic never quietly does the wrong thing for a type you did not think about.

Limitations

  • Inference failure requires an explicit type argument. When the checker cannot resolve a parameter, you supply it in angle brackets. There is no fallback that lets the call through unresolved.
  • No any anywhere in a generic. Parameters, constraints, and return types are all held to real types. unknown is available where you genuinely do not know the type; see the unknown type.
  • Default type arguments parse, but the default is not applied yet. Writing <T = Foo> to give a parameter a fallback is accepted syntax, but the fallback is inert: using the type without its argument (type Used = Box for Box<T = string>) still reports a missing type argument. Supply the argument explicitly until the default is wired.