Module 2 · 2. Type System, Values, Nullability, and Pattern Matching · Lesson 5 of 24
Parsing bool, char, string, Numbers, and Dates Safely
Learning outcomes
Turn untrusted text into a value without treating a normal typo as an application crash. By the end, you can distinguish parse success from the parsed value, choose an explicit input format, validate an application rule, and explain the rejection to the caller.
Choose the contract before the parser
First ask: which values, spelling, separators, whitespace and range does this field accept? A command-line argument, a form field and an API payload need not have the same rules. Keep parsing, application validation and display formatting as separate decisions.
- Use
TryParsewhen bad external input is expected; inspect its Boolean return before using theoutvalue. UseParsewhen an exception is the intended failure contract. This choice is about failure handling, not a guarantee that the data is trustworthy.
- For a fixed date format, choose
TryParseExact. For an integer count, useint.TryParse; for this lesson's decimal amount, usedecimal.TryParse. A successful conversion still needs any application-specific checks.
- For an input that is already a
string, decide whether it may be missing, blank or padded. Validate or normalize it deliberately rather than forcing an unnecessary numeric-style conversion.
Small values with observable results
bool.TryParse accepts true/false case-insensitively, including surrounding whitespace. yes and 1 fail. Its Boolean return reports success; the out value can still be false.
char.TryParse needs exactly one UTF-16 code unit; null or a different length fails. A space is one code unit, so parser success alone does not mean “a letter.” Strings are immutable sequences of these units. The rocket below occupies two, and a user-perceived character can also contain combining marks.
This example allows a padded name, rejects a missing or whitespace-only name, and retains the trimmed result. A command token uses an explicit ordinal, case-insensitive comparison. Human-language text may need different comparison rules.
using System;
using System.Globalization;
bool parsed = bool.TryParse("false", out bool enabled);
Console.WriteLine($"bool false: success={parsed}; value={enabled}");
Console.WriteLine($"bool yes: success={bool.TryParse("yes", out _)}");
Console.WriteLine($"bool padded: success={bool.TryParse(" TRUE ", out _)}");
Console.WriteLine($"char Q: success={char.TryParse("Q", out _)}");
Console.WriteLine($"char emoji: success={char.TryParse("\U0001F680", out _)}");
Console.WriteLine($"emoji UTF-16 length={"\U0001F680".Length}");
string? rawName = " Mira ";
if (string.IsNullOrWhiteSpace(rawName))
{
Console.WriteLine("Name is required.");
}
else
{
string name = rawName.Trim();
Console.WriteLine($"name=[{name}]; original=[{rawName}]");
}
bool isRun = string.Equals("rUn", "run", StringComparison.OrdinalIgnoreCase);
Console.WriteLine($"command matches={isRun}");
if (int.TryParse("12", NumberStyles.None, CultureInfo.InvariantCulture, out int count))
Console.WriteLine($"count={count.ToString(CultureInfo.InvariantCulture)}");
Console.WriteLine($"integer fraction: success={int.TryParse("12.5", NumberStyles.None, CultureInfo.InvariantCulture, out _)}");
Console.WriteLine($"integer overflow: success={int.TryParse("2147483648", NumberStyles.None, CultureInfo.InvariantCulture, out _)}");Each C# code block is a separate console program. Create a console project and replace its Program.cs with one C# block; run from that project directory. Do not concatenate the three programs. The first program prints:
bool false: success=True; value=False bool yes: success=False bool padded: success=True char Q: success=True char emoji: success=False emoji UTF-16 length=2 name=[Mira]; original=[ Mira ] command matches=True count=12 integer fraction: success=False integer overflow: success=False
Read the first line carefully: the conversion succeeded and the value is false. The name line shows that trimming did not mutate the original string. The integer examples distinguish a valid count, a disallowed fraction and a value beyond int range. Do not replace a failed count with zero and accidentally accept it.
Culture and numeric syntax are separate choices
For machine-to-machine text, agree on a stable wire format and specify CultureInfo.InvariantCulture. For a localized form, intentionally use its agreed user culture. Do not guess by trying server cultures until one happens to parse; the same punctuation can carry different meanings.
NumberStyles controls which syntax is allowed. The integer example uses None; the amount below allows a leading sign and a decimal point. It excludes whitespace, grouping, currency symbols and exponents. Invariant culture chooses the period as the decimal separator; it does not itself forbid grouping. Avoid the broader NumberStyles.Number when groups are outside your contract.
Preserved date example
Require yyyy-MM-dd with invariant culture and DateTimeStyles.None. This accepts a real calendar date with that shape. It rejects both 2026-8-24 and 2026-02-29: matching the shape is not enough to make a date valid.
using System;
using System.Globalization;
static bool TryReadDate(string? text, out DateOnly date) =>
DateOnly.TryParseExact(
text,
"yyyy-MM-dd",
CultureInfo.InvariantCulture,
DateTimeStyles.None,
out date);
if (TryReadDate("2026-08-24", out var releaseDate))
Console.WriteLine(releaseDate.ToString("D", CultureInfo.InvariantCulture));Monday, 24 August 2026
The D display format produces the long date above. Input format and output format are independent choices. DateOnly is appropriate here because the exercise needs a calendar date, not a time of day or an instant with an offset.
Practice with a complete solution
Build a console program named DateAmount that accepts exactly two arguments. The first is a real calendar date in yyyy-MM-dd format. The second is a positive decimal amount using digits 0–9, an optional leading sign and an optional period. For example, 1250.50, +12.5, .5 and 5. are accepted. No spaces, commas, currency symbol or exponent are allowed. Validate in this order: argument count, date, amount conversion, then amount greater than zero.
This is a parsing exercise, not a fixed-scale currency validator. It accepts 12.345. Very precise fractional inputs can be rounded when converted to decimal; define separate precision, scale and rounding rules when an application requires exact monetary input.
Use this complete program in a separate .NET 10 console project. The return values are process exit codes: zero means accepted; the other values identify a validation branch. All messages go to standard output here so the walkthrough is easy to check.
using System;
using System.Globalization;
static bool TryReadDate(string? text, out DateOnly date) =>
DateOnly.TryParseExact(
text,
"yyyy-MM-dd",
CultureInfo.InvariantCulture,
DateTimeStyles.None,
out date);
if (args.Length != 2)
{
Console.WriteLine("Usage: DateAmount <yyyy-MM-dd> <amount>");
return 2;
}
if (!TryReadDate(args[0], out DateOnly date))
{
Console.WriteLine("Date must be a real calendar date in yyyy-MM-dd format.");
return 3;
}
const NumberStyles amountStyle =
NumberStyles.AllowLeadingSign | NumberStyles.AllowDecimalPoint;
if (!decimal.TryParse(
args[1], amountStyle, CultureInfo.InvariantCulture, out decimal amount))
{
Console.WriteLine("Amount must fit decimal; use digits and an optional leading sign or decimal point, with no spaces or commas.");
return 4;
}
if (amount <= 0m)
{
Console.WriteLine("Amount must be greater than zero.");
return 5;
}
string dateText = date.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture);
string amountText = amount.ToString(CultureInfo.InvariantCulture);
Console.WriteLine($"Accepted: date={dateText}; amount={amountText}");
return 0;Trace the input through the branches
dotnet run -- 2026-08-24 1250.50reaches both successful parses, passes the positive-amount rule and printsAccepted: date=2026-08-24; amount=1250.50. Exit code:0.
dotnet run -- 2026-02-29 12.5printsDate must be a real calendar date in yyyy-MM-dd format.and exits with3. The amount is never examined.
dotnet run -- 2026-08-24 12,50prints the amount-format message and exits with4. A comma is not silently removed or reinterpreted.
dotnet run -- 2026-08-24 -12.5parses the amount successfully, then printsAmount must be greater than zero.and exits with5. Valid syntax does not imply an acceptable application value.
- With no arguments, the program prints its usage message and exits with
2. With an invalid date and invalid amount, the date message wins because it is checked first.
Try the boundary cases yourself: a valid leap date (2028-02-29), zero, an empty quoted amount, 1e2, leading spaces, and the integer 79228162514264337593543950336 (one above decimal.MaxValue). Predict the message and exit code before running each one.
Boundary-case answer key
For the leap-date case, use amount 12.5. For the remaining cases, keep the date 2026-08-24. Supply each amount as one argument, including the empty or padded amount.
2028-02-29with12.5:Accepted: date=2028-02-29; amount=12.5; exit0.
- Amount
0:Amount must be greater than zero.; exit5. It reaches the positive-amount check.
- An empty amount,
1e2,12.5preceded by one space, or79228162514264337593543950336:Amount must fit decimal; use digits and an optional leading sign or decimal point, with no spaces or commas.; exit4. Each stops at amount conversion.
Failure modes to avoid
- Checking the parsed Boolean instead of the success flag, or using another failed parse's default result as real input.
- Assuming every
charor string index represents a complete visible character.
- Using
ToLower()as a shortcut for a security-sensitive identifier comparison. Choose the requiredStringComparison.OrdinalorOrdinalIgnoreCaseexplicitly.
- Letting the server's current culture decide a wire format, or treating invariant culture as a complete grammar validator.
- Catching every exception and calling it bad input. For example, an unsupported numeric style can be a programming error even in a
TryParsecall.
Interview check with an answer
Question: Why choose TryParseExact for this date, and why not accept whichever localized amount the server can parse?
Answer: The caller was promised one date shape, so the parser should enforce it and report failure without throwing for a normal typo. The amount needs one shared meaning across deployments. State the culture and allowed syntax, then apply the positive-amount rule separately. Return a field-specific message so the caller knows what to change.
References
Microsoft .NET documentation: Boolean.TryParse, Char.TryParse, Int32.TryParse, Decimal.TryParse, NumberStyles, DateOnly.TryParseExact, UTF-16 and characters, and string comparison guidance.
Analogy
Imagine a workshop with two desks for material requests. The first clerk converts each slip's date and amount into ledger entries, using one posted set of writing rules. The second clerk checks whether the request is allowed. A request for a negative amount can be entered correctly and still be rejected at the second desk.
Mapping. The posted rules are the agreed input format, culture and allowed numeric syntax. The Boolean return from TryParse reports conversion success; inspect the out value only after success. The lesson's positive-amount check belongs to the second desk: -12.5 converts successfully but fails that rule.
Where it stops. The first desk must do more than recognize tidy handwriting: parsing also requires a value the target type can represent, and the date parser rejects impossible calendar dates. A successful parse does not establish application validity or exact monetary precision. Those requirements need their own rules.