Skip to main content

Parsing What People Type

These methods read values out of strings that people write: query-string parameters, config entries, form fields, log lines and chat messages. Use them when the input is text and you want a typed value, either null on failure or an exception that says what went wrong.

All of them use the invariant culture. '.' is always the decimal separator, whatever the server's culture is.

At a glance​

MethodWhat it does
TryParse<T>Parses text into any value type and returns null instead of throwing.
ToEnum<T>Parses an enum name or number with Enum.Parse, and throws on failure.
ToTimeSpanReads a human duration such as "1d 2h 30m" or "1.5 hours".
ToByteCountReads a size such as "10 MB" or "4 KiB" into bytes (1024-based).
FromCompactStringReads a shortened number such as "1.3M" or "2k".
ExtractWithPulls named values out of text with a {name} template (the reverse of FormatWith).
ExtractEmailsFinds every e-mail address in free text.
ExtractUrlsFinds every http and https URL in free text.
ParseKeyValuePairsParses "Server=db;Database=app" style text into a case-insensitive dictionary.

Typed values​

TryParse<T>​

TryParse<T>() parses a string into a value type and returns T?. When the text can't be parsed, the result is null, so you need no out variable and no try/catch. It suits query-string and config values, where ?? defaultValue gives a fallback.

Reading query-string values
int? quantity = "12".TryParse<int>(); // → 12
int? missing = "twelve".TryParse<int>(); // → null
int pageSize = "abc".TryParse<int>() ?? 20; // → 20

decimal? price = " 19.99 ".TryParse<decimal>(); // → 19.99
bool? isActive = "TRUE".TryParse<bool>(); // → true
bool? isGift = "yes".TryParse<bool>(); // → null

Guid? orderId = "3f2504e0-4f89-11d3-9a0c-0305e82c3301".TryParse<Guid>();
// → new Guid("3f2504e0-4f89-11d3-9a0c-0305e82c3301")

It works for:

  • every integer type (int, long, short, byte, sbyte, ushort, uint, ulong, nint, nuint, BigInteger, and Int128 / UInt128 on .NET 7 and later),
  • double, float, decimal and Half,
  • bool, Guid, char, TimeSpan, DateTime and DateTimeOffset,
  • enums,
  • any other value type whose TypeConverter reads strings, such as DateOnly on .NET 7 and later.

null, an empty string and white space all return null. White space around the value is ignored.

Numbers​

The rules are strict, so a typo can't become a wrong number.

Number rules
int? plus = "+5".TryParse<int>(); // → 5
int? hex = "0x10".TryParse<int>(); // → null
int? tooBig = "99999999999".TryParse<int>(); // → null
long? big = "99999999999".TryParse<long>(); // → 99999999999
byte? negative = "-1".TryParse<byte>(); // → null

double? ratio = "1e3".TryParse<double>(); // → 1000
decimal? exponent = "1e3".TryParse<decimal>(); // → null
decimal? trailingSign = "5-".TryParse<decimal>(); // → null

decimal? germanPrice = "19,99".TryParse<decimal>(); // → null
decimal? withThousands = "1,299.00".TryParse<decimal>(); // → null
  • Integers accept decimal digits with an optional leading sign. Hex, thousands separators and trailing signs are rejected.
  • double, float and Half accept exponents ("1e3"). decimal does not.
  • A value that overflows the target type returns null.
Thousands separators are rejected

"1,5" returns null, not 15. A user who types a decimal comma gets a failure instead of a value ten times too large. If your users type 1,299.00, remove the commas before you parse.

Enums​

Enum names are matched case-insensitively. A number must be a defined member. For a [Flags] enum, it can also be a valid combination of members.

Parsing enum input
DayOfWeek? deliveryDay = "friday".TryParse<DayOfWeek>(); // → DayOfWeek.Friday
DayOfWeek? byNumber = "3".TryParse<DayOfWeek>(); // → DayOfWeek.Wednesday
DayOfWeek? undefined = "42".TryParse<DayOfWeek>(); // → null
DayOfWeek? twoDays = "Monday, Tuesday".TryParse<DayOfWeek>(); // → null
DayOfWeek? typo = "Funday".TryParse<DayOfWeek>(); // → null

FileShare? share = "Read, Write".TryParse<FileShare>(); // → FileShare.ReadWrite
FileShare? unknownBit = "64".TryParse<FileShare>(); // → null

A comma-separated list is accepted only for [Flags] enums. Enum.TryParse on its own would OR "Monday, Tuesday" into Wednesday.

Dates and times​

Results never depend on the server's time zone.

Parsing timestamps from an API
DateTime? placedAt = "2024-01-15T10:30:00+03:00".TryParse<DateTime>();
// → new DateTime(2024, 1, 15, 7, 30, 0, DateTimeKind.Utc)

DateTime? wallTime = "2024-01-15T10:30:00".TryParse<DateTime>();
// → new DateTime(2024, 1, 15, 10, 30, 0, DateTimeKind.Unspecified)

DateTimeOffset? shippedAt = "2024-01-15T10:30:00".TryParse<DateTimeOffset>();
// → new DateTimeOffset(2024, 1, 15, 10, 30, 0, TimeSpan.Zero)

DateTime? invalid = "2024-13-45".TryParse<DateTime>(); // → null
  • DateTime: text with an offset or Z returns a UTC value (DateTimeKind.Utc). Text without an offset keeps the wall time as DateTimeKind.Unspecified.
  • DateTimeOffset: text without an offset is read as UTC (+00:00), not as the machine's offset.
  • TimeSpan uses the standard TimeSpan format ("01:30:00", "2.03:04:05"). For text like "1h30m", use ToTimeSpan.
note

A value type without a TypeConverter that reads strings, such as KeyValuePair<int, int>, always returns null.

ToEnum<T>​

ToEnum<T>() parses an enum name or number and throws when it can't. Use it when a bad value is a bug, for example a value read from your own database. ToEnum<T>() is case-sensitive. ToEnum<T>(bool ignoreCase) lets you choose.

Reading a stored cut-off day
DayOfWeek cutoffDay = "Monday".ToEnum<DayOfWeek>(); // → DayOfWeek.Monday
DayOfWeek fromNumber = "1".ToEnum<DayOfWeek>(); // → DayOfWeek.Monday
DayOfWeek relaxed = "saturday".ToEnum<DayOfWeek>(ignoreCase: true); // → DayOfWeek.Saturday
FileShare share = "Read, Write".ToEnum<FileShare>(); // → FileShare.ReadWrite

DayOfWeek strict = "monday".ToEnum<DayOfWeek>(); // throws ArgumentException
  • A null string throws ArgumentNullException.
  • A name that is not a member throws ArgumentException.
  • T must be an enum (where T : struct, Enum).
ToEnum follows Enum.Parse

It accepts any number, defined or not: "99".ToEnum<DayOfWeek>() returns (DayOfWeek)99. It also ORs a comma-separated list on any enum: "Monday, Tuesday".ToEnum<DayOfWeek>() returns DayOfWeek.Wednesday. For user input, use TryParse<T>, which rejects both.

Durations, sizes and short numbers​

These three read what the matching formatters write. When the text doesn't fit, each throws FormatException with a message that shows the expected format.

ToTimeSpan​

ToTimeSpan() reads a duration the way people write it in config files and chat: "90s", "1d 2h 30m", "1.5 hours". It is the reverse of ToHumanString() on a TimeSpan. The results below show what TimeSpan.ToString() prints.

Reading timeouts and SLAs from config
TimeSpan sessionTimeout = "90s".ToTimeSpan(); // → 00:01:30
TimeSpan cacheLifetime = "1d 2h 30m".ToTimeSpan(); // → 1.02:30:00
TimeSpan retryDelay = "500ms".ToTimeSpan(); // → 00:00:00.5000000
TimeSpan meetingLength = "1.5 hours".ToTimeSpan(); // → 01:30:00
TimeSpan trialPeriod = "2 weeks".ToTimeSpan(); // → 14.00:00:00
TimeSpan responseSla = "1 day, 2 hours and 30 minutes".ToTimeSpan(); // → 1.02:30:00
TimeSpan clockSkew = "-5m".ToTimeSpan(); // → -00:05:00

Accepted forms:

FormExamples
Compact, with or without spaces"1d 2h 30m", "1h30m", "10 sec 250 msec"
Decimal amounts"1.5h", "0.25 d"
Long words, singular or plural"2 weeks", "1 week 1 day", "2 hrs"
Parts separated by commas or and"1 day, 2 hours and 30 minutes"
Negative (the sign applies to the whole duration)"-5m", "minus 5 minutes", "-1h 30m"
Any case, with padding" 3 MIN "

Units, matched case-insensitively:

UnitAccepted words
Millisecondsms, msec, millisecond, milliseconds
Secondss, sec, secs, second, seconds
Minutesm, min, mins, minute, minutes
Hoursh, hr, hrs, hour, hours
Daysd, day, days
Weeksw, wk, wks, week, weeks

Parts can come in any order, and repeated units add up: "30m 1h" is 01:30:00.

Text that ToHumanString() writes reads back to the same value:

Round trip with ToHumanString
TimeSpan original = new TimeSpan(1, 2, 30, 0);
string text = original.ToHumanString(); // → "1 day, 2 hours, 30 minutes"
TimeSpan parsed = text.ToTimeSpan(); // → 1.02:30:00
  • A null string throws ArgumentNullException.
  • These throw FormatException: an empty string, a number without a unit ("5"), an unknown unit ("5 parsecs", "1 month"), a malformed number ("1..5h"), and a sign anywhere but the start ("1h -30m").
  • Fractions are rounded to the nearest tick.
No months or years

Months and years have no fixed length, so they are not units. Write "30 days" or "52 weeks" instead.

ToByteCount​

ToByteCount() reads a size such as "10 MB" or "4 KiB" and returns the number of bytes as a long. Use it for upload limits and quotas in config. It is the reverse of ToFileSize().

Reading upload limits
long maxUpload = "10 MB".ToByteCount(); // → 10485760
long attachmentLimit = "25mb".ToByteCount(); // → 26214400
long archiveQuota = "1.5GB".ToByteCount(); // → 1610612736
long thumbnailLimit = "4 KiB".ToByteCount(); // → 4096
long headerLimit = "512 bytes".ToByteCount(); // → 512
long plainBytes = "100".ToByteCount(); // → 100
long backupSize = " 2 TB ".ToByteCount(); // → 2199023255552

Units are matched case-insensitively. All of them are 1024-based, so KB and KiB mean the same thing.

Power of 1024Accepted units
0 (bytes)no unit, b, byte, bytes
1kb, kib, k
2mb, mib, m
3gb, gib, g
4tb, tib, t
5pb, pib
6eb, eib
  • A null string throws ArgumentNullException.
  • These throw FormatException: an empty string, a unit with no number ("MB"), an unknown unit ("10 XB"), a decimal comma ("1,5 MB"), a negative size ("-1 MB"), a split unit ("10 M B") and non-ASCII digits.
  • A size larger than long.MaxValue ("8 EB" or more) throws OverflowException.
  • The result is rounded to a whole byte with Math.Round, which rounds halves to even: "1.5 bytes" is 2, and "2.5" is also 2.
Round trip with ToFileSize

ToFileSize() keeps one decimal, so the round trip is exact only when the size fits that precision. 1_610_612_736L.ToFileSize() is "1.5 GB" and reads back as 1610612736. 1_600_000L.ToFileSize() is "1.5 MB" and reads back as 1572864. See Numbers.

FromCompactString​

FromCompactString() reads a shortened number such as "1.3M", "2k" or "1,250" and returns a long. Use it for filter boxes where people type "10k". It is the reverse of ToCompactString().

Reading a minimum-revenue filter
long minRevenue = "1.3M".FromCompactString(); // → 1300000
long minOrders = "2k".FromCompactString(); // → 2000
long followers = "1,250".FromCompactString(); // → 1250
long marketCap = "3.5B".FromCompactString(); // → 3500000000
long budget = "12 K".FromCompactString(); // → 12000
long refund = "-1.5K".FromCompactString(); // → -1500
long mixed = "1,250,000.5K".FromCompactString(); // → 1250000500
long rounded = "1.2345K".FromCompactString(); // → 1235

Suffixes are matched case-insensitively: K (10^3), M (10^6), B (10^9), T (10^12), Qa (10^15) and Qi (10^18). A number without a suffix is read as is.

  • A leading + or - and spaces around the suffix are allowed.
  • ',' is accepted only as a thousands separator in groups of three ("1,250"). "1,5M" and "12,34" throw FormatException.
  • These also throw FormatException: an empty string, a suffix alone ("K"), an unknown suffix ("1.5X"), ".5K", "5.K", "1 000" and exponents ("1e3").
  • A number that doesn't fit in a long ("10 Qi") throws OverflowException. A null string throws ArgumentNullException.
  • The result is rounded to the nearest whole number, with halves away from zero: "0.4" is 0, "0.5" is 1.
  • The separators are the same in every culture: "1,250.5K" is 1250500 on a German server too.
Round trip with ToCompactString

ToCompactString() keeps one decimal. Values like 1_500 or 3_000_000_000 read back exactly. 1_250_000L.ToCompactString() is "1.3M", which reads back as 1300000. See Numbers.

Reading structure out of text​

ExtractWith​

ExtractWith(template) matches text against a template with {name} placeholders and returns the captured values by name. It returns null when the text doesn't fit. It is the reverse of FormatWith: what FormatWith writes, ExtractWith reads back. Use it for routes, file names, invoice numbers and other text with a fixed shape.

Reading ids out of a route
IReadOnlyDictionary<string, string>? route =
"/users/7/orders/99".ExtractWith("/users/{id}/orders/{orderId}");
// → { ["id"] = "7", ["orderId"] = "99" }

int? orderId = route?["orderId"].TryParse<int>(); // → 99

IReadOnlyDictionary<string, string>? other = "/products/7".ExtractWith("/users/{id}");
// → null

string path = "/users/{id}/orders/{orderId}".FormatWith(new { id = 7, orderId = 99 });
// → "/users/7/orders/99"
Invoice numbers and file names
IReadOnlyDictionary<string, string>? invoice = "INV-2024-00042".ExtractWith("INV-{year}-{number}");
// → { ["year"] = "2024", ["number"] = "00042" }

IReadOnlyDictionary<string, string>? logFile = "2024-01-05.log".ExtractWith("{y}-{m}-{d}.log");
// → { ["y"] = "2024", ["m"] = "01", ["d"] = "05" }

IReadOnlyDictionary<string, string>? report = "files/docs/2024/report.pdf".ExtractWith("files/{path}.{ext}");
// → { ["path"] = "docs/2024/report", ["ext"] = "pdf" }

How matching works:

  • Literal text must match exactly. The comparison is ordinal, so it is case-sensitive: "/Users/7" does not fit "/users/{id}".
  • Regex characters in the template are plain text: "v1.2+(beta)" fits "v{major}.{minor}+(beta)".
  • If the template starts or ends with literal text, the text must start or end with it.
  • Each placeholder captures at least one character. An empty value means no match: "/users/" does not fit "/users/{id}".
  • A placeholder ends at the first occurrence of the literal text that follows it. "files/a.b.txt" with "files/{name}.{ext}" gives name = "a" and ext = "b.txt".
  • The last placeholder takes everything up to the closing literal text, or to the end of the text. Line breaks are included: "/users/7\n" gives id = "7\n".
  • Extra text is never ignored: "healthy" does not fit "health".

A template without placeholders returns an empty dictionary when the text equals it, and null otherwise. The keys of the result are case-sensitive.

Errors:

  • A null text or template throws ArgumentNullException.
  • The template throws FormatException when a placeholder is not closed ("{id"), has an invalid name ("{}", "{1x}", "{a-b}"), appears twice, or sits right next to another placeholder ("{a}{b}"). Placeholder names use ASCII letters, digits and _, and can't start with a digit.
Differences from FormatWith

ExtractWith has no format specifiers ({Total:C}), no nested paths ({Address.City}) and no {{ escape for a literal brace. Every value comes back as a string. Convert it with TryParse<T>.

Safe on hostile input

Matching is a single forward scan with no regular expression and no backtracking. Very long or crafted text can't make it slow.

ExtractEmails​

ExtractEmails() finds every e-mail address in free text, in order of appearance. Use it on support tickets, signatures or imported notes.

Finding addresses in a support ticket
const string ticket =
"Contact sales@contoso.com or Jane.Doe@billing.contoso.co.uk (cc: ops@contoso.com).";

IReadOnlyList<string> emails = ticket.ExtractEmails();
// → ["sales@contoso.com", "Jane.Doe@billing.contoso.co.uk", "ops@contoso.com"]

IReadOnlyList<string> none = "no mail here, just an @ sign".ExtractEmails();
// → []
  • The result is never null. It is empty when there are no addresses.
  • Duplicates are kept. Call .Distinct() if you need each address once.
  • A null string throws ArgumentNullException.
  • The pattern is loose on purpose, so it finds addresses in prose. To check that one string is a valid address, use IsValidEmail from Encoding and validation.

ExtractUrls​

ExtractUrls() finds every http and https URL in free text, in order of appearance. Sentence punctuation at the end of a URL (. , ; : ! ?) is not part of it, and brackets, quotes and angle brackets end it.

Finding links in a release note
const string note =
"See https://example.com/a?b=1. Also http://test.org/path, and (https://x.io/y)!";

IReadOnlyList<string> links = note.ExtractUrls();
// → ["https://example.com/a?b=1", "http://test.org/path", "https://x.io/y"]

IReadOnlyList<string> question = "Did you open https://example.com/docs?!".ExtractUrls();
// → ["https://example.com/docs"]
  • The scheme is matched case-insensitively: "HTTPS://EXAMPLE.COM" is found.
  • Other schemes (ftp://) and bare domains (example.com) are not found. The result is empty when nothing matches.
  • A null string throws ArgumentNullException.

ParseKeyValuePairs​

ParseKeyValuePairs() parses connection-string style text ("Server=db;Database=app") into an IReadOnlyDictionary<string, string> with case-insensitive keys. The overload ParseKeyValuePairs(char pairSeparator, char keyValueSeparator) takes other separators, for tags such as "env:prod, team:billing".

Reading a connection string
IReadOnlyDictionary<string, string> settings =
"Server = db01 ; Database=Orders; Trusted_Connection;".ParseKeyValuePairs();

string server = settings["SERVER"]; // → "db01"
string database = settings["database"]; // → "Orders"
string trusted = settings["Trusted_Connection"]; // → ""
Reading resource tags with custom separators
IReadOnlyDictionary<string, string> tags = "env:prod, team:billing, tier".ParseKeyValuePairs(',', ':');

string environment = tags["env"]; // → "prod"
string team = tags["team"]; // → "billing"
string tier = tags["tier"]; // → ""

The rules:

  • Keys and values are trimmed. Empty segments are skipped, so ";; ;" and "" give an empty dictionary.
  • A segment without the key/value separator becomes a key with an empty value.
  • Only the first key/value separator splits a segment: "Password=a=b=c" gives Password = "a=b=c".
  • When a key repeats, in any case, the last value wins: "a=1;A=2" gives one entry, a = "2".

Errors:

  • A null string throws ArgumentNullException.
  • A segment with a value but no key ("=value") throws FormatException.
  • The same character for both separators throws ArgumentException for keyValueSeparator.
No quoting

Quotes are not special, and a value can't contain the pair separator: "Password=p=ss;word" gives Password = "p=ss" and a second key, word. For ADO.NET connection strings with quoted values, use DbConnectionStringBuilder.

See also​

  • Durations: ToHumanString() and other TimeSpan helpers. ToHumanString() writes what ToTimeSpan reads.
  • Numbers: ToFileSize() and ToCompactString(), which write what ToByteCount and FromCompactString read.
  • Text and humanize: FormatWith, the reverse of ExtractWith.
  • Enums: enum helpers beyond parsing.