Exceptions or Results
Exceptions are for the exceptional; a failure you expect is better returned as a value, which is exactly what TryParse does.
public static bool TryParse(string? s, out int result)Throwing an exception is expensive and it changes the shape of the calling code: execution stops and resumes somewhere else. That is right for something genuinely unexpected, such as a missing configuration file at startup. It is wrong for a failure you expect many times a minute. A person typing letters into a number field is not an exceptional event. It is the normal case, and normal cases should be returned, not thrown.
The framework's own answer
.NET has a convention for this. A method whose name begins with Try returns bool, true for success and false for failure, and passes the value back through an out parameter. int.TryParse, DateTime.TryParse and Dictionary<TKey, TValue>.TryGetValue all follow it. Each has a throwing twin, int.Parse or the dictionary indexer, for the cases where bad input really is a fault. Same work, two contracts; you choose by whether the failure is expected.
When a bool is not enough
A bool says that it failed, not why. When the caller needs the reason, return the reason. A tuple does for two or three fields; a small record type is clearer once there are names worth having. This is the plain version of what other languages call a result type, and it needs no library.
What trips people up
Do not use try/catch as a loop condition. Validating a thousand rows with a thousand thrown exceptions is slow and buries the real faults in noise.
Example
string[] inputs = ["42", "forty-two"];
foreach (string input in inputs)
{
// TryParse reports failure by returning false instead of throwing. The
// number comes back through the out parameter, set to 0 on failure.
if (int.TryParse(input, out int number))
{
Console.WriteLine($"{input} -> {number}");
}
else
{
Console.WriteLine($"{input} -> no number (out value is {number})");
}
}
// The same shape written for your own code: bool return, value through out.
bool TryGetPriceInCents(string sku, out int cents)
{
Dictionary<string, int> catalogue = new()
{
["cable"] = 450,
["hub"] = 2900
};
return catalogue.TryGetValue(sku, out cents);
}
Console.WriteLine(TryGetPriceInCents("hub", out int price) ? $"hub: {price}" : "hub: unknown");
Console.WriteLine(TryGetPriceInCents("laptop", out int other) ? $"laptop: {other}" : "laptop: unknown");
// Output:
// 42 -> 42
// forty-two -> no number (out value is 0)
// hub: 2900
// laptop: unknownWhen to use it
- Validating a few thousand uploaded rows without throwing a few thousand exceptions.
- Looking up an optional setting where absence is normal rather than broken.
- Returning the reason a discount code was rejected so the page can display it.
More examples
TryGetValue instead of the indexer
Two methods, one data structure, two contracts. Use the indexer when a missing key means the program is broken and you want to hear about it loudly. Use TryGetValue when a missing key is an ordinary answer, such as a product nobody has ordered yet.
Dictionary<string, int> stock = new()
{
["cable"] = 3
};
// stock["hub"] would throw KeyNotFoundException. TryGetValue reports the
// miss as false and leaves hubCount at 0.
if (!stock.TryGetValue("hub", out int hubCount))
{
hubCount = 0;
}
Console.WriteLine($"hub: {hubCount}");
// Output:
// hub: 0A result type that carries the reason
Use this once the caller needs to show the reason to a user or write it to a log. Every outcome is an ordinary return value, so the caller cannot forget to handle one: the compiler hands them the record and they have to read Ok. The Error property is string?, which states in the type that it is null on success.
// A record is a short way to declare a type that just holds values.
public readonly record struct DiscountResult(bool Ok, int Percent, string? Error);
public static class Discounts
{
public static DiscountResult For(string code)
{
if (string.IsNullOrWhiteSpace(code))
{
return new DiscountResult(false, 0, "no code supplied");
}
if (code != "SPRING")
{
return new DiscountResult(false, 0, $"unknown code {code}");
}
return new DiscountResult(true, 10, null);
}
}When throwing is still right
A result type is not always the better choice. At startup, missing configuration means the process cannot do its job at all, so an exception that stops it immediately is better than a false that some caller might ignore. The rule is about who can act on the failure: if nobody can, throw.
static class Settings
{
public static string ReadConnectionString(string path)
{
if (!File.Exists(path))
{
// There is no sensible value to return and no caller that can
// recover. Stopping now, loudly, is the correct behaviour.
throw new InvalidOperationException($"settings file {path} is missing");
}
return File.ReadAllText(path).Trim();
}
}
Discussion