Understanding Type Conversions
Myth: Conversion operators are just syntactic sugar for factory methods. Reality: They integrate deeply with the compiler's type system, enabling natural code flow and compile-time safety that regular methods can't match.
User-defined conversions let your custom types participate in C#'s type system like built-in types. Just as you can convert an int to a long implicitly, you can define implicit conversions for your structs and classes. Explicit conversions handle cases where data loss or failure might occur, forcing developers to acknowledge the risk with a cast.
You'll build a Temperature struct that converts between Celsius and Fahrenheit, then create a Money type with safe currency conversions. By the end, you'll know when to use implicit versus explicit operators and how to avoid common pitfalls that break user expectations.
Implementing Implicit Conversions
Implicit conversions happen automatically without a cast. Use them when conversion always succeeds and never loses data. The compiler inserts these conversions wherever types don't match, making your code feel natural and reducing boilerplate.
The operator keyword combined with implicit defines these conversions. They must be static and public. The method signature shows the source type as a parameter and the target type as the return type. This tells the compiler how to transform one type into another automatically.
public readonly struct Celsius
{
public double Value { get; }
public Celsius(double value)
{
Value = value;
}
// Implicit conversion from double
public static implicit operator Celsius(double value)
{
return new Celsius(value);
}
// Implicit conversion to double
public static implicit operator double(Celsius celsius)
{
return celsius.Value;
}
public override string ToString() => $"{Value:F1}°C";
}
// Usage
Celsius temp = 25.5; // Implicit conversion from double
double degrees = temp; // Implicit conversion to double
Console.WriteLine($"Temperature: {temp} = {degrees} degrees");
The compiler uses these operators automatically when you assign a double to a Celsius variable or vice versa. No explicit casts needed. This works because conversion between these types is always safe and preserves all information. The relationship feels natural since temperature is fundamentally a number.
Creating Explicit Conversions for Data Loss
Explicit conversions require a cast. Use them when conversion might lose data, throw exceptions, or requires careful consideration. The cast syntax forces developers to acknowledge that something potentially unsafe is happening.
Temperature conversions between Celsius and Fahrenheit involve calculations that could lose precision. More importantly, they represent different scales where automatic conversion might cause confusion. Explicit operators prevent accidental mixing of units.
public readonly struct Fahrenheit
{
public double Value { get; }
public Fahrenheit(double value)
{
Value = value;
}
// Explicit conversion from Celsius
public static explicit operator Fahrenheit(Celsius celsius)
{
return new Fahrenheit(celsius.Value * 9.0 / 5.0 + 32.0);
}
// Explicit conversion to Celsius
public static explicit operator Celsius(Fahrenheit fahrenheit)
{
return new Celsius((fahrenheit.Value - 32.0) * 5.0 / 9.0);
}
public override string ToString() => $"{Value:F1}°F";
}
// Usage
var celsiusTemp = new Celsius(25);
var fahrenheitTemp = (Fahrenheit)celsiusTemp; // Explicit cast required
Console.WriteLine($"{celsiusTemp} = {fahrenheitTemp}");
var backToCelsius = (Celsius)fahrenheitTemp;
Console.WriteLine($"{fahrenheitTemp} = {backToCelsius}");
The explicit cast forces you to think about what you're doing. Converting temperatures involves mathematical operations and represents a deliberate unit change. The syntax makes conversions visible in code reviews and prevents bugs where temperatures in different units get mixed accidentally.
Building a Money Type with Safe Conversions
Financial calculations demand precision. A Money struct with conversion operators can prevent decimal rounding errors and ensure currency conversions are explicit. Implicit conversion from decimal to Money is safe, but converting between different currencies must be explicit.
public readonly struct Money
{
public decimal Amount { get; }
public string Currency { get; }
public Money(decimal amount, string currency)
{
Amount = amount;
Currency = currency ?? "USD";
}
// Implicit conversion from decimal (assumes USD)
public static implicit operator Money(decimal amount)
{
return new Money(amount, "USD");
}
// Explicit conversion to decimal (loses currency information)
public static explicit operator decimal(Money money)
{
return money.Amount;
}
// Arithmetic operators
public static Money operator +(Money a, Money b)
{
if (a.Currency != b.Currency)
throw new InvalidOperationException(
$"Cannot add {a.Currency} and {b.Currency}");
return new Money(a.Amount + b.Amount, a.Currency);
}
public static Money operator -(Money a, Money b)
{
if (a.Currency != b.Currency)
throw new InvalidOperationException(
$"Cannot subtract {a.Currency} and {b.Currency}");
return new Money(a.Amount - b.Amount, a.Currency);
}
public override string ToString() => $"{Amount:C} {Currency}";
}
Converting a decimal to Money is safe and common enough to be implicit. It assumes USD but makes financial code cleaner. Converting Money back to decimal is explicit because you lose the currency information. The arithmetic operators enforce currency matching, preventing accidental cross-currency calculations.
Understanding Conversion Rules and Constraints
The compiler follows specific rules when applying conversions. It will combine at most one user-defined conversion with built-in conversions. It can convert int to long (built-in), then long to Money (user-defined), but it won't chain two user-defined conversions.
You can only define conversions in the source or target type. If you own neither type, you can't add conversions between them. This prevents conflicts when multiple libraries try to define the same conversion. It also keeps ownership clear.
public readonly struct Meters
{
public double Value { get; }
public Meters(double value) => Value = value;
public static implicit operator double(Meters m) => m.Value;
}
public readonly struct Feet
{
public double Value { get; }
public Feet(double value) => Value = value;
public static implicit operator double(Feet f) => f.Value;
// Must explicitly define this conversion
// Compiler won't chain Feet→double→Meters automatically
public static explicit operator Meters(Feet feet)
{
return new Meters(feet.Value * 0.3048);
}
}
// Usage
var distance = new Feet(10);
var meters = (Meters)distance; // Works: uses explicit operator
// var autoMeters = (Meters)(double)distance; // Must cast explicitly
Even though both Meters and Feet convert to double, the compiler won't automatically chain them. You must define direct conversion between custom types. This explicit design prevents surprising behavior and keeps performance predictable.
Mistakes to Avoid
Making narrowing conversions implicit causes silent data loss. Converting double to int loses the decimal part, so it's explicit in C#. Your types should follow the same principle. If precision, range, or semantics change, make the conversion explicit even if it technically always succeeds.
Throwing exceptions in implicit conversions breaks user expectations. Developers assume implicit conversions are safe and won't fail. If conversion can throw, make it explicit or provide a separate TryConvert method. Reserve exceptions for truly exceptional cases in explicit conversions.
Defining conversions in both directions without careful thought creates confusion. If A converts to B implicitly and B converts to A implicitly, you've created a symmetric relationship that might not reflect reality. Temperature scales are different, so conversions should be explicit. Money to decimal can be explicit while decimal to Money is implicit because one is common and the other loses data.
Forgetting that conversions are evaluated at compile time causes runtime surprises. The compiler picks conversion operators based on static types, not runtime types. Polymorphism doesn't apply to conversion operators. If your variable is typed as object, conversions won't work even if the runtime value has conversion operators defined.
Try It Yourself
This example demonstrates both implicit and explicit conversions working together with real calculations.
Steps
- Create project:
dotnet new console -n ConversionDemo
- Enter directory:
cd ConversionDemo
- Update Program.cs with the code below
- Copy the project file configuration
- Build and run:
dotnet run
// Copy the Celsius, Fahrenheit, and Money struct definitions from above
Console.WriteLine("=== Temperature Conversions ===\n");
Celsius roomTemp = 22.5; // Implicit from double
Console.WriteLine($"Room temperature: {roomTemp}");
var roomTempF = (Fahrenheit)roomTemp; // Explicit conversion
Console.WriteLine($"In Fahrenheit: {roomTempF}");
var converted = (Celsius)roomTempF; // Convert back
Console.WriteLine($"Converted back: {converted}\n");
Console.WriteLine("=== Money Conversions ===\n");
Money price = 99.99m; // Implicit from decimal
Console.WriteLine($"Price: {price}");
Money tax = 8.50m;
Money total = price + tax; // Addition works
Console.WriteLine($"With tax: {total}");
decimal rawAmount = (decimal)total; // Explicit to decimal
Console.WriteLine($"Raw amount: {rawAmount:C}\n");
Console.WriteLine("=== Type Safety Demo ===\n");
try
{
var eurPrice = new Money(50m, "EUR");
var usdPrice = new Money(60m, "USD");
var invalid = eurPrice + usdPrice; // This throws!
}
catch (InvalidOperationException ex)
{
Console.WriteLine($"Error caught: {ex.Message}");
}
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
</Project>
Run result
=== Temperature Conversions ===
Room temperature: 22.5°C
In Fahrenheit: 72.5°F
Converted back: 22.5°C
=== Money Conversions ===
Price: $99.99 USD
With tax: $108.49 USD
Raw amount: $108.49
=== Type Safety Demo ===
Error caught: Cannot add EUR and USD
Choosing the Right Approach
Use implicit conversions when types have a natural "is-a" relationship. A Celsius value is fundamentally a number, so converting to and from double makes sense. The conversion preserves all information and never fails. Users expect it to work seamlessly.
Choose explicit conversions when semantics change or data can be lost. Converting Fahrenheit to Celsius involves calculations and represents different measurement scales. Even though it always succeeds mathematically, the semantic difference justifies requiring a cast. Money to decimal is explicit because currency information disappears.
Consider factory methods instead of conversions when construction is complex or requires multiple parameters. If converting between types needs configuration or context, a static Create method communicates intent better than an operator. Conversions should feel lightweight and obvious.
Provide TryConvert methods alongside conversion operators when failure is common. Explicit operators can throw exceptions, but parsing scenarios benefit from TryParse patterns. This gives callers a choice between throwing and returning false on failure. Both patterns can coexist on the same type.