Skip to content

Operator Overloading & Generic Math

Key Points

  • Define operator == and operator != together; same for </<=/>/>=. Mismatched operators produce subtle bugs.
  • op_Implicit for lossless widening (int → long); op_Explicit for narrowing or domain conversions that can fail. Implicit conversions surprise readers — use sparingly.
  • Generic math (C# 11 / .NET 7+) introduced INumber<T>, IAdditionOperators<TSelf, TOther, TResult>, and static abstract interface members so you can write generic algorithms over numeric types.
  • IParsable<T> + IFormattable + ISpanFormattable are the modern trio for round-tripping value types to/from strings without allocations.
  • Senior rule: overload operators only when they read like math in the domain (Money + Money, Vector * scalar). For everything else, name the method.

Concepts (deep dive)

Operators are static methods

public readonly record struct Money(decimal Amount, string Currency)
{
    public static Money operator +(Money a, Money b)
    {
        if (a.Currency != b.Currency) throw new InvalidOperationException("Currency mismatch");
        return new Money(a.Amount + b.Amount, a.Currency);
    }
    public static Money operator *(Money a, decimal factor) => a with { Amount = a.Amount * factor };
}

Compiles to op_Addition, op_Multiply static methods. Languages other than C# (F#, VB) see them as named methods.

Pairs that must move together

Operator Pair
== !=
< >, <=, >=
+ - (often)
++ --
true false (rare; for short-circuit && \|\|)

When you overload ==, also override Equals and GetHashCode for the contract.

User-defined conversions

public readonly struct Celsius(double v)
{
    public double Value { get; } = v;
    public static implicit operator Celsius(double v) => new(v);          // dangerous? double → Celsius is a "tagging" conversion
    public static explicit operator Fahrenheit(Celsius c) => new(c.Value * 9 / 5 + 32);
}

Celsius room = 22.0;                      // implicit
var f = (Fahrenheit)room;                 // explicit

Senior guideline: keep op_Implicit only when the conversion is lossless and obvious. int → long qualifies; string → SqlQuery doesn't.

Generic math (C# 11+)

static abstract interface members let you constrain generics on operators:

public static T Sum<T>(IEnumerable<T> values) where T : INumber<T>
{
    T total = T.Zero;
    foreach (var v in values) total += v;
    return total;
}

Sum(new[] { 1, 2, 3 });          // int
Sum(new[] { 1.5m, 2.5m });       // decimal
Sum(new[] { 1.0, 2.0, 3.0 });    // double

INumber<T> chains IAdditiveIdentity, IComparisonOperators, IIncrementOperators, etc. Useful for algorithm libraries; overkill for app code.

IParsable<T> + ISpanFormattable

public readonly partial struct Sku : IParsable<Sku>, ISpanFormattable
{
    public string Code { get; }
    public Sku(string code) => Code = code;

    public static Sku Parse(string s, IFormatProvider? p) => new(s);
    public static bool TryParse(string? s, IFormatProvider? p, out Sku result)
    { result = new Sku(s ?? ""); return s is { Length: > 0 }; }

    public bool TryFormat(Span<char> dest, out int written, ReadOnlySpan<char> fmt, IFormatProvider? p)
        => Code.AsSpan().TryCopyTo(dest) ? (written = Code.Length, true).Item2 : (written = 0, false).Item2;

    public string ToString(string? format, IFormatProvider? p) => Code;
}

This makes the type a first-class citizen in int.Parse-style generic code.

op_CheckedAddition / unchecked operators

C# 11 introduced checked user-defined operators:

public static Money operator checked +(Money a, Money b) => /* throw on overflow */;
public static Money operator +(Money a, Money b) => /* wrap */;

The checked form is selected inside checked { … } blocks.

== on classes vs records vs structs

  • Class without override → reference equality.
  • Record class → structural value equality, generated.
  • Record struct → structural, generated, no boxing.
  • Struct without override → reflection-based field-by-field (slow); always implement IEquatable<T> and ==.

Code: correct vs wrong

❌ Wrong: implicit conversion that loses info

public static implicit operator string(Email e) => e.Address;
// Now Email becomes string in interpolation, logging, dictionary keys — ambiguous & accidental.

✅ Correct: explicit + named method

public override string ToString() => Address;            // implicit cultural use
public string ToCanonicalString() => Address.ToLowerInvariant();

❌ Wrong: == without Equals

public class Money { public static bool operator ==(Money a, Money b) => a.Amount == b.Amount; /* ... */ }
new HashSet<Money>().Contains(...);  // hashes by reference; equality contract broken

✅ Correct: full quartet

public sealed class Money : IEquatable<Money>
{
    public bool Equals(Money? o) => o is not null && Amount == o.Amount && Currency == o.Currency;
    public override bool Equals(object? o) => Equals(o as Money);
    public override int GetHashCode() => HashCode.Combine(Amount, Currency);
    public static bool operator ==(Money? a, Money? b) => a is null ? b is null : a.Equals(b);
    public static bool operator !=(Money? a, Money? b) => !(a == b);
}

Design patterns for this topic

Pattern 1 — Strongly-typed quantity

public readonly record struct Bytes(long Value)
{
    public static Bytes operator +(Bytes a, Bytes b) => new(a.Value + b.Value);
    public static Bytes operator *(Bytes a, int n) => new(a.Value * n);
    public override string ToString() => $"{Value} B";
}

Domain primitives that compose through math operators.

Pattern 2 — INumber<T>-constrained algorithm

public static T Average<T>(IEnumerable<T> xs) where T : INumber<T>
{
    T sum = T.Zero;
    int count = 0;
    foreach (var x in xs) { sum += x; count++; }
    return count == 0 ? T.Zero : sum / T.CreateChecked(count);
}

Pattern 3 — Round-trippable value via IParsable<T> + ISpanFormattable

Use when the type appears in CSV, JSON, query strings, or model binding — letting the framework reach the same Parse/TryFormat contract built-in numerics use.

Pattern 4 — op_Checked for currency/financial code

Banking domains want overflow to throw, not wrap. Define operator checked + and call within checked { … } blocks.


Pros & cons / trade-offs

Choice Pros Cons
Operator overloads Reads like math, fluent Hard to discover, easy to misuse
Implicit conversion Concise call sites Hides bugs, ambiguous overload resolution
Explicit conversion Intent is clear More casts at call sites
INumber<T> constraint Reusable algorithms Compile-time complexity, harder to debug

When to use / when to avoid

  • Define operators for domain types where math expressions are natural.
  • Use INumber<T> in libraries that genuinely operate on multiple numeric types (statistics, signal processing).
  • Avoid implicit conversions unless lossless and obvious; prefer explicit or named methods.
  • Avoid generic math in app code where one or two concrete numeric types suffice.

Interview Q&A

Q1. Why are operators static? A. They participate in overload resolution that doesn't depend on this polymorphism — both operands must be visible to the compiler. Languages without operators (F#) call them by their op_* method name.

Q2. What does static abstract give you? A. Interface members callable from generic code without an instance: T.Zero, T.Parse. Enables generic math, parsable factories, etc.

Q3. Why does == on string not call op_Equality for object? A. The C# compiler picks string.operator == because both operands are typed string. With object operands, it falls back to reference equality unless you cast.

Q4. What's the difference between op_Implicit and op_Explicit ILwise? A. Both are static methods; the C# compiler emits different methods names and chooses based on cast syntax. Implicit is callable without a cast; Explicit requires (T)x.

Q5. When is INumber<T> overkill? A. App code that handles only decimal or int. Generic math shines in shared math libraries; in domain code it adds compile complexity for no benefit.

Q6. Why must op == and op != move together? A. The compiler enforces it (CS0216) — the consistency expectation is fundamental: a == b!(a != b).

Q7. What's CreateChecked vs CreateTruncating on INumber<T>? A. CreateChecked throws on overflow when converting between numeric types; CreateTruncating wraps. Use Checked for financial; Truncating for low-level perf code.

Q8. How does record struct get == for free? A. The compiler synthesizes IEquatable<T>.Equals(T), Equals(object), GetHashCode, ==, != based on positional/declared members — no reflection at runtime.


Gotchas / common mistakes

  • Implicit conversions in dictionary keys causing accidental matches.
  • Overloading + without thinking about += (compiler synthesizes; ensure semantics make sense).
  • op_Equality without Equals override → reference equality leaks through object.Equals.
  • Forgetting that INumber<T> requires T.Zero etc. — not every "numeric" type implements it.
  • Using operator overloading on entities (DDD) — equality should be by ID, not value.
  • Generic-math constraints exploding in error messages — keep them on a small surface.

Further reading