Variant is a small discriminated union for a fixed set of unmanaged value types. It stores the payload inline in a 16-byte buffer plus a discriminator, so constructing and carrying a Variant does not box the underlying value.
Use TryGetValue(...) to read the typed payload without boxing. Accessing Value returns object, so that path boxes the stored value.
| Category | Types |
|---|---|
| Boolean and character | bool, char |
| Signed integers | sbyte, short, int, long, nint + Int128 on .NET 8+ |
| Unsigned integers | byte, ushort, uint, ulong, nuint + UInt128 on .NET 8+ |
| Floating point | float, double + Half on .NET 8+ |
| Other numerics | decimal |
| Date and time | TimeSpan, DateTime, DateTimeOffset + DateOnly/TimeOnly on .NET 8+ |
| Identifier | Guid |
Create a Variant with the constructor for the type you want to store:
Variant count = new(42);
Variant price = new(19.95m);
Variant when = new(DateTimeOffset.UtcNow);
Variant id = new(Guid.Parse("e63bcfc0-c8a3-4d11-9958-7f042e6cbef0"));TryGetValue is the zero-allocation read path. It succeeds when the requested type matches the stored type exactly, or when the stored value is an integer type that can be widened safely to the requested integer type.
Variant value = new(42);
if (value.TryGetValue(out int number))
{
Console.WriteLine(number); // 42
}
var matched = value.TryGetValue(out Guid id);
Console.WriteLine(matched); // False
Console.WriteLine(id); // 00000000-0000-0000-0000-000000000000Integer reads are based on type range, not on the specific stored value.
- Wider same-sign reads are allowed, such as
byte -> ushort -> uint -> ulong -> UInt128andsbyte -> short -> int -> long -> Int128. - Signed/unsigned changes are allowed only when the destination type can represent the full range of the source type, such as
byte -> shortanduint -> long. - Non-widening conversions are rejected even if a particular value would fit, so
int -> uintandulong -> longdo not work.
Variant small = new((byte)250);
small.TryGetValue(out ushort asUInt16); // True
Variant wideUnsigned = new(3_456_789_012U);
wideUnsigned.TryGetValue(out long asInt64); // True
Variant signed = new(42);
signed.TryGetValue(out uint asUInt32); // FalseIf you're using a C# version with union syntax support, you can use normal type patterns such as value is int and switch expressions:
string result = value switch
{
int i => $"It's an int32: {i}",
Guid g => $"It's a guid: {g}",
_ => "It's something else",
};Without union syntax support, the typical pattern is to probe the types you expect with TryGetValue:
static string Describe(Variant value)
{
if (value.TryGetValue(out int i))
{
return $"int: {i}";
}
if (value.TryGetValue(out decimal d))
{
return $"decimal: {d}";
}
if (value.TryGetValue(out DateTimeOffset dto))
{
return $"date/time: {dto:O}";
}
if (value.TryGetValue(out Guid guid))
{
return $"guid: {guid}";
}
throw new InvalidOperationException("Unsupported variant value.");
}Value exposes the current payload as object:
Variant value = new(TimeSpan.FromMinutes(5));
object boxed = value.Value;
Console.WriteLine((TimeSpan)boxed); // 00:05:00This is convenient, but it boxes the underlying value type. Prefer TryGetValue when you want to preserve the non-boxing behavior.
A default-initialized Variant has no stored value.
Variant empty = default;
Console.WriteLine(empty.TryGetValue(out int number)); // False
Console.WriteLine(number); // 0
// empty.Value throws InvalidOperationExceptionTryGetValueand the explicit cast operators allow safe integer widening conversions based on type range, not on the specific runtime value.- Signed/unsigned changes are allowed only when the destination type can represent the full range of the stored type.
- Equality compares integer payloads by numeric value, so integer variants with the same value compare equal even when their widths differ (for example
byte(42)equalslong(42)). DateTime.KindandDateTimeOffset.Offsetare preserved.- The inline storage is 16 bytes, which is large enough for the currently supported payloads, including
Guid.