Skip to content

RFC: Generic aliases - #28

Open
stakach wants to merge 12 commits into
crystal-lang:masterfrom
stakach:rfc/generic-aliases
Open

stakach wants to merge 12 commits into
crystal-lang:masterfrom
stakach:rfc/generic-aliases

Conversation

@stakach

@stakach stakach commented May 23, 2026

Copy link
Copy Markdown

Rendered: https://github.com/stakach/rfcs/blob/rfc/generic-aliases/text/0028-generic-aliases.md

Summary

Allow alias declarations to take type parameters so a single declaration can stand in for a family of substituted types:

alias Maybe(T)        = T | Nil
alias StringKeyed(V)  = Hash(String, V)
alias Pair(K, V)      = Tuple(K, V)

A generic alias is usable everywhere a regular type is — as a restriction, in type declarations, inside generic instantiations, as a metaclass expression, and as a forall T restriction — and is purely substitutional (no new runtime type, no new dispatch).

Motivation

#2803 — today there's no way to name a parameterised shape without either copy-pasting per-variant aliases (MaybeInt32, MaybeString, …) or introducing a wrapper class with runtime cost. The RFC body covers prior art (Rust, Scala 3, TypeScript, Haskell) and the rationale for choosing transparent substitution over a nominal wrapper.

Status

Drafted at the level of user-visible semantics; intentionally avoids prescribing compiler internals so the implementation has room to land however reviewers think is cleanest. Splat parameters and parameter constraints are explicitly listed as out of scope / unresolved for follow-up RFCs.

Comment thread text/0028-generic-aliases.md Outdated

## Summary

Allow `alias` declarations to take type parameters, so an alias body can refer to those parameters and resolve to a different concrete type at each use site:

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

polish: I don't think "alias body" is an established term. We should consider if we want to use that here or prefer a different one.

"alias value" could be an alternative (following Crystal::Alias#value).

Comment thread text/0028-generic-aliases.md Outdated
Comment thread text/0028-generic-aliases.md Outdated
Comment thread text/0028-generic-aliases.md Outdated
Comment on lines +155 to +158
1. **As a metaclass expression.** `Maybe(Int32)` evaluates to the metaclass `(Int32 | Nil).class`.
2. **As a restriction or type declaration.** `: Pair(K, V)` in an argument restriction, type declaration, or `@x : Maybe(Int32)` instance var declaration. Matching proceeds against the substituted type.
3. **Inside generic instantiations.** `Hash(String, Maybe(V))` resolves to `Hash(String, V | Nil)`.
4. **Inside `forall T` restrictions.** When the alias body itself contains a free variable bound by the surrounding `forall`, the matcher expands the alias before unification.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

question: Does this enumeration add relevant information?
Particularly the last point is confusing. The rest seems redundant.

Comment thread text/0028-generic-aliases.md Outdated
Comment on lines +176 to +188
### How it composes with existing features

- **Restrictions.** A generic alias appearing in a restriction expands inside the matcher _before_ the existing restriction algorithm runs. The matcher sees only the substituted form, so `def f(x : Maybe(T)) forall T` behaves identically to `def f(x : T | Nil) forall T`.

- **Generic instantiations.** A generic alias passed as a type argument to another generic (`Array(Maybe(Int32))`) is substituted first, then the outer generic is instantiated with the result.

- **`typeof`.** Already substitutes through aliases; generic aliases just extend that substitution to take arguments.

- **`is_a?` / `as` / `responds_to?`.** Operate on the substituted type. `x.is_a?(Maybe(Int32))` is equivalent to `x.is_a?(Int32 | Nil)`.

- **`{% ... %}` macros.** A generic alias is a `TypeNode` like any other. `Maybe(Int32).resolve` returns the resolved type of the substituted body. Macros that walk type expressions see the alias name and its arguments, not the expanded body, until they call `.resolve`.

- **Documentation.** A generic alias is documented as `Maybe(T)` with `T` as a free type parameter, exactly as a generic class would be.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

question: I believe this section could be summarized as "generic aliases resolve in the same order as non-generic ones"

Comment thread text/0028-generic-aliases.md Outdated

## Unresolved questions

- **Variadic type parameters.** Whether `alias Foo(*T) = ...` should mean "splat into a tuple body" or something more general. Punted to a follow-up RFC.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

question: What's particularly problematic about splat parameters? I don't understand the challenges with that.

@beta-ziliani beta-ziliani left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would really like to have this feature! Thanks Stephen!

While I work through it, I wrote down a few small comments that I prefer to submit prior to the full review.

alias MaybeUser = User | Nil
```

This is the central case raised in [#2803](https://github.com/crystal-lang/crystal/issues/2803). It also shows up wherever a project ports a generic shape from another language or stdlib idiom (`Optional<T>`, `Result<T, E>`, `Map<K, V>`-style wrappers) — without generic aliases, the standard workaround is to introduce an empty subclass or module, which carries dispatch and runtime cost that an alias would not.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

... without generic aliases, the standard workaround is to introduce an empty subclass or module, which carries dispatch and runtime cost that an alias would not.

Is this true? The dispatch will be there for an alias as well when it's a union.


## Drawbacks

- **More syntax to learn.** Today `alias` is a one-shape declaration; this adds a second shape. The notation is the same one already used for generic classes, so the cost is modest, but it is non-zero.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just as a comment, for me learning that aliases were not generic was the surprise, so in my head this proposal simplifies syntax instead of making it harder.


## Rationale and alternatives

- **Just use a class or module.** Today's workaround is `class Maybe(T) < (T | Nil); end` (which doesn't compile because you can't inherit a union), or a wrapper class with delegation. The first doesn't work; the second introduces runtime overhead and breaks reference equality with the underlying value. A purely substitutional generic alias is the only way to give the shape a name without changing the runtime model.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't get why show a workaround that doesn't work.


Recursive _non-generic_ aliases remain supported (they are how recursive structural types like `Json` are expressed in Crystal today). The alias body is evaluated lazily and the recursion bottoms out where the body refers to the alias name in a position that doesn't immediately require the resolved type.

A generic alias that recursively references itself with the same type parameters expands like any other recursive alias. A generic alias that references itself with _different_ type parameters expands the substitution at each use; if no fixed point exists, the compiler reports a "recursive alias can't be expanded" error at the use site, mirroring the existing behaviour for non-generic aliases that can't be resolved.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What exactly happens if I write:

alias Y(F) = F(Y(F))

alias Erase(T) = Erase

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The second is ill-formed, as it leads to an infinite substitution. It's no different from alias Erase = Erase or alias Erase(T) = Erase(T). Right?

AFAIK, the first one should be OK as long as there's progress (F has a head type constructor, like a Union or a type that itself has a head type constructor).

wrong number of type vars for Pair (given 1, expected 2)
```

A non-generic alias used with type arguments (`Maybe(Int32)` where `Maybe` was declared without parameters) is also an error, with the same wording.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What about the opposite? Is it valid at all to use Maybe in restrictions or runtime code, just as you can sometimes use Array alone without instantiating it?


### Syntax

`alias` declarations gain an optional parenthesised list of type parameters after the name:

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Because this is a syntax addition, all the required changes to the Alias AST node's macro interface must also be included.

Comment thread text/0028-generic-aliases.md Outdated
A generic alias works as a method-argument restriction in the same way the substituted type would:

```crystal
alias Pair(K, V) = Tuple(K, V)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The problem with these snippets is that I can simply write alias Pair = Tuple and everything will just work as before; the only added value is arity checking.

alias Maybe(T) = T | Nil
alias StringKeyed(V) = Hash(String, V)
alias Pair(K, V) = Tuple(K, V)
```

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is alias Apply(F, T) = F(T) well-formed?

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also what happens if an alias resolves to another uninstantiated generic type? If I write Boxed(Array) then it would resolve to Array, but Boxed(Array)(Int32) is currently invalid syntax. What do you think about these types?

# Pair(Symbol, Maybe(Int32)) ≡ Tuple(Symbol, Int32 | Nil)
```

No new type is introduced; in particular, `Maybe(Int32)` and `Int32 | Nil` are the _same_ type for every purpose the compiler cares about (`is_a?`, `==`, virtual dispatch, generic instantiation cache keys).

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What about 1.is_a?(Maybe) or Maybe(Int32).is_a?(Maybe.class)? (see my other comment about using Maybe alone)

Comment thread text/0028-generic-aliases.md Outdated

### With `forall`

Type parameters of a generic alias can themselves be left unbound and unified by a `forall T` clause. The alias body is expanded inside the matcher before unification, so the surrounding `forall` sees the substituted form:

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The way free variables are bound in the compiler is not a unification algorithm, yet.

alias Pair(K, V) = Tuple(K, V)
```

A generic alias is usable everywhere a regular type is: as a type restriction, in type declarations, in generic instantiations, in metaclass expressions, and as the restriction of a `forall T` method.

@HertzDevil HertzDevil May 26, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Probably not everywhere, since they generally do not make sense when reopened, whereas non-generic aliases do:

# implies generic specialization for `Hash(String, Int32)`? (#3298)
class StringKeyed(Int32)
end

# implies partial generic specialization for `Hash(String, V)`?
class StringKeyed(V)
end

# ?
class StringKeyed
end

stakach and others added 8 commits May 27, 2026 07:13
Co-authored-by: Johannes Müller <straightshoota@gmail.com>
Co-authored-by: Johannes Müller <straightshoota@gmail.com>
Co-authored-by: Johannes Müller <straightshoota@gmail.com>
Co-authored-by: Johannes Müller <straightshoota@gmail.com>
Co-authored-by: Johannes Müller <straightshoota@gmail.com>
Co-authored-by: Johannes Müller <straightshoota@gmail.com>
Co-authored-by: Johannes Müller <straightshoota@gmail.com>
Co-authored-by: Johannes Müller <straightshoota@gmail.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants