C# generics are real at run time. The type argument survives compilation. So the code can ask what T is, make a T[], and add two T values.
You already use List<T> and Dictionary<TKey, TValue>. This page covers writing generic code well. Constraints say what T can do. Variance says when one generic type can stand in for another. Static abstract members and generic math let T bring its own operators. Each feature has a tested sample, traps, and interview questions.
T what its constraints allow. Every feature here adds a new kind of constraint. Or it follows from T being known at run time.A where clause limits which types can fill T. In return, the code may use what the constraint promises.
class and struct (C# 2.0): T is a reference type, or a non-nullable value type.new() (C# 2.0): T has a public parameterless constructor.unmanaged, Enum, Delegate (C# 7.3): T is a struct with no references, an enum, or a delegate.notnull (C# 8): T is not a nullable type. It is checked by nullable analysis, so a breach is a warning.unmanaged unlocks sizeof(T), pointers, and raw byte views of T./// <summary>A base class for the base-class constraint demo.</summary>
public abstract class Entity
{
public int Id { get; init; }
}
/// <summary>A sample entity.</summary>
public sealed class Customer : Entity
{
public string Name { get; init; } = "";
}
/// <summary>A sample enum for the Enum constraint.</summary>
public enum Color { Red, Green, Blue }
/// <summary>A store that can only hold entities. The constraint lets it read Id.</summary>
public sealed class Repo<TEntity> where TEntity : Entity
{
private readonly Dictionary<int, TEntity> _byId = [];
/// <summary>Adds or replaces an entity by its Id.</summary>
/// <param name="item">The entity.</param>
/// <example>repo.Save(new Customer { Id = 1 }) stores it under key 1.</example>
public void Save(TEntity item) => _byId[item.Id] = item; // Id is visible via Entity
/// <summary>Finds an entity by Id.</summary>
/// <param name="id">The key.</param>
/// <returns>The entity, or null when missing.</returns>
/// <example>repo.Find(1) returns the customer saved with Id 1.</example>
public TEntity? Find(int id) => _byId.GetValueOrDefault(id);
}
public static class Constraints
{
/// <summary>Largest item. IComparable lets the code call CompareTo.</summary>
/// <param name="items">A non-empty sequence.</param>
/// <returns>The maximum.</returns>
/// <example>Constraints.Largest(["pear", "fig", "apple"]) returns "pear".</example>
public static T Largest<T>(IEnumerable<T> items) where T : IComparable<T>
{
using var e = items.GetEnumerator();
if (!e.MoveNext()) throw new InvalidOperationException("empty sequence");
T best = e.Current;
// Invariant: best is the max of the items seen so far.
while (e.MoveNext())
{
// > 0 means e.Current sorts after best.
if (e.Current.CompareTo(best) > 0) best = e.Current;
}
return best;
}
/// <summary>Makes n fresh objects. new() lets the code call the constructor.</summary>
/// <param name="n">How many to make.</param>
/// <returns>A list of n new instances.</returns>
/// <example>Constraints.MakeMany<List<int>>(2).Count returns 2.</example>
public static List<T> MakeMany<T>(int n) where T : new()
{
var list = new List<T>(n); // capacity n, the exact final size
for (int i = 0; i < n; i++) list.Add(new T());
return list;
}
/// <summary>Parses text to a value type, or null. struct lets T? mean Nullable.</summary>
/// <param name="text">The text to parse.</param>
/// <returns>The value, or null when parsing fails.</returns>
/// <example>Constraints.ParseOrNull<int>("x") returns null.</example>
public static T? ParseOrNull<T>(string text) where T : struct, IParsable<T> =>
T.TryParse(text, null, out T value) ? value : null; // null: no format provider
/// <summary>Size in bytes of an unmanaged type. Needs unsafe for sizeof(T).</summary>
/// <returns>The size of T.</returns>
/// <example>Constraints.SizeOf<long>() returns 8.</example>
public static unsafe int SizeOf<T>() where T : unmanaged => sizeof(T);
/// <summary>All names of an enum, with no typeof or casts.</summary>
/// <returns>The member names in declared order.</returns>
/// <example>Constraints.Names<Color>() returns "Red,Green,Blue".</example>
public static string Names<T>() where T : struct, Enum =>
string.Join(",", Enum.GetValues<T>());
/// <summary>Groups words by a key that must not be null.</summary>
/// <param name="words">The words.</param>
/// <param name="key">Picks the group key. notnull matches Dictionary's own rule.</param>
/// <returns>A count per key.</returns>
/// <example>Constraints.CountBy(["a", "bb", "cc"], w => w.Length)[2] returns 2.</example>
public static Dictionary<TKey, int> CountBy<TKey>(IEnumerable<string> words,
Func<string, TKey> key) where TKey : notnull
{
var counts = new Dictionary<TKey, int>();
foreach (var w in words)
{
var k = key(w);
counts[k] = counts.GetValueOrDefault(k) + 1; // + 1 for this word, 0 if new
}
return counts;
}
}
new() uses reflection-like code in some runtimes. In hot paths pass a Func<T> factory instead.struct excludes Nullable<T>. int? does not satisfy where T : struct.class or struct comes first and new() comes last.a + b on an unconstrained T? A: Nothing promises T has +. Before C# 11 there was no constraint for operators. Now INumber<T> provides one.unmanaged allow? A: T has no reference fields, at any depth. So you can take sizeof(T), use T*, and view T as raw bytes.Before C# 13, a ref struct such as Span<T> could never be a type argument. where T : allows ref struct is an anti-constraint. It widens the set of allowed types to include ref structs. In exchange, the method must treat T by ref struct rules. No boxing, no class fields, and no capture in lambdas.
C# 13 also lets a ref struct implement interfaces. Together, these let generic code run over stack-only types.
Func<…>, Action<…>, and many interfaces with allows ref struct. So a lambda can now take a span parameter through a generic delegate./// <summary>Anything with a length. A ref struct may implement it in C# 13.</summary>
public interface IHasLength
{
int Length { get; }
}
/// <summary>A stack-only window over text that implements an interface.</summary>
public ref struct TextWindow : IHasLength
{
private readonly ReadOnlySpan<char> _text;
public TextWindow(ReadOnlySpan<char> text) => _text = text;
public int Length => _text.Length;
}
public static class RefStructGenerics
{
/// <summary>Reads Length from any T, including ref structs.</summary>
/// <param name="value">The value. It is never boxed.</param>
/// <returns>value.Length.</returns>
/// <example>RefStructGenerics.LengthOf(new TextWindow("abc")) returns 3.</example>
public static int LengthOf<T>(T value) where T : IHasLength, allows ref struct =>
value.Length;
/// <summary>Applies a function to a value that may be a ref struct.</summary>
/// <param name="value">The input.</param>
/// <param name="f">Func is marked allows ref struct in .NET 9.</param>
/// <returns>f(value).</returns>
/// <example>RefStructGenerics.Apply("hey".AsSpan(), s => s.Length) returns 3.</example>
public static TResult Apply<T, TResult>(T value, Func<T, TResult> f)
where T : allows ref struct => f(value);
/// <summary>Runs both helpers on stack-only values.</summary>
/// <returns>The two lengths.</returns>
/// <example>RefStructGenerics.Demo() returns "5 2".</example>
public static string Demo()
{
int a = LengthOf(new TextWindow("hello"));
// Span<char> as a type argument: illegal before C# 13.
int b = Apply("hi".AsSpan(), s => s.Length);
return $"{a} {b}";
}
}
object o = value; or a lambda that captures value no longer compiles.allows ref struct.Variance says when a generic type built from Derived can be used where one built from Base is expected. Only interfaces and delegates can be variant, and only for reference type arguments.
out T: T only comes out. IEnumerable<string> is an IEnumerable<object>. Direction is preserved.in T: T only goes in. IComparer<object> is an IComparer<string>. Direction flips.List<T>. No conversion at all.Reading the figure. Grey boxes are the element types. The grey arrow says a string converts to object. Green is covariant: the generic arrow points the same way. Violet is contravariant: the arrow points down. A comparer that handles any object can surely compare strings. Red is invariant, with no arrow. A List<object> would let you add an int to a list of strings.
out T when T appears only in return positions. Mark it in T when T appears only as a parameter.IComparer<Entity> sorts customers, orders, and anything else derived from Entity.Func<in T, out TResult> and Action<in T> are already variant./// <summary>Produces T values. T only comes out, so the interface is covariant.</summary>
public interface IProducer<out T>
{
T Make();
}
/// <summary>Consumes T values. T only goes in, so the interface is contravariant.</summary>
public interface IConsumer<in T>
{
void Take(T item);
}
/// <summary>Makes customers.</summary>
public sealed class CustomerMaker : IProducer<Customer>
{
public Customer Make() => new() { Id = 7, Name = "Ann" }; // 7 is a sample id
}
/// <summary>Logs the id of any entity.</summary>
public sealed class EntityLogger : IConsumer<Entity>
{
public List<int> Seen { get; } = [];
public void Take(Entity item) => Seen.Add(item.Id);
}
public static class Variance
{
/// <summary>Uses covariance and contravariance once each.</summary>
/// <returns>The id produced and the ids logged.</returns>
/// <example>Variance.Demo() returns "7 7".</example>
public static string Demo()
{
// Covariance: a producer of Customer is a producer of Entity.
IProducer<Entity> producer = new CustomerMaker();
// Contravariance: a consumer of Entity can consume Customers.
var logger = new EntityLogger();
IConsumer<Customer> consumer = logger;
consumer.Take((Customer)producer.Make());
return $"{producer.Make().Id} {string.Join(",", logger.Seen)}";
}
/// <summary>Sorts strings with a comparer written for object.</summary>
/// <returns>The strings sorted by length.</returns>
/// <example>Variance.SortWithObjectComparer() returns ["b", "aa", "ccc"].</example>
public static List<string> SortWithObjectComparer()
{
// ToString()! is safe here because every item is a non-null string.
IComparer<object> byLength = Comparer<object>.Create(
(x, y) => x.ToString()!.Length.CompareTo(y.ToString()!.Length));
var words = new List<string> { "ccc", "b", "aa" };
words.Sort(byLength); // IComparer<object> used as IComparer<string>
return words;
}
/// <summary>Array covariance compiles but fails at run time.</summary>
/// <example>Variance.ArrayCovarianceTrap() throws ArrayTypeMismatchException.</example>
public static void ArrayCovarianceTrap()
{
object[] items = new string[1]; // 1 slot is enough to show the problem
items[0] = 42; // the real array holds strings, so this throws
}
}
IEnumerable<int> does not convert to IEnumerable<object>. Each int would need a box.List<string> to List<object> is a compile error. Use IReadOnlyList<object>, which is covariant.out T if T appears as a method parameter. That is the rule working, not a bug.IList<T> invariant but IEnumerable<T> covariant? A: IList has Add(T), so T goes in and out. IEnumerable only hands T out.Action<object> logger can be passed where an Action<string> is expected. Anything that accepts any object accepts a string.An interface can now declare static abstract members: static methods, properties, and operators that each implementing type must supply. static virtual members give a default the type may replace. Generic code then calls them on the type parameter, as in T.Zero or a + b.
The usual shape is the curiously recurring pattern: interface IShape<TSelf> where TSelf : IShape<TSelf>. It lets a static member return the implementing type itself.
T.Empty, T.Parse(text), or T.Unit()./// <summary>A shape type that can describe itself without an instance.</summary>
public interface IShape<TSelf> where TSelf : IShape<TSelf>
{
static abstract string Name { get; }
static abstract TSelf Unit();
static virtual int Corners => 0; // 0: the default for shapes with no corners
double Area { get; }
}
/// <summary>A square implements every static member, and overrides Corners.</summary>
public sealed record Square(double Side) : IShape<Square>
{
public static string Name => "square";
public static Square Unit() => new(1); // 1: a unit side length
public static int Corners => 4; // 4: a square has four corners
public double Area => Side * Side;
}
/// <summary>A circle keeps the default Corners.</summary>
public sealed record Circle(double Radius) : IShape<Circle>
{
public static string Name => "circle";
public static Circle Unit() => new(1); // 1: a unit radius
public double Area => Math.PI * Radius * Radius;
}
/// <summary>Types that can be added and have a zero. Operators in an interface.</summary>
public interface IAddable<TSelf> where TSelf : IAddable<TSelf>
{
static abstract TSelf Zero { get; }
static abstract TSelf operator +(TSelf a, TSelf b);
}
/// <summary>A 2D vector that supports + and Zero.</summary>
public readonly record struct Vec2(int X, int Y) : IAddable<Vec2>
{
public static Vec2 Zero => new(0, 0); // (0, 0): adding it changes nothing
public static Vec2 operator +(Vec2 a, Vec2 b) => new(a.X + b.X, a.Y + b.Y);
}
public static class StaticAbstracts
{
/// <summary>Describes a shape type using only static members.</summary>
/// <returns>Name, corner count, and the area of the unit shape, rounded.</returns>
/// <example>StaticAbstracts.Describe<Square>() returns "square 4 1".</example>
public static string Describe<T>() where T : IShape<T> =>
$"{T.Name} {T.Corners} {Math.Round(T.Unit().Area, 2)}"; // 2 decimal places
/// <summary>Sums any IAddable type, starting from its own Zero.</summary>
/// <param name="items">The values.</param>
/// <returns>The total. Zero for an empty list.</returns>
/// <example>StaticAbstracts.SumAll([new Vec2(1, 2), new Vec2(3, 4)]) is (4, 6).</example>
public static T SumAll<T>(IEnumerable<T> items) where T : IAddable<T>
{
T total = T.Zero;
foreach (var item in items) total += item; // operator + from the interface
return total;
}
}
List<IShape<Square>> fails with CS8920, because the interface itself has no static implementation.Circle.Corners does not compile. Only T.Corners inside a constrained generic finds the default.class A : IShape<B>. It is a convention, not a proof.+ from T.TSelf parameter? A: Static members like Zero must return the implementing type. TSelf names that type inside the interface..NET 7 used static abstract members to give every numeric type a set of interfaces. INumber<T> bundles the common ones: + - * /, comparison, T.Zero, T.One, parsing, and conversion. int, long, double, decimal, and BigInteger all implement it. Finer interfaces exist too, such as IAdditionOperators and IBinaryInteger<T>.
Sum, Mean, Clamp, or a matrix once, for every numeric type.T.CreateChecked(x) to convert a count or literal into T.checked, operators call the checked versions. So int overflow throws while doubles do not.using System.Numerics;
public static class GenericMath
{
/// <summary>Sums any numeric type, throwing on integer overflow.</summary>
/// <param name="values">The numbers.</param>
/// <returns>The total. T.Zero for an empty span.</returns>
/// <example>GenericMath.Sum<int>([1, 2, 3]) returns 6.</example>
public static T Sum<T>(ReadOnlySpan<T> values) where T : INumber<T>
{
T total = T.Zero;
// checked makes int and long throw on overflow. double has no checked form.
foreach (T v in values) total = checked(total + v);
return total;
}
/// <summary>Mean of the values, in the same numeric type.</summary>
/// <param name="values">A non-empty span.</param>
/// <returns>Sum divided by count. Integer types truncate.</returns>
/// <example>GenericMath.Mean<double>([1, 2]) returns 1.5.</example>
public static T Mean<T>(ReadOnlySpan<T> values) where T : INumber<T>
{
// 0 items would divide by zero, so reject it up front.
if (values.Length == 0) throw new ArgumentException("empty", nameof(values));
// CreateChecked turns the int count into a T, throwing if it does not fit.
return Sum(values) / T.CreateChecked(values.Length);
}
/// <summary>Greatest common divisor for any integer type.</summary>
/// <param name="a">First number.</param>
/// <param name="b">Second number.</param>
/// <returns>gcd(a, b), always non-negative.</returns>
/// <example>GenericMath.Gcd(12L, 18L) returns 6.</example>
public static T Gcd<T>(T a, T b) where T : IBinaryInteger<T>
{
// Invariant: gcd(a, b) never changes. b shrinks each pass, so the loop ends.
while (b != T.Zero) (a, b) = (b, a % b);
return T.Abs(a);
}
}
Mean<int>([1, 2]) is 1, not 1.5. Convert to double first if you want a fraction.CreateChecked, CreateSaturating, CreateTruncating differ on out-of-range input. They throw, clamp, or wrap.INumber<T> excludes Complex, which has no ordering. Use INumberBase<T> to include it.T.Zero work with no instance? A: Zero is a static abstract property on INumberBase<T>. Each numeric type implements it, and the JIT resolves it for the concrete T.An attribute class can now be generic: class ValidatorAttribute<T> : Attribute. You apply it as [Validator<NotEmpty>]. Before C# 11 you had to pass typeof(NotEmpty) to the constructor, with no compile-time check on what the type was.
using System.Reflection;
/// <summary>Checks one value.</summary>
public interface IValidator
{
bool IsValid(object? value);
}
/// <summary>Rejects null and empty strings.</summary>
public sealed class NotEmpty : IValidator
{
public bool IsValid(object? value) => value is string { Length: > 0 }; // > 0 chars
}
/// <summary>Accepts only strings that contain an at sign.</summary>
public sealed class HasAt : IValidator
{
public bool IsValid(object? value) => value is string s && s.Contains('@');
}
/// <summary>Non-generic face of the attribute, so reflection can find all of them.</summary>
public interface IValidatorSource
{
IValidator Create();
}
/// <summary>Names a validator type. The constraint is checked at compile time.</summary>
[AttributeUsage(AttributeTargets.Property, AllowMultiple = true)]
public sealed class ValidatorAttribute<TValidator> : Attribute, IValidatorSource
where TValidator : IValidator, new()
{
public IValidator Create() => new TValidator();
}
/// <summary>A sample model with validated properties.</summary>
public sealed class Signup
{
[Validator<NotEmpty>]
[Validator<HasAt>]
public string Email { get; set; } = "";
[Validator<NotEmpty>]
public string Name { get; set; } = "";
}
public static class Validation
{
/// <summary>Runs every validator attribute on every property.</summary>
/// <param name="model">The object to check.</param>
/// <returns>"Property:Validator" for each failure, sorted.</returns>
/// <example>Validation.Errors(new Signup { Email = "x", Name = "A" }) is ["Email:HasAt"]
/// </example>
public static List<string> Errors(object model)
{
var errors = new List<string>();
foreach (PropertyInfo prop in model.GetType().GetProperties())
{
object? value = prop.GetValue(model);
// OfType finds every closed generic attribute through the shared interface.
foreach (var source in prop.GetCustomAttributes(true).OfType<IValidatorSource>())
{
IValidator v = source.Create();
if (!v.IsValid(value)) errors.Add($"{prop.Name}:{v.GetType().Name}");
}
}
errors.Sort(StringComparer.Ordinal); // property order from reflection is not fixed
return errors;
}
}
[Validator<T>].dynamic, nullable reference annotations like string?, and tuple names.GetCustomAttribute<ValidatorAttribute<NotEmpty>>() finds only that exact one. A non-generic base or interface finds them all.[Validator<NotEmpty>] give over [Validator(typeof(NotEmpty))]? A: Compile-time checking. The constraint rejects a type that is not an IValidator with a public constructor.Each closed generic type is a separate type at run time. Cache<int> and Cache<string> have separate static fields and separate static constructors. The runtime runs each static constructor once, safely across threads. So a static generic class is a free, lock-free, per-type cache.
Dictionary<Type, X>. The lookup is a static field read the JIT can inline.Array.Empty<T>() and EqualityComparer<T>.Default./// <summary>Counts how many per-type caches have been built.</summary>
public static class CacheStats
{
public static int Builds;
}
/// <summary>One cached description per closed type T.</summary>
public static class TypeInfoCache<T>
{
public static readonly string Description;
// Runs once per T, the first time TypeInfoCache<T> is touched.
static TypeInfoCache()
{
Interlocked.Increment(ref CacheStats.Builds); // thread-safe + 1
Description = $"{typeof(T).Name}:{(typeof(T).IsValueType ? "value" : "ref")}";
}
}
/// <summary>A static counter in a generic class: one counter per T.</summary>
public static class PerTypeCounter<T>
{
public static int Count;
}
/// <summary>Marker types used only by the demo below, so its count is predictable.</summary>
public sealed class DemoA;
public sealed class DemoB;
public static class StaticCaching
{
/// <summary>Reads the cache three times for two types.</summary>
/// <returns>How many builds happened. 2 on the first call, 0 after.</returns>
/// <example>StaticCaching.BuildsForDemo() returns 2 the first time.</example>
public static int BuildsForDemo()
{
int before = CacheStats.Builds;
_ = TypeInfoCache<DemoA>.Description; // builds the DemoA cache
_ = TypeInfoCache<DemoA>.Description; // already built, no new build
_ = TypeInfoCache<DemoB>.Description; // builds the DemoB cache
return CacheStats.Builds - before;
}
/// <summary>Shows that each T has its own static field.</summary>
/// <returns>The counts for int and string after bumping int twice.</returns>
/// <example>StaticCaching.SeparateStatics() returns "2 0" on a fresh run.</example>
public static string SeparateStatics()
{
PerTypeCounter<int>.Count++; // + 1 for int only
PerTypeCounter<int>.Count++; // + 1 again, int is now 2
return $"{PerTypeCounter<int>.Count} {PerTypeCounter<string>.Count}";
}
}
static int in a generic class is not shared across T. That is a common bug when people expect one global counter.TypeInitializationException.Array.Empty<T>() cached? A: In a static field of a generic class. Each T gets its own zero-length array, built once, with no dictionary or lock.default(T) (C# 2.0) is the zero value of T. That is null for reference types, 0 for numbers, false for bool, and all-zero fields for structs.default literal (C# 7.1) lets the compiler infer T, as in T x = default;.T? on an unconstrained T (C# 9) means “T, or default”. It is not Nullable<T> for value types.EqualityComparer<T>.Default.Equals(x, default).public static class Defaults
{
/// <summary>First match, or default(T) when nothing matches.</summary>
/// <param name="items">Items to search.</param>
/// <param name="match">The test.</param>
/// <returns>The match, or default. For int that is 0, not null.</returns>
/// <example>Defaults.FindOrDefault([1, 2], x => x > 5) returns 0.</example>
public static T? FindOrDefault<T>(IEnumerable<T> items, Func<T, bool> match)
{
foreach (var item in items)
{
if (match(item)) return item;
}
return default;
}
/// <summary>Shows default for several kinds of T.</summary>
/// <returns>The printed defaults.</returns>
/// <example>Defaults.Show() returns "0|False|null|null|(0, 0)".</example>
public static string Show()
{
int? maybe = default; // Nullable<int>: default is null, not 0
string? text = default;
return $"{default(int)}|{default(bool)}|{maybe?.ToString() ?? "null"}|"
+ $"{text ?? "null"}|({default(Vec2).X}, {default(Vec2).Y})";
}
/// <summary>True when value is the default of its type.</summary>
/// <param name="value">Any value.</param>
/// <returns>True for null, 0, false, or an all-zero struct.</returns>
/// <example>Defaults.IsDefault(0) returns true.</example>
public static bool IsDefault<T>(T value) =>
EqualityComparer<T>.Default.Equals(value, default);
}
FindOrDefault on ints returns 0 for both “found 0” and “not found”. Use the bool TryFind(..., out T) pattern instead.T? means two things. With where T : struct it is Nullable<T>. Unconstrained, it is only an annotation.value == default does not compile for an unconstrained T. Use EqualityComparer<T>.Default.default on a struct skips its constructor, so non-nullable reference fields inside are null.FirstOrDefault() return on an empty List<int>? A: 0. You cannot tell it from a real 0, so use Any() or a TryXxx method when 0 is valid.Java erases generics. List<String> becomes a raw List of objects after compiling. C# generics are reified. The type argument is kept in metadata and is real at run time.
typeof(T), new T[n], and x is List<int> all work.List<int> stores raw ints. Java must box every Integer.Reading the figure. The grey box is the compiled IL, which exists once. Blue is the single machine-code body every reference type shares. All references are pointer sized, so one body works for all. Green bodies are made per value type, because an int and a double differ in size. Notice no path boxes anything.
obj is IList<int>, typeof(T) == typeof(string).typeof(T) == typeof(int) checks for value types, so per-type fast paths are free.public static class Reified
{
/// <summary>Makes a typed array. Impossible with erased generics.</summary>
/// <param name="n">Length.</param>
/// <returns>A T[] of length n.</returns>
/// <example>Reified.MakeArray<int>(3).GetType().Name returns "Int32[]".</example>
public static T[] MakeArray<T>(int n) => new T[n];
/// <summary>Names T at run time.</summary>
/// <returns>typeof(T).Name.</returns>
/// <example>Reified.NameOf<Guid>() returns "Guid".</example>
public static string NameOf<T>() => typeof(T).Name;
/// <summary>A per-type fast path. The JIT deletes the dead branch for value types.</summary>
/// <param name="value">The value to describe.</param>
/// <returns>"int:N" for ints, "other" for anything else.</returns>
/// <example>Reified.Describe(5) returns "int:5".</example>
public static string Describe<T>(T value)
{
if (typeof(T) == typeof(int)) return $"int:{(int)(object)value!}";
return "other";
}
/// <summary>Run-time checks on closed generic types.</summary>
/// <returns>Results of three is-checks on a List of int.</returns>
/// <example>Reified.TypeChecks() returns [true, false, true].</example>
public static List<bool> TypeChecks()
{
object list = new List<int>();
return
[
list is List<int>,
list is List<long>, // a different closed type, so false
typeof(List<int>) != typeof(List<string>),
];
}
}
typeof(T) or new T[].Describe, (int)(object)value looks like boxing. For T = int the JIT removes it.nameof turns a symbol into its name as a string at compile time. Before C# 14, a generic type needed a dummy argument: nameof(List<int>). C# 14 accepts the unbound form: nameof(List<>) and nameof(Dictionary<,>). You can reach members too: nameof(List<>.Count).
"List" would miss.public static class NameofUnbound
{
/// <summary>Names generic types and members without a type argument.</summary>
/// <returns>The three names, joined by spaces.</returns>
/// <example>NameofUnbound.Demo() returns "List Dictionary Count".</example>
public static string Demo() =>
$"{nameof(List<>)} {nameof(Dictionary<,>)} {nameof(List<>.Count)}";
/// <summary>A typical use: a clear error message.</summary>
/// <param name="index">Index that was too big.</param>
/// <returns>The message text.</returns>
/// <example>NameofUnbound.Message(9) returns "Repo: index 9 out of range".</example>
public static string Message(int index) => $"{nameof(Repo<>)}: index {index} out of range";
}
nameof(List<>) is "List", not "List`1". Use typeof(List<>).Name for the metadata name.nameof(Dictionary<int,>) is still an error.LangVersion 14 or later.nameof(Dictionary<,>) return? A: "Dictionary". It is a compile-time constant, with no type arguments and no backtick arity.allows ref struct is the one constraint that widens T, to spans and friends.out keeps the arrow, in flips it. Only interfaces and delegates, only reference types.INumber<T> is built on them.The next page, Delegates and Lambdas, puts Func<in T, out TResult> to work. It covers closures, capture bugs, events, and function pointers.