C# 12 lifted a long-standing restriction on the using alias directive. Before C# 12, the right side of using Alias = ...; had to be a named type or a namespace. As of C# 12, it can be almost any type the compiler knows about, including tuples, constructed generics, arrays, pointers, and nullable value types. The result is shorter, more readable code in the places where C#'s type names had grown the most uncomfortable: long generic signatures, repeated tuple shapes, and nested array types.
This lesson covers what the new alias form lets you do, the rules and limits the compiler enforces, and the patterns that actually pay off in real e-commerce code.
In C# 11 and earlier, the alias directive accepted three shapes on its right side: a namespace, a non-generic named type, or an open generic type (the latter only in a very narrow form). Code like this compiled:
Actually, that line is a good example of what didn't work. Pre-C# 12, the compiler rejected aliases to constructed generic types, so the line above failed with CS0305 or similar. You could alias System.Collections.Generic.List only as a name, not as List<int>. You also couldn't alias a tuple, an array type, or a pointer.
C# 12 removed those restrictions. The right side can now be:
| Shape | Pre-C# 12 | C# 12+ |
|---|---|---|
Named type (System.Console) | Allowed | Allowed |
Namespace (System.IO) | Allowed | Allowed |
Constructed generic (List<int>) | Not allowed | Allowed |
Tuple ((string, int)) | Not allowed | Allowed |
Array (int[], string[][]) | Not allowed | Allowed |
Nullable value type (int?) | Not allowed | Allowed |
Pointer (int*) | Not allowed | Allowed (in unsafe context) |
Function pointer (delegate*<int, int>) | Not allowed | Allowed |
The directive still applies only to the file it appears in (unless prefixed with global, more on that later), and it still produces no runtime artifact. The alias is pure compile-time substitution. The compiler resolves every use of the alias to the underlying type before generating IL.
A small diagram captures how the alias maps to the real type.
The alias never reaches the runtime. The assembly metadata sees only the underlying tuple, generic, or array type. That has implications for type identity, which the lesson returns to later.
The most useful new shape is the tuple alias. Tuples are great for ad hoc grouping, but the type signature (string, int, decimal) doesn't carry meaning. A reader has to guess what each field represents, and tuple field names don't survive method boundaries unless they're part of the declared type. An alias gives the tuple a name once, and the name follows it everywhere.
The alias keeps the field names. mouse.Name, mouse.Quantity, and mouse.UnitPrice work because the alias declared them. Without the alias, the variable would have to be typed as (string Name, int Quantity, decimal UnitPrice) at every declaration, or as the bare (string, int, decimal) with field names like Item1, Item2, Item3.
The alias also smooths out method signatures. A method that takes or returns a CartLine reads like a method that takes or returns a normal type:
List<CartLine> is far more readable than List<(string Name, int Quantity, decimal UnitPrice)>. The signature of SumLines tells you immediately what kind of data it processes. The shape of the tuple is still visible in one place (the alias declaration at the top of the file), which is exactly where a reader looks first.
Tuple aliases are most useful when the same tuple shape appears in many places. If a cart line shows up in three files, declaring using CartLine = (...) in each file (or once globally) means a single source of truth for the field names. Rename Quantity to Qty and every file picks up the change.
A tuple alias does not create a new struct. CartLine is still ValueTuple<string, int, decimal>, with the same size, layout, and copy semantics as any other tuple. Aliasing is free at runtime.
The second high-value pattern is aliasing a constructed generic type. Dictionary and list types with several type parameters and namespace-qualified element types produce signatures that are painful to read and even more painful to write repeatedly.
Consider a service that maintains a lookup from order ID to a list of order events:
The alias collapses a 76-character type name into OrderEventLog. Every method signature, field type, and local variable that handles this structure now uses the short form. If the underlying shape changes (say, the value becomes a List<OrderEvent> instead of List<string>), the change happens in one place.
You can mix aliases with normal generic usage. The alias defines a specific construction, but you can still write the original form elsewhere in the same file when it's clearer:
PriceMap and Dictionary<int, decimal> refer to the same runtime type. You can assign one to the other, pass either to a method that expects the other, and the compiler sees no difference. The alias is a name for the file, not a distinct type.
The new alias form accepts more than tuples and generics. Any constructed type that the compiler can spell on the right side of = is fair game, with a couple of restrictions covered later.
Arrays:
ProductIds is just int[]. PriceMatrix is decimal[][]. Both aliases are usable anywhere the original array type would work, which is everywhere arrays are accepted.
Nullable value types:
The alias OptionalDiscount stands for decimal? which is shorthand for Nullable<decimal>. The alias and the original are interchangeable.
Pointers and function pointers are also allowed, with the caveat that pointer types only exist in an unsafe context. Pointer aliases are useful in low-level interop and performance-sensitive code, which most e-commerce work never touches, but the door is open:
You can declare those aliases, but you can only use the pointer types inside an unsafe block in a project that has <AllowUnsafeBlocks>true</AllowUnsafeBlocks> in the .csproj file. The alias declaration itself doesn't trigger an unsafe context; the usage does.
A diagram summarizes the types the new form accepts:
The green boxes (named type, namespace) were always allowed. The orange boxes are what C# 12 added.
global using AliasesAn alias declared with using X = ...; is file-scoped. It applies from the top of the file (where the directives live) to the bottom, and nowhere else. Another file in the same project sees nothing. This is the same behavior as a normal using directive.
That's a deliberate design choice. File-scoped aliases let one file pick its own preferred name for a complex type without forcing the choice on the rest of the codebase. Two files in the same project can each alias Dictionary<int, decimal> under different names (PriceMap in one, PriceTable in another), and neither alias leaks.
When the same alias should be available across the entire project, the global using form does the job:
A global using lives in any one file in the project (conventionally GlobalUsings.cs). Every other file in the same compilation unit picks up the alias as if it were declared locally. You don't repeat the using line at the top of every file.
The same restrictions apply. A global using alias can target any of the types the file-scoped form accepts. The visibility is project-wide instead of file-scoped.
The orange path shows a file-local alias: only Cart.cs sees it. The cyan path shows a global alias: every file in the project sees it.
A few practical notes on choosing between the two. Project-wide aliases for genuinely cross-cutting concepts (like an OrderId alias for int that documents intent across the whole codebase) belong in global using. Aliases that exist only to clean up one file's signatures belong as local using directives. Mixing them is fine.
The C# 12 expansion is wide but not unlimited. A few categories of things still don't work on the right side of =.
You can't alias a method or a property. Aliases name types, not members. using PrintHello = Console.WriteLine; won't compile; the alias must resolve to a type.
You can't alias an instance of a delegate (a callable value). You can alias the delegate type itself, but not a specific captured invocation. using SayHi = () => Console.WriteLine("Hi"); is not valid syntax. To get a similarly named callable, declare a local function or a static readonly field of a delegate type.
You can't alias dynamic directly. dynamic is a compile-time fiction over object, and the alias form doesn't accept it. Aliasing object and using it where dynamic would have been used is technically possible but loses the dynamic dispatch.
You can't alias unbound generics in the form you might hope for. using Box = List<>; is not valid; the type arguments must be filled in. Alias to a specific construction like List<int>, or alias the open type's name only: using GenericList = System.Collections.Generic.List; works (the List name without arguments), but uses of GenericList<int> then have to spell out the arguments at each call.
You can't alias ref types or Span<T> to escape their stack-only restrictions. The alias inherits whatever the underlying type allows. If Span<T> can't be stored in a field, neither can a Span<T> alias.
These limits don't usually matter in practice. The expanded form covers the cases where engineers wanted aliases the most. The remaining gaps are either niche or have other language features that handle the same need better.
None of these restrictions have a workaround that makes the alias "almost work." If the right side isn't a type the compiler can spell, the directive simply doesn't compile. Pick a different solution (a wrapper struct, a method, a record) and move on.
typedefC and C++ developers may compare this feature to typedef. The two are closely related, with a few important differences.
| Trait | C# alias (using) | C/C++ typedef |
|---|---|---|
| Creates a new type | No, just a name | No, just a name |
| Affects overload resolution | No | No |
| Visible across files by default | No (file-scoped) | Yes (header-scoped) |
| Project-wide form available | Yes (global using) | Yes (via header inclusion) |
| Aliases tuples and generics | Yes (C# 12+) | Yes (templates and structs) |
| Survives in compiled metadata | No | No |
The semantic role is the same in both languages: a typedef or using alias gives a new name to an existing type without creating a new type. Code compiled against the alias and code compiled against the original are interoperable. Neither language treats the alias as a distinct type for purposes of method overloading or type checking.
The biggest behavioral difference is scoping. typedef in C/C++ lives in whatever scope it appears in, which usually means it's visible everywhere the containing header is included. C#'s file-scoped using is narrower by default, with global using as the explicit opt-in to project-wide visibility. The C# design forces a small decision (file-local or global?), which is clearer than the implicit propagation through C-style includes.
The other difference is that typedef (and using in C++11) can name function types in a way C# doesn't directly support. typedef int (*BinaryOp)(int, int); in C aliases a function pointer type with a single name. C# 12 supports the equivalent for managed function pointers in unsafe code (using FastBinaryOp = delegate*<int, int, int>;), but for normal delegate types, you declare the delegate once and use its name without an alias.
A few patterns come up often enough in C# 12+ code to be worth naming.
Repository keys. Repositories and lookup-heavy services often deal with multiple types of IDs (customer ID, order ID, product ID), all backed by int or Guid. A pair of aliases documents intent without inventing wrapper structs:
This is documentation, not type safety. CustomerId and OrderId are both int, so the compiler will let you pass a customer ID where an order ID is expected. The aliases just make the intent of each parameter and field easier to read. If you need real type safety, use a readonly record struct instead. The trade-off is more boilerplate for the stronger guarantee.
Domain tuples. Tuples are the right shape when you want grouping without OOP ceremony. Aliasing the tuple gives it a name that survives method signatures:
The alias makes the report code read like it's working with a typed OrderSummary. The runtime type is ValueTuple<int, string, decimal>, so this is genuinely free; no class allocation, no GC pressure.
Taming long generic signatures. Factory and configuration code often produces signatures with three or four nested generic parameters. Aliasing the long form once at the top of the file removes the visual noise:
The method or class would otherwise spell that 80-character type at every signature. The alias makes the rest of the file readable, and the underlying nested type appears in exactly one place.
The most common misunderstanding is treating an alias like a new type. It is not. An alias is a renaming, full stop. A few consequences flow from that.
The runtime sees only the underlying type. typeof(CartLine).Name returns ValueTuple\3 if CartLine aliases a three-element tuple, because the alias does not survive into reflection or assembly metadata. Two different aliases for the same underlying type return the same Type` object.
Method overloading does not distinguish aliases. A method with void Save(CustomerId id) and a method with void Save(OrderId id) produce CS0111 ("Type already defines a member with the same parameter types") because both signatures collapse to void Save(int) after the aliases are resolved.
Pattern matching does not distinguish aliases. obj is CartLine line and obj is (string, int, decimal) line match the same set of values.
Implicit conversion rules apply to the underlying type, not the alias. An IntArray alias for int[] accepts any int[] value with no cast, and any int[] variable accepts an IntArray value with no cast.
The alias is a service to the human reader, not a guarantee enforced by the compiler. Treat it like a comment that the compiler also understands. When you need an actual new type, use readonly record struct or struct instead.
Mistaking an alias for a new type leads to bugs that compile but misbehave at runtime. The most common case is two aliases for int (or Guid) and a function that swaps them silently. If the values represent genuinely different domains, accept the boilerplate of a wrapper struct.
10 quizzes