Types and Reflection
These methods answer questions about a Type without hand-written reflection: what is its C# name, does it implement IRepository<>, is it a record, a nullable or a number, which attributes does it carry. Reach for them in mappers, serializers, DI registration, validation and error messages, wherever you inspect types at run time.
The Type and Guid methods live in the System namespace. The attribute methods (GetAttribute, GetAttributes, HasAttribute) live in System.Reflection, so they need using System.Reflection;, which reflection code usually has anyway.
At a glance
| Method | What it does |
|---|---|
GetFriendlyName | Gets the type name as you would write it in C#: Dictionary<string, List<int>>, int?, (int, string). |
GetFullNameWithAssemblyName | Gets "Full.Type.Name, AssemblyName" without version or public key. |
IsAssignableTo | true if a value of the type can be assigned to the target type. |
IsAssignableToGenericType | true if the type is, inherits from or implements an open generic type such as IRepository<>. |
GetGenericArgumentsOf | Gets the type arguments the type supplies to an open generic type, such as Order for IRepository<Order>. |
GetInterfacesAndAbstractClasses | Gets every interface the type implements plus its abstract base classes. |
IsNullableValueType | true for Nullable<T> (int?, DateTime?), false for everything else. |
UnwrapNullable | Gets T for Nullable<T>, or the type itself. |
GetDefaultValue | Gets what default(T) would be, for a Type known only at run time. |
IsNumeric | true for integer and floating-point types, decimal, Half, Int128, UInt128 and their nullables. |
IsComplex | true for types with properties of their own; false for primitives, enums, string, dates, Guid, Uri. |
IsRecord | true for record classes and record structs. |
IsAnonymous | true for compiler-generated anonymous types (new { Id = 1 }). |
IsStatic | true for static class types. |
GetAttribute | Gets the single attribute of a given type applied to a member, or null. |
GetAttributes | Gets every attribute of a given type applied to a member, in declaration order. |
HasAttribute | true if the member has the attribute at least once. |
ToShortId | Encodes a Guid as a 22-character URL-safe id instead of 36 characters. |
Shared rules
- Every
Typemethod throwsArgumentNullException(parameter nametype) when the type isnull. The one exception isIsAssignableToGenericType, which returnsfalse. - The attribute methods throw
ArgumentNullException(parameter namemember) when the member isnull. - Several methods carry
[DynamicallyAccessedMembers]annotations, so the trimmer keeps the members they read. See Trimming and AOT.
The examples below use these domain types:
namespace Shop.Orders;
public interface IEntity { }
public interface IAuditable : IEntity { }
public interface IRepository<TEntity> { }
public abstract class AggregateRoot { }
public sealed class Order : AggregateRoot, IAuditable
{
public Guid Id { get; set; }
public decimal Total { get; set; }
}
public sealed class OrderFilter
{
public decimal? MinTotal { get; set; }
}
public class RepositoryBase<TEntity> : IRepository<TEntity> { }
public sealed class OrderRepository : RepositoryBase<Order> { }
public record OrderPlaced(Guid OrderId, decimal Total);
public record struct Money(decimal Amount, string Currency);
Type names
GetFriendlyName
GetFriendlyName() returns the type name the way C# source writes it. Type.Name gives you Dictionary`2; this gives you Dictionary<string, List<int>>. Use it in log messages, exception messages and diagnostics. Namespaces are left out.
string lookup = typeof(Dictionary<string, List<int?>>).GetFriendlyName(); // → "Dictionary<string, List<int?>>"
string keyword = typeof(int).GetFriendlyName(); // → "int"
string nullable = typeof(Guid?).GetFriendlyName(); // → "Guid?"
string tuple = typeof((int Id, List<string> Names)?).GetFriendlyName(); // → "(int, List<string>)?"
string jagged = typeof(int[][,]).GetFriendlyName(); // → "int[][,]"
string task = typeof(Task<int>).GetFriendlyName(); // → "Task<int>"
string open = typeof(Dictionary<,>).GetFriendlyName(); // → "Dictionary<TKey, TValue>"
string domain = typeof(OrderRepository).GetFriendlyName(); // → "OrderRepository"
It also handles the less common shapes:
| Type | Result |
|---|---|
Nested type Outer<int>.Inner<string> | "Outer<int>.Inner<string>" |
| A tuple with 15 elements | "(int, int, ..., string)", flattened the way C# writes it |
typeof(List<string>).MakeByRefType() | "ref List<string>" |
typeof(int).MakePointerType() | "int*" |
A generic parameter (TValue of Dictionary<,>) | "TValue" |
Tuple element names (Id, Names) are not part of the runtime type, so they do not appear in the result. Built-in types use their C# keywords: int, string, object, bool, decimal, void, nint and so on.
GetFullNameWithAssemblyName
GetFullNameWithAssemblyName() returns "Full.Type.Name, AssemblyName": the full name plus the short assembly name, without version, culture or public key token. That string is enough for Type.GetType to load the type again, so it suits type names stored in configuration, message headers or a database.
string typeName = typeof(Order).GetFullNameWithAssemblyName(); // → "Shop.Orders.Order, Shop.Orders"
Type? loaded = Type.GetType(typeName); // → typeof(Order)
string text = typeof(string).GetFullNameWithAssemblyName(); // → "System.String, System.Private.CoreLib"
The first line assumes Order is compiled into an assembly named Shop.Orders. The last line shows the result on .NET 8 and later.
For a closed generic type, Type.FullName already includes the assembly-qualified names of the type arguments, so the result is long. Only the outer assembly name is shortened.
Assignability and generics
IsAssignableTo
IsAssignableTo<TTarget>() and IsAssignableTo(targetType) check whether a value of the type can be stored in a variable of the target type. They read in the natural direction: type.IsAssignableTo<IDisposable>() instead of typeof(IDisposable).IsAssignableFrom(type).
bool isEntity = typeof(Order).IsAssignableTo<IEntity>(); // → true (through IAuditable)
bool isRoot = typeof(Order).IsAssignableTo<AggregateRoot>(); // → true
bool boxes = typeof(int).IsAssignableTo<object>(); // → true
bool unrelated = typeof(string).IsAssignableTo<int>(); // → false
bool narrowing = typeof(object).IsAssignableTo(typeof(string)); // → false
Both forms throw ArgumentNullException when type is null. The second also throws when targetType is null (parameter name targetType).
.NET 5 and later have a built-in instance method Type.IsAssignableTo(Type?). When you target .NET 5 or later, type.IsAssignableTo(typeof(X)) binds to the built-in method, which returns false for a null target instead of throwing. For non-null arguments the results are the same. Call System.TypeExtensions.IsAssignableTo(type, targetType) as a static method if you want the null checks. Keep the System. prefix: System.Reflection has a TypeExtensions class of its own, so the short name does not compile in a file with using System.Reflection;. The generic form IsAssignableTo<TTarget>() has no built-in twin, so it always uses this library.
IsAssignableToGenericType
IsAssignableToGenericType(genericType) checks whether a type matches an open generic type anywhere in its hierarchy: the type itself, a base class, or an implemented interface. Plain IsAssignableTo cannot do this, because nothing is assignable to an open type such as IRepository<>. Use it when you scan assemblies for handlers, repositories or validators.
bool isRepository = typeof(OrderRepository).IsAssignableToGenericType(typeof(IRepository<>)); // → true (interface of the base class)
bool isBase = typeof(OrderRepository).IsAssignableToGenericType(typeof(RepositoryBase<>)); // → true
bool isSequence = typeof(List<int>).IsAssignableToGenericType(typeof(IEnumerable<>)); // → true
bool isItself = typeof(List<>).IsAssignableToGenericType(typeof(List<>)); // → true
bool isDictionary = typeof(List<int>).IsAssignableToGenericType(typeof(IDictionary<,>)); // → false
bool notGeneric = typeof(int).IsAssignableToGenericType(typeof(IEnumerable<>)); // → false
List<Type> repositories = typeof(OrderRepository).Assembly
.GetTypes()
.Where(type => !type.IsAbstract && !type.IsGenericTypeDefinition)
.Where(type => type.IsAssignableToGenericType(typeof(IRepository<>)))
.ToList();
// → [typeof(OrderRepository)]
This method is null-tolerant: a null type or a null generic type returns false instead of throwing.
Pass the open definition, typeof(IRepository<>). For a closed type such as typeof(IRepository<Order>), use IsAssignableTo instead.
GetGenericArgumentsOf
GetGenericArgumentsOf(openGenericType) returns the type arguments a type supplies to an open generic type it is, inherits from or implements. IsAssignableToGenericType answers "is it a repository?"; this answers "a repository of what?". It returns null when the open type is not in the hierarchy.
Type[]? entity = typeof(OrderRepository).GetGenericArgumentsOf(typeof(IRepository<>)); // → [typeof(Order)]
Type[]? items = typeof(List<int>).GetGenericArgumentsOf(typeof(IEnumerable<>)); // → [typeof(int)]
Type[]? pair = typeof(Dictionary<string, int>).GetGenericArgumentsOf(typeof(IDictionary<,>)); // → [typeof(string), typeof(int)]
Type[]? none = typeof(List<int>).GetGenericArgumentsOf(typeof(IDictionary<,>)); // → null
foreach (Type repositoryType in repositories)
{
Type[]? arguments = repositoryType.GetGenericArgumentsOf(typeof(IRepository<>));
if (arguments is not null)
{
Type serviceType = typeof(IRepository<>).MakeGenericType(arguments);
services.AddScoped(serviceType, repositoryType); // IRepository<Order> → OrderRepository
}
}
The type and its base classes are searched first, then its interfaces. The first match wins.
- Throws
ArgumentNullExceptionwhentypeoropenGenericTypeisnull. - Throws
ArgumentException(parameter nameopenGenericType) whenopenGenericTypeis not a generic type definition, for exampletypeof(IEnumerable<int>).
If a type implements the same open interface twice, for example IHandler<OrderPlaced> and IHandler<OrderShipped>, you get the arguments of whichever one Type.GetInterfaces() returns first. .NET does not guarantee that order. Use type.GetInterfaces() directly when you need every implementation.
GetInterfacesAndAbstractClasses
GetInterfacesAndAbstractClasses() returns every interface the type implements, inherited ones included, followed by its abstract base classes from nearest to farthest. Concrete base classes and object are left out. Use it to register one implementation under all of its contracts.
Type[] contracts = typeof(Order).GetInterfacesAndAbstractClasses();
// → [typeof(IAuditable), typeof(IEntity), typeof(AggregateRoot)]
Type[] forInterface = typeof(IAuditable).GetInterfacesAndAbstractClasses(); // → []
Type[] forObject = typeof(object).GetInterfacesAndAbstractClasses(); // → []
An interface type, or object, returns an empty array, because neither has a base type. The interfaces come first, in the order Type.GetInterfaces() returns them, which .NET does not guarantee. Compare the result as a set.
Nullable types and default values
IsNullableValueType
IsNullableValueType() returns true only for Nullable<T>, such as int? or DateTime?. Reference types return false, even though they can hold null. Use it when a property of type int? needs different handling from int, for example to allow an empty form field.
bool optionalQuantity = typeof(int?).IsNullableValueType(); // → true
bool shippedAt = typeof(DateTime?).IsNullableValueType(); // → true
bool quantity = typeof(int).IsNullableValueType(); // → false
bool name = typeof(string).IsNullableValueType(); // → false
bool definition = typeof(Nullable<>).IsNullableValueType(); // → false
Nullable reference type annotations (string?) exist only at compile time and leave no trace on the Type: a string? property's PropertyType is typeof(string) (and typeof(string?) does not compile).
UnwrapNullable
UnwrapNullable() returns T for Nullable<T> and the type itself for any other type. It replaces Nullable.GetUnderlyingType(type) ?? type before a type switch or a conversion.
Type discount = typeof(decimal?).UnwrapNullable(); // → typeof(decimal)
Type id = typeof(Guid?).UnwrapNullable(); // → typeof(Guid)
Type count = typeof(int).UnwrapNullable(); // → typeof(int)
Type name = typeof(string).UnwrapNullable(); // → typeof(string)
PropertyInfo property = typeof(OrderFilter).GetProperty(nameof(OrderFilter.MinTotal))!; // decimal?
Type targetType = property.PropertyType.UnwrapNullable(); // → typeof(decimal)
object value = Convert.ChangeType("49.90", targetType, CultureInfo.InvariantCulture); // → 49.90m
GetDefaultValue
GetDefaultValue() returns what default(T) would be when you only have a Type: null for reference types and Nullable<T>, a zeroed instance for other value types. Use it to reset properties, fill missing constructor arguments, or tell "unset" from "set" in a mapper.
object? quantity = typeof(int).GetDefaultValue(); // → 0
object? paid = typeof(bool).GetDefaultValue(); // → false
object? rate = typeof(double).GetDefaultValue(); // → 0.0
object? id = typeof(Guid).GetDefaultValue(); // → Guid.Empty
object? discount = typeof(int?).GetDefaultValue(); // → null
object? note = typeof(string).GetDefaultValue(); // → null
object? order = typeof(Order).GetDefaultValue(); // → null
Throws ArgumentException (parameter name type) when the type has unbound generic parameters, such as typeof(KeyValuePair<,>).
Value types are created with Activator.CreateInstance. If a struct declares its own parameterless constructor (allowed since C# 10), that constructor runs, so the result can differ from default(T), which never runs a constructor. For public struct Price { public Price() { Currency = "USD"; } ... }, the result has Currency == "USD".
Type shape
IsNumeric
IsNumeric() returns true for types that hold a number: sbyte, byte, short, ushort, int, uint, long, ulong, float, double, decimal, Half, Int128 and UInt128, plus Nullable<T> of any of them. Use it to right-align a column, pick a numeric input, or choose a number format.
bool total = typeof(decimal).IsNumeric(); // → true
bool discount = typeof(decimal?).IsNumeric(); // → true
bool weight = typeof(Half).IsNumeric(); // → true
bool big = typeof(Int128).IsNumeric(); // → true
bool day = typeof(DayOfWeek).IsNumeric(); // → false
bool flag = typeof(bool).IsNumeric(); // → false
bool letter = typeof(char).IsNumeric(); // → false
bool text = typeof(string).IsNumeric(); // → false
bool date = typeof(DateTime).IsNumeric(); // → false
Enums are not numeric here, even though an integer backs them. bool and char are not numeric either. Half, Int128 and UInt128 are recognized by name, so the netstandard2.1 build of the library handles them too when it runs on a newer .NET.
IsComplex
IsComplex() returns true for types that have properties of their own, and false for simple values: primitives (int, double, bool, char, ...), enums, string, decimal, DateTime, DateTimeOffset, TimeSpan, Guid, Uri, DateOnly, TimeOnly, Half, Int128, UInt128, and the nullable form of each value type. Use it in a mapper, a query-string writer or a form builder to decide whether to recurse into a property or write it as one value.
bool order = typeof(Order).IsComplex(); // → true
bool lines = typeof(List<int>).IsComplex(); // → true
bool codes = typeof(int[]).IsComplex(); // → true
bool any = typeof(object).IsComplex(); // → true
bool contract = typeof(IAuditable).IsComplex(); // → true
bool total = typeof(decimal).IsComplex(); // → false
bool placedAt = typeof(DateTimeOffset).IsComplex(); // → false
bool id = typeof(Guid?).IsComplex(); // → false
bool day = typeof(DayOfWeek?).IsComplex(); // → false
bool link = typeof(Uri).IsComplex(); // → false
Collections and arrays count as complex. If you handle collections separately, check for IEnumerable first, and keep in mind that string is also an IEnumerable<char>.
IsRecord
IsRecord() returns true for record classes and record struct types, including derived records and readonly record struct. Use it when records need different handling, for example to skip setter-based mapping for positional records.
bool placed = typeof(OrderPlaced).IsRecord(); // → true (record class)
bool money = typeof(Money).IsRecord(); // → true (record struct)
bool order = typeof(Order).IsRecord(); // → false (plain class)
bool count = typeof(int).IsRecord(); // → false
bool text = typeof(string).IsRecord(); // → false
bool disposable = typeof(IDisposable).IsRecord(); // → false
The runtime has no "record" flag, so the check looks for members the compiler generates. Record classes have a public <Clone>$ method. Record structs have a non-public bool PrintMembers(StringBuilder) method. A hand-written struct that declares the same private method is reported as a record too.
IsAnonymous
IsAnonymous() returns true for anonymous types created with new { ... }, in C# and in Visual Basic. Use it in a logger or serializer that should print anonymous objects as plain property bags.
object payload = new { OrderId = 1042, Status = "Paid" };
bool anonymous = payload.GetType().IsAnonymous(); // → true
bool empty = new { }.GetType().IsAnonymous(); // → true
bool order = typeof(Order).IsAnonymous(); // → false
bool placed = typeof(OrderPlaced).IsAnonymous(); // → false
bool list = typeof(List<int>).IsAnonymous(); // → false
The check requires three things: the [CompilerGenerated] attribute, AnonymousType in the type name, and a name that starts with <> (C#) or VB$ (Visual Basic).
IsStatic
IsStatic() returns true for static class types. The runtime has no static flag: the compiler emits a static class as abstract and sealed, and that is what this checks. Use it to skip types you cannot create when you scan an assembly.
bool math = typeof(Math).IsStatic(); // → true
bool helpers = typeof(System.TypeExtensions).IsStatic(); // → true
bool text = typeof(string).IsStatic(); // → false
bool root = typeof(AggregateRoot).IsStatic(); // → false (abstract, not sealed)
bool contract = typeof(IDisposable).IsStatic(); // → false
bool count = typeof(int).IsStatic(); // → false
Attributes
These three methods work on any MemberInfo: a Type, PropertyInfo, MethodInfo, FieldInfo, EventInfo or ConstructorInfo. They are typed wrappers over the attribute APIs in System.Reflection, and they include inherited attributes by default.
The examples use these types (RangeAttribute comes from System.ComponentModel.DataAnnotations):
[AttributeUsage(AttributeTargets.All, AllowMultiple = true)]
public sealed class TagAttribute : Attribute
{
public TagAttribute(string name) => Name = name;
public string Name { get; }
}
[Obsolete("Use InvoiceExporter.")]
public class LegacyInvoiceExporter { }
public class Invoice
{
[Range(1, 100_000)]
public decimal Amount { get; set; }
[Tag("billing")]
[Tag("export")]
public string Number { get; set; } = "";
public string? Notes { get; set; }
}
[Tag("orders")]
public class OrderController { }
public sealed class AdminOrderController : OrderController { }
For attributes on enum members, such as Status.Pending.GetAttribute<DescriptionAttribute>(), see Enums.
GetAttribute
GetAttribute<TAttribute>() returns the single TAttribute applied to the member, or null when the member has none. It searches inherited members too: base types for a type, overridden members for a method or property. The GetAttribute<TAttribute>(inherit) overload lets you turn that search off.
PropertyInfo amount = typeof(Invoice).GetProperty(nameof(Invoice.Amount))!;
RangeAttribute? range = amount.GetAttribute<RangeAttribute>();
object? minimum = range?.Minimum; // → 1
object? maximum = range?.Maximum; // → 100000
PropertyInfo notes = typeof(Invoice).GetProperty(nameof(Invoice.Notes))!;
RangeAttribute? none = notes.GetAttribute<RangeAttribute>(); // → null
TagAttribute? inherited = typeof(AdminOrderController).GetAttribute<TagAttribute>();
// → the [Tag("orders")] declared on OrderController
TagAttribute? own = typeof(AdminOrderController).GetAttribute<TagAttribute>(inherit: false);
// → null
- Throws
ArgumentNullExceptionwhenmemberisnull. - Throws
AmbiguousMatchExceptionwhen the attribute is applied more than once, as[Tag]is onInvoice.Number. UseGetAttributesfor attributes that allow multiple uses.
GetAttributes
GetAttributes<TAttribute>() returns every TAttribute applied to the member, inherited ones included, as an IReadOnlyList<TAttribute> in declaration order. When there are none it returns an empty list, never null.
PropertyInfo number = typeof(Invoice).GetProperty(nameof(Invoice.Number))!;
IReadOnlyList<TagAttribute> tags = number.GetAttributes<TagAttribute>();
List<string> names = tags.Select(tag => tag.Name).ToList(); // → ["billing", "export"]
PropertyInfo amount = typeof(Invoice).GetProperty(nameof(Invoice.Amount))!;
int count = amount.GetAttributes<TagAttribute>().Count; // → 0
Throws ArgumentNullException when member is null.
HasAttribute
HasAttribute<TAttribute>() returns true when the member has the attribute at least once, inherited ones included. It does not create the attribute instance, so prefer it over GetAttribute<T>() is not null when you only need a yes or no.
bool obsolete = typeof(LegacyInvoiceExporter).HasAttribute<ObsoleteAttribute>(); // → true
bool fromBase = typeof(AdminOrderController).HasAttribute<TagAttribute>(); // → true
PropertyInfo number = typeof(Invoice).GetProperty(nameof(Invoice.Number))!;
bool tagged = number.HasAttribute<TagAttribute>(); // → true
PropertyInfo notes = typeof(Invoice).GetProperty(nameof(Invoice.Notes))!;
bool ranged = notes.HasAttribute<RangeAttribute>(); // → false
Throws ArgumentNullException when member is null.
"Inherited" follows the attribute's own [AttributeUsage(Inherited = ...)] setting. ObsoleteAttribute is declared with Inherited = false, so a class derived from LegacyInvoiceExporter is not reported as obsolete.
HasAttribute calls MemberInfo.IsDefined, which ignores inheritance for properties and events. On an overridden property, HasAttribute sees only the attributes declared on the override, while GetAttribute and GetAttributes also find the ones on the base property. For overridden properties, use property.GetAttribute<T>() is not null.
Guid short ids
ToShortId
ToShortId() encodes a Guid as a 22-character id instead of the usual 36. The id is Base64url without padding: letters, digits, - and _, so it is safe in URLs, file names and anything a person copies. Turn it back into a Guid with FromShortId.
Guid orderId = new Guid("7c9e6679-7425-40de-944b-e07fc1f90ae7");
string shortId = orderId.ToShortId(); // → "eWaefCV03kCUS-B_wfkK5w"
string url = $"https://shop.example/orders/{shortId}";
Guid restored = shortId.FromShortId(); // → orderId
string empty = Guid.Empty.ToShortId(); // → "AAAAAAAAAAAAAAAAAAAAAA"
To create a new random short id in one call, use Guid.NewShortId() (C# 14). It does the same as Guid.NewGuid().ToShortId(). See Extension members.
Short ids are case-sensitive: changing the case of one letter gives a different Guid. Do not store them in a case-insensitive column or compare them with StringComparison.OrdinalIgnoreCase.
The bytes come from Guid.ToByteArray(), which stores the first three fields little-endian. A tool in another language that Base64url-encodes the UUID in RFC 4122 byte order produces a different string for the same id. Use ToShortId and FromShortId on both ends.
See also
- Enums: descriptions, flags and attributes on enum members.
- Objects: read and write properties by name, and other object helpers.
- Text and humanize:
FromShortIdand other text helpers. - Trimming and AOT: what the reflection annotations mean for trimmed and AOT-compiled apps.