Generic math with static abstract interface members: writing and consuming numeric generic code.
C# 11 lets interfaces declare static abstract members (including operators), and .NET 7 used that to add generic math: interfaces such as INumber<T>, IAdditionOperators<TSelf, TOther, TResult>, IAdditiveIdentity<TSelf, TResult>, IMinMaxValue<T> and IParsable<T>, implemented by all built-in numeric types. You write static T Sum<T>(ReadOnlySpan<T> values) where T : INumber<T> and use T.Zero, + and T.CreateChecked(count). Your own types join by implementing the interfaces with the self-referencing constraint pattern (struct Money : IAdditionOperators<Money, Money, Money>). The JIT specialises generics for value types, so this code runs as fast as hand-written int or double versions. Prefer the smallest interface that expresses what you need, and use checked conversions (CreateChecked) unless you have a reason not to.
## How I would answer this in an interview
**Generic math** (.NET 7, C# 11) lets you write one algorithm that works for **any numeric type** — `int`, `long`, `decimal`, `double`, `Half`, `Int128`, `BigInteger` or your own — by constraining type parameters to interfaces with **static abstract members** (operators, constants, parsing). The JIT specialises the code per value type, so it's as fast as hand-written versions. I'd write an example, explain the interface hierarchy, `T.Zero`/`CreateChecked`, `IParsable
The interviewer checks whether you can write and reason about generic code using static abstract members.
Green flags
Sum or Average with T.Zero and CreateChecked.Red flags
dynamic or overloads for each numeric type.public interface IAdditiveIdentity<TSelf, TResult> where TSelf : IAdditiveIdentity<TSelf, TResult>
{
static abstract TResult AdditiveIdentity { get; }
}
Implementations provide static members; generic code calls T.AdditiveIdentity. In IL, calls use constrained. !!T call ..., resolved per instantiation. They're not virtual dispatch at run time for value types.
Operators must be declared in terms of the implementing type (static TSelf operator +(TSelf l, TOther r)). Without TSelf, the interface couldn't express "the result is the same type as the implementer". The constraint where TSelf : IFoo<TSelf> ensures TSelf is the type implementing the interface (by convention; C# can't strictly enforce "self").
CreateChecked/Saturating/Truncating<TOther>(TOther value) are implemented via TryConvertFrom... and TryConvertTo... static abstract members, allowing conversions between arbitrary numeric types, including user-defined ones, in both directions.
Some interfaces provide default implementations (e.g. INumber<T>.Clamp, Max, Min) built from other members, so custom types get them for free but can override for performance.
Sum/Average have generic-math-aware implementations in recent .NET versions.Vector<T>/Vector128<T> and TensorPrimitives work with numeric generic constraints for SIMD.IParsable<T>/ISpanParsable<T> for custom route parameter types.decimal where exactness matters, or compensated summation).Sum for large inputs (use a wider accumulator type via CreateChecked into long/Int128).T.Zero vs default(T): for built-ins they're the same, but custom types may differ — always use T.Zero.Sum<T> and benchmark with int, double, decimal vs hand-written loops.Money with addition interfaces and use it in Sum<T>.CreateChecked, CreateSaturating, CreateTruncating converting 300 to byte.checked { }.using System.Globalization;
using System.Numerics;
public readonly record struct Money(decimal Amount)
: IAdditionOperators<Money, Money, Money>,
IAdditiveIdentity<Money, Money>,
IComparisonOperators<Money, Money, bool>
{
public static Money AdditiveIdentity => new(0m);
public static Money operator +(Money left, Money right) => new(left.Amount + right.Amount);
public static bool operator <(Money left, Money right) => left.Amount < right.Amount;
public static bool operator >(Money left, Money right) => left.Amount > right.Amount;
public static bool operator <=(Money left, Money right) => left.Amount <= right.Amount;
public static bool operator >=(Money left, Money right) => left.Amount >= right.Amount;
public override string ToString() => Amount.ToString("C", CultureInfo.GetCultureInfo("en-AU"));
}
public static class Stats
{
// Smallest constraint that expresses the algorithm
public static T Sum<T>(ReadOnlySpan<T> values)
where T : IAdditionOperators<T, T, T>, IAdditiveIdentity<T, T>
{
T total = T.AdditiveIdentity;
foreach (T value in values) total += value;
return total;
}
public static T Average<T>(ReadOnlySpan<T> values) where T : INumber<T>
{
if (values.IsEmpty) throw new ArgumentException("No values", nameof(values));
T total = T.Zero;
foreach (T value in values) total += value;
return total / T.CreateChecked(values.Length); // throws if Length does not fit in T
}
public static T Max<T>(ReadOnlySpan<T> values) where T : IComparisonOperators<T, T, bool>
{
T max = values[0];
foreach (T value in values[1..]) if (value > max) max = value;
return max;
}
public static T Setting<T>(IReadOnlyDictionary<string, string> settings, string key) where T : IParsable<T>
=> T.Parse(settings[key], CultureInfo.InvariantCulture);
}
public static class Program
{
public static void Main()
{
Console.WriteLine(Stats.Sum<int>([1, 2, 3])); // 6
Console.WriteLine(Stats.Average<double>([1.5, 2.5, 3.5])); // 2.5
Console.WriteLine(Stats.Average<decimal>([10m, 20m, 25m])); // 18.333...
Console.WriteLine(Stats.Sum<Money>([new(10m), new(5.5m)])); // $15.50
Console.WriteLine(Stats.Max<Money>([new(10m), new(25m), new(5m)])); // $25.00
var settings = new Dictionary<string, string> { ["Retries"] = "3", ["Timeout"] = "00:00:30", ["Launch"] = "2026-11-01" };
Console.WriteLine(Stats.Setting<int>(settings, "Retries"));
Console.WriteLine(Stats.Setting<TimeSpan>(settings, "Timeout"));
Console.WriteLine(Stats.Setting<DateOnly>(settings, "Launch"));
try { Console.WriteLine(Stats.Average<byte>(new byte[300])); }
catch (OverflowException) { Console.WriteLine("300 does not fit in a byte"); }
}
}
INumber<T>What happens: requiring INumber<T> when only addition is needed.
Why it is wrong: excludes types that don't support full numeric semantics (e.g. Money, complex numbers).
Do instead: constrain to the minimal interfaces.
INumber<T> on domain typesWhat happens: Money : INumber<Money>.
Why it is wrong: forces nonsensical operations (money × money, parsing semantics).
Do instead: implement only meaningful operator interfaces.
default(T) instead of T.ZeroWhat happens: generic code initialises accumulators with default.
Why it is wrong: custom types may define zero differently.
Do instead: T.Zero or T.AdditiveIdentity.
What happens: summing many ints into an int accumulator.
Why it is wrong: silent wraparound.
Do instead: wider accumulator types or checked conversions.
What happens: generic code expects / to produce fractions for all types.
Why it is wrong: integer division truncates; floating has NaN; decimal has limited range.
Do instead: constrain to IFloatingPoint<T> when fractional semantics are required.
What happens: custom numeric classes in generic loops. Why it is wrong: shared code and runtime lookups reduce performance. Do instead: use structs for numeric types.
What happens: methods with five or six numeric interface constraints. Why it is wrong: hard to read and reuse. Do instead: define a composite interface for your domain.
What happens: generic numeric code for logic only ever used with decimal.
Why it is wrong: complexity without benefit.
Do instead: keep it concrete until reuse is real.
Generic math is a library author's tool that lets one correct, tested algorithm serve every numeric type, and it is the main practical reason static abstract interface members exist.
| Context | Use generic math? |
|---|---|
| Shared numeric or statistics library | yes, removes per-type duplication |
| Units of measure and domain value types | selectively, with only valid operators |
| Generic parsing of configuration and inputs | yes, via IParsable<T> |
| Single-type business logic | usually no; plain decimal is clearer |
The senior trade-off is readability versus reuse. Long constraint lists scare readers, so name helper interfaces or document constraints, and keep generic math at the edges of the system where reuse pays.
An interface is a contract: a set of members a type promises to provide, with no instance state. A class or struct can implement many interfaces. Since C# 8 an interface can carry default implementations (used only through the interface, and they need .NET Core 3.0+), plus static, private and protected members. Since C# 11 it can declare static abstract / static virtual members, which power generic math (INumber<T>, T.Zero). Explicit implementation (void IDisposable.Dispose()) hides a member unless you cast. Calls through an interface use interface dispatch, which the JIT can often devirtualise. Casting a struct to an interface boxes it; a generic constraint (where T : IComparable<T>) avoids the box.
Constraints tell the compiler what T can do so you can use it safely. C# has class and class? for reference types, struct for non-nullable value types, unmanaged for blittable structs, notnull, new() for a public parameterless constructor, base-class and interface constraints, where T : U, and Enum and Delegate constraints. C# 13 added the allows ref struct anti-constraint so T can be a Span<T>-like type. Interface constraints on struct T also avoid boxing because the JIT emits constrained calls.
You overload an operator with a public static method named operator + (or ==, <, implicit operator and so on), where at least one parameter is the containing type. Some operators must be overloaded in pairs: ==/!=, </>, <=/>=, true/false. If you overload ==, also override Equals and GetHashCode so all equality paths agree. Conversion operators are implicit (safe, no data loss) or explicit (may fail or lose data). C# 11 added checked operators and static abstract operators in interfaces for generic math (IAdditionOperators<,,>). C# 14 lets you declare compound assignment (+=) and ++/-- as instance operators that update the object in place. Overload only when the meaning is obvious, as with Money, Vector or BigInteger.
How to tell it (STAR)
**Situation:** our [telemetry library] produced different p95 values depending on the numeric type. **Task:** I owned the library and wanted to fix the bug and the cause. **Action:** I found four copies of each algorithm that had drifted apart, rewrote them once using generic math with INumber<T>, added tests that ran every type through the same cases, and benchmarked to prove speed was unchanged. **Result:** [about 600] lines removed, the inconsistency fixed, and future fixes now apply to every type at once.
Last updated 30 Oct 2026.