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
| Method | What 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. |
ToTimeSpan | Reads a human duration such as "1d 2h 30m" or "1.5 hours". |
ToByteCount | Reads a size such as "10 MB" or "4 KiB" into bytes (1024-based). |
FromCompactString | Reads a shortened number such as "1.3M" or "2k". |
ExtractWith | Pulls named values out of text with a {name} template (the reverse of FormatWith). |
ExtractEmails | Finds every e-mail address in free text. |
ExtractUrls | Finds every http and https URL in free text. |
ParseKeyValuePairs | Parses "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.
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, andInt128/UInt128on .NET 7 and later), double,float,decimalandHalf,bool,Guid,char,TimeSpan,DateTimeandDateTimeOffset,- enums,
- any other value type whose
TypeConverterreads strings, such asDateOnlyon .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.
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,floatandHalfaccept exponents ("1e3").decimaldoes not.- A value that overflows the target type returns
null.
"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.
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.
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 orZreturns a UTC value (DateTimeKind.Utc). Text without an offset keeps the wall time asDateTimeKind.Unspecified.DateTimeOffset: text without an offset is read as UTC (+00:00), not as the machine's offset.TimeSpanuses the standardTimeSpanformat ("01:30:00","2.03:04:05"). For text like"1h30m", useToTimeSpan.
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.
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
nullstring throwsArgumentNullException. - A name that is not a member throws
ArgumentException. Tmust be an enum (where T : struct, Enum).
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.
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:
| Form | Examples |
|---|---|
| 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:
| Unit | Accepted words |
|---|---|
| Milliseconds | ms, msec, millisecond, milliseconds |
| Seconds | s, sec, secs, second, seconds |
| Minutes | m, min, mins, minute, minutes |
| Hours | h, hr, hrs, hour, hours |
| Days | d, day, days |
| Weeks | w, 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:
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
nullstring throwsArgumentNullException. - 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.
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().
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 1024 | Accepted units |
|---|---|
| 0 (bytes) | no unit, b, byte, bytes |
| 1 | kb, kib, k |
| 2 | mb, mib, m |
| 3 | gb, gib, g |
| 4 | tb, tib, t |
| 5 | pb, pib |
| 6 | eb, eib |
- A
nullstring throwsArgumentNullException. - 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) throwsOverflowException. - The result is rounded to a whole byte with
Math.Round, which rounds halves to even:"1.5 bytes"is2, and"2.5"is also2.
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().
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"throwFormatException.- 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") throwsOverflowException. Anullstring throwsArgumentNullException. - The result is rounded to the nearest whole number, with halves away from zero:
"0.4"is0,"0.5"is1. - The separators are the same in every culture:
"1,250.5K"is1250500on a German server too.
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.
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"
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}"givesname = "a"andext = "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"givesid = "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
nulltext or template throwsArgumentNullException. - The template throws
FormatExceptionwhen 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.
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>.
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.
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
nullstring throwsArgumentNullException. - The pattern is loose on purpose, so it finds addresses in prose. To check that one string is a valid address, use
IsValidEmailfrom 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.
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
nullstring throwsArgumentNullException.
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".
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"]; // → ""
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"givesPassword = "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
nullstring throwsArgumentNullException. - A segment with a value but no key (
"=value") throwsFormatException. - The same character for both separators throws
ArgumentExceptionforkeyValueSeparator.
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 otherTimeSpanhelpers.ToHumanString()writes whatToTimeSpanreads. - Numbers:
ToFileSize()andToCompactString(), which write whatToByteCountandFromCompactStringread. - Text and humanize:
FormatWith, the reverse ofExtractWith. - Enums: enum helpers beyond parsing.