When Conversions Go Wrong
If you've ever written int x = (int)someDouble and lost fractional data silently, you've experienced the trade-off between convenience and safety. C# lets you define custom conversions between your types, but choosing between implicit and explicit operators determines whether callers notice data loss.
Implicit conversions happen automatically and should never surprise users. Explicit conversions require casts and signal "I know what I'm doing." Getting this choice wrong leads to silent bugs or APIs that require unnecessary ceremony.
You'll learn the rules for safe conversion design, when each operator type fits, and how to prevent data loss. By the end, you'll write conversions that match user expectations and protect data integrity.
Implicit Conversions: Safe and Automatic
Implicit conversions work without casts. The compiler applies them automatically when you assign or pass a value to a compatible type. Use implicit operators only when conversion always succeeds and preserves all information.
The standard example is numeric widening: converting int to long never fails or loses data. Your custom types should follow this same principle. If there's any chance of information loss or failure, use an explicit operator instead.
namespace Units;
public readonly struct Celsius
{
public double Value { get; }
public Celsius(double value) => Value = value;
// Implicit to double - always safe, no data loss
public static implicit operator double(Celsius c) => c.Value;
// Implicit from double - always succeeds
public static implicit operator Celsius(double value) => new(value);
}
public readonly struct Kelvin
{
public double Value { get; }
public Kelvin(double value) => Value = value;
// Implicit Celsius to Kelvin - always valid
public static implicit operator Kelvin(Celsius c) =>
new(c.Value + 273.15);
// Kelvin to Celsius
public static implicit operator Celsius(Kelvin k) =>
new(k.Value - 273.15);
}
These conversions preserve all data and never fail. Converting between temperature scales is mathematically straightforward, and extracting the numeric value is information-preserving. Users can write double temp = myCelsius without casts.
Explicit Conversions: When Precision Matters
Explicit conversions require casts and signal potential data loss or failure. Use them when rounding occurs, when conversion might throw, or when the conversion changes meaning significantly enough that users should acknowledge it.
Explicit operators let you provide convenient conversion syntax while forcing callers to be deliberate. The cast syntax makes data transformation visible in code reviews and prevents accidental precision loss.
namespace Finance;
public readonly struct Money
{
public decimal Amount { get; }
public string Currency { get; }
public Money(decimal amount, string currency)
{
Amount = amount;
Currency = currency;
}
// Explicit to int - loses decimal places
public static explicit operator int(Money m) => (int)m.Amount;
// Explicit to double - loses precision
public static explicit operator double(Money m) => (double)m.Amount;
// Explicit from string - might fail
public static explicit operator Money(string s)
{
var parts = s.Split(' ');
if (parts.Length != 2)
throw new FormatException("Format must be 'amount currency'");
if (!decimal.TryParse(parts[0], out var amount))
throw new FormatException($"Invalid amount: {parts[0]}");
return new Money(amount, parts[1]);
}
}
Converting Money to int loses cents. Converting from string might throw if the format is wrong. Both require explicit casts like (int)money or (Money)"100.50 USD", making the potential issues visible to anyone reading the code.
Designing Bidirectional Conversions
When providing conversions in both directions, consider whether each direction should be implicit or explicit independently. One direction might be safe while the reverse loses information or could fail.
Common patterns include implicit from your type to a simpler representation, but explicit back if reconstruction might fail or lose data. This asymmetry protects users while still providing convenience for safe operations.
namespace Domain;
public readonly struct UserId
{
public int Value { get; }
public UserId(int value)
{
if (value <= 0)
throw new ArgumentException("User ID must be positive");
Value = value;
}
// Implicit to int - always safe
public static implicit operator int(UserId id) => id.Value;
// Explicit from int - validation required
public static explicit operator UserId(int value) => new(value);
// Implicit to string - always succeeds
public static implicit operator string(UserId id) => id.Value.ToString();
// Explicit from string - parsing might fail
public static explicit operator UserId(string s)
{
if (!int.TryParse(s, out var value))
throw new FormatException($"Cannot parse '{s}' as UserId");
return new UserId(value);
}
}
Extracting the int or string is safe and implicit. Creating a UserId requires validation, so those conversions are explicit. This design lets users write int id = userId naturally while forcing var userId = (UserId)42 to acknowledge validation.
Working with Nullable Value Types
The compiler automatically lifts your conversion operators to nullable versions. If you define implicit operator int(MyStruct), you also get implicit operator int?(MyStruct?) that returns null when the input is null.
You can define nullable-specific operators if you need custom null handling. This lets you provide different behavior for null inputs than what the automatic lifting provides.
namespace Gaming;
public readonly struct Score
{
public int Points { get; }
public Score(int points)
{
if (points < 0)
throw new ArgumentException("Score cannot be negative");
Points = points;
}
// Implicit to int
public static implicit operator int(Score score) => score.Points;
// Compiler provides: implicit operator int?(Score? score)
// Explicit nullable override - treat null as zero
public static explicit operator int(Score? score) => score?.Points ?? 0;
// Optional: convert null to minimum score instead
public static Score FromNullable(int? points) =>
points.HasValue ? new Score(points.Value) : new Score(0);
}
The automatic nullable lifting handles null propagation naturally. If you need different null semantics, define explicit overloads or provide helper methods. Most types work fine with the default lifted behavior.
Choosing the Right Approach
Use implicit conversions when the operation is information-preserving and always succeeds. Think of built-in conversions like int to long or derived class to base class. If users wouldn't want to see an explicit cast in their code, make it implicit.
Use explicit conversions when data might be lost, when validation could fail, or when the semantic meaning changes enough that callers should acknowledge it. Casting from double to int loses decimals—that deserves visibility. Parsing strings might throw—that deserves a cast.
Avoid conversion operators entirely when the operation needs configuration or context. Converting a User to a UserDTO might require decisions about which fields to include. Use a method like ToDTO() instead, where parameters make options explicit.
Never throw from implicit operators unless the type's invariants are fundamentally violated. Users expect implicit conversions to "just work" like built-in conversions. Reserve exceptions for explicit operators where casts signal "this might fail."
Try It Yourself
Build types with both implicit and explicit conversions to see how they behave differently. You'll experience how implicit conversions provide convenience while explicit ones force acknowledgment of data loss.
Steps
- Set up the project:
dotnet new console -n ConversionDemo
- Navigate in:
cd ConversionDemo
- Replace Program.cs with the code below
- Update the .csproj file
- Run:
dotnet run
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
</Project>
Console.WriteLine("=== Conversion Operators Demo ===\n");
var distance = new Meters(1500);
// Implicit conversion to double
double meters = distance;
Console.WriteLine($"Distance: {meters}m");
// Explicit conversion to int (loses precision)
int rounded = (int)distance;
Console.WriteLine($"Rounded: {rounded}m");
// Explicit from string (might throw)
try
{
var parsed = (Meters)"2500";
Console.WriteLine($"Parsed: {parsed.Value}m");
}
catch (FormatException ex)
{
Console.WriteLine($"Parse failed: {ex.Message}");
}
readonly struct Meters
{
public double Value { get; }
public Meters(double value) => Value = value;
// Implicit to double - safe
public static implicit operator double(Meters m) => m.Value;
// Explicit to int - loses decimals
public static explicit operator int(Meters m) => (int)m.Value;
// Explicit from string - validation required
public static explicit operator Meters(string s)
{
if (!double.TryParse(s, out var value))
throw new FormatException($"Invalid meters value: {s}");
return new Meters(value);
}
}
Console
=== Conversion Operators Demo ===
Distance: 1500m
Rounded: 1500m
Parsed: 2500m
Notice how implicit to double needs no cast, while explicit to int and from string both require casts. This makes potential data loss and parsing failures visible in the code.