Variant Type
Variant is a sealed discriminated union representing a value that is exactly one of: None, Bool, Int32, String, Dictionary, or a nested array. It is the lingua franca of the platform: every cross-process value (gRPC request bodies, state-store entries, message payloads, worker code) is a Variant.
The cases
classDiagram
class Variant {
<<sealed abstract record>>
+None() Variant
+Bool(bool) Variant
+Int32(int) Variant
+String(string) Variant
+Dictionary(IReadOnlyDictionary~string,Variant~) Variant
+Array(IReadOnlyList~Variant~) Variant
+Serialize() byte[]
+Deserialize(byte[]) Variant
}
class Variant~T~ {
<<record>>
+Value: T
}
Variant is an abstract record with 5 concrete subtypes (one per case). The 6th case, None, is a singleton (Variant.None).
Construction
using Virtufin.Data;
// Explicit construction
Variant v1 = Variant.Int32(42);
Variant v2 = Variant.String("hello");
Variant v3 = Variant.Bool(true);
Variant v4 = Variant.None();
Variant v5 = Variant.Dictionary(new Dictionary<string, Variant>
{
["price"] = 100.5m,
["symbol"] = "BTCUSDT",
["sides"] = Variant.Array(new[] { Variant.String("buy"), Variant.String("sell") }),
});
// Implicit construction from C# primitives
Variant implicit1 = 42; // -> Variant.Int32(42)
Variant implicit2 = "hello"; // -> Variant.String("hello")
Variant implicit3 = true; // -> Variant.Bool(true)
Variant implicit4 = 100.5m; // -> Variant.Decimal via implicit conversion
Pattern matching
string describe(Variant v) => v switch
{
Variant.None => "null",
Variant.Bool b => $"bool: {b.Value}",
Variant.Int32 i => $"int: {i.Value}",
Variant.String s => $"str: {s.Value}",
Variant.Dictionary d => $"dict with {d.Value.Count} keys",
Variant.Array a => $"array with {a.Value.Count} elements",
_ => "unknown", // unreachable — Variant is sealed
};
Type safety
Variant is sealed, so the C# compiler can verify exhaustiveness in switch expressions. The implicit conversions from C# primitives are lossy in one direction: a long or double will be coerced to Int32 (with potential data loss). For arbitrary-precision decimals, use the explicit Variant.Decimal(DecimalAmount) constructor.
// These are SAFE
Variant.Int32(42);
Variant.String("hello");
// These COERCE — be careful
long big = 1_000_000_000_000L;
Variant v = big; // => Variant.Int32(1_000_000_000_000) — fits, but read as int32
FlatBuffers serialization
Every Variant can be serialized to FlatBuffers binary and deserialized back. The schema (virtufin/data/variant.fbs) is shared across C#, Python, and TypeScript SDKs — a value produced in C# is readable in Python without any code generation.
Variant v = Variant.Dictionary(new Dictionary<string, Variant>
{
["price"] = 100.5m,
["symbol"] = "BTCUSDT",
});
byte[] bytes = v.Serialize();
int size = bytes.Length; // ~50 bytes for this example
// On the receiving end (any language)
Variant restored = Variant.Deserialize(bytes);
The FlatBuffers schema is in Virtufin.Data/Schemas/variant.fbs. See FlatBuffers for the cross-language contract.
Equality and hashing
Variant uses record-style value equality: two Variants are equal if they're the same case and have equal inner values. Dictionary and Array cases use sequence-equal comparison. This means Variants can be used as dictionary keys or in HashSets.
var a = Variant.Int32(42);
var b = Variant.Int32(42);
Console.WriteLine(a == b); // True
var x = Variant.Dictionary(new Dictionary<string, Variant> { ["k"] = Variant.Int32(1) });
var y = Variant.Dictionary(new Dictionary<string, Variant> { ["k"] = Variant.Int32(1) });
Console.WriteLine(x == y); // True (sequence-equal)
Cross-cutting use cases
| Surface | Why Variant |
|---|---|
| gRPC state-store values | gRPC doesn't have a "dynamic" type — Variant is the dynamic |
| Worker code payloads | Workers accept a Variant body in their Execute call; the Variant shape defines the worker's contract |
| Pub/Sub message bodies | CloudEvent data field is Variant so any shape can flow through the bus |
| Configuration values | All config (env, file, command-line) is read as Variant and dispatched by type |
Performance characteristics
Variantis anabstract recordwith 5 concrete subtypes — no boxing for value types (they're stored as the inner value).Serializeallocates abyte[]of ~20-100 bytes depending on complexity.Deserializeparses a FlatBuffer — O(n) in payload size, no reflection.- Dictionary and Array cases use immutable inner types (
IReadOnlyDictionary,IReadOnlyList) so they're safe to share across threads.
For very high-throughput paths, the DecimalAmount case (the 6th case, not in the diagram above) is a struct that stores (long Whole, int Fraction, byte N) — no allocations, copy-by-value.