Text and Humanizing
These methods turn data into text that people read: messages from templates, counts with the right noun, previews, search highlights and diffs. They also cover a few checks and conversions on text that users type or that you store, such as card numbers, Roman numerals, short ids and compressed payloads.
All of them are extension methods on string in the System namespace, so no using is needed.
At a glance
| Method | What it does |
|---|---|
FormatWith | Fills {Name} placeholders from an object's properties or a dictionary's keys. |
Pluralize | Returns the English plural of a word or phrase. |
ToQuantity | Combines a count with the singular or plural form: "3 orders". |
IsValidCreditCard | Checks the length and Luhn checksum of a card number. |
FromRoman | Parses a standard Roman numeral into an int. |
FromShortId | Turns a 22-character short id back into its Guid. |
Wrap | Word-wraps text to a maximum line width. |
ReverseText | Reverses text without breaking emoji or accented letters. |
WordCount | Counts the words in a text. |
ReadingTime | Estimates how long a text takes to read. |
TruncateWords | Keeps the first N words and adds "...". |
Highlight | Wraps every match of a search term in markers, HTML-safe by default. |
DiffLines | Compares two texts line by line, like git diff. |
Compress | GZips text and returns it as Base64. |
Decompress | Restores text from Compress, with a size limit. |
PrettyJson and MinifyJson live in the same class but are documented on the JSON and safe logging page.
Templates and wording
FormatWith
FormatWith(object source, IFormatProvider? provider = null) fills named placeholders in a template. It works like string interpolation, but for templates stored as data: e-mail bodies, notification texts or messages kept in a database.
A placeholder is {Name}, {Name:format} or a dotted path such as {Customer.Name}. Each name is read from a public instance property of source, or from a key when source (or any object along the path) is a dictionary.
using System.Globalization;
Invoice invoice = new Invoice
{
Number = "INV-1042",
Customer = new Customer { Name = "Ann", Address = new Address { City = "Amman" } },
Total = 1234.5m,
DueDate = new DateTime(2024, 5, 17),
};
string template = "Invoice {Number} for {Customer.Name} ({Customer.Address.City}): {Total:N2}, due {DueDate:yyyy-MM-dd}.";
string body = template.FormatWith(invoice, CultureInfo.InvariantCulture);
// → "Invoice INV-1042 for Ann (Amman): 1,234.50, due 2024-05-17."
A dictionary works the same way. This suits values that come from configuration or a database row.
Dictionary<string, object?> values = new Dictionary<string, object?>
{
["Name"] = "Ann",
["Count"] = 3,
};
string message = "{Name} has {Count} items in the cart".FormatWith(values);
// → "Ann has 3 items in the cart"
Formats. The text after the first : is the format string, so a format may itself contain colons. The format is applied only when the value is IFormattable (numbers, dates, Guid, enums, and so on).
DateTime placedAt = new DateTime(2024, 5, 17, 13, 45, 0);
string line = "Placed {When:yyyy-MM-dd HH:mm}".FormatWith(new { When = placedAt }, CultureInfo.InvariantCulture);
// → "Placed 2024-05-17 13:45"
Culture. Without a provider, formats use the current culture. Pass a provider when the output must not depend on the machine.
// Current culture is de-DE
string local = "Total: {Total:N2}".FormatWith(new { Total = 1234.5m });
// → "Total: 1.234,50"
string invariant = "Total: {Total:N2}".FormatWith(new { Total = 1234.5m }, CultureInfo.InvariantCulture);
// → "Total: 1,234.50"
Literal braces. Write {{ for { and }} for }, as in string.Format.
string help = "{{Name}} is replaced by {Name}, }} too".FormatWith(new { Name = "Ann" });
// → "{Name} is replaced by Ann, } too"
Null values. A null value becomes an empty string, with or without a format. A path that passes through null also gives an empty string.
Dictionary<string, object?> values = new Dictionary<string, object?> { ["Name"] = null };
string missingName = "[{Name}] [{Name:N2}]".FormatWith(values);
// → "[] []"
string missingCity = "[{Address.City}]".FormatWith(new { Address = (object?)null });
// → "[]"
ArgumentNullExceptionwhentemplateorsourceisnull.FormatExceptionwhen a placeholder names a missing property or dictionary key ({Missing},{Address.Missing}), when a placeholder is empty ({}), when a{is not closed ("Hi {Name"), or when a single}appears ("Hi } there").
- Property names are matched case-sensitively. Dictionary keys use the dictionary's own comparer.
- Only public instance properties are read. Fields and indexers are not.
- Spaces around a name are ignored:
{ Name }is the same as{Name}. - A template with no placeholders comes back unchanged.
FormatWithuses reflection and is marked[RequiresUnreferencedCode]. See Trimming and AOT.- The reverse operation, pulling values out of text that matches a template, is
ExtractWithon the String parsing page.
Pluralize
Pluralize() returns the English plural of a singular word or phrase. It handles regular endings, a list of irregular words and a list of uncountable words. Only the last word of a phrase changes. The capitalization of the input is kept.
"item".Pluralize(); // → "items"
"box".Pluralize(); // → "boxes"
"church".Pluralize(); // → "churches"
"city".Pluralize(); // → "cities"
"day".Pluralize(); // → "days"
"person".Pluralize(); // → "people"
"child".Pluralize(); // → "children"
"knife".Pluralize(); // → "knives"
"sheep".Pluralize(); // → "sheep"
"credit card".Pluralize(); // → "credit cards"
"Box".Pluralize(); // → "Boxes"
"BOX".Pluralize(); // → "BOXES"
"Person".Pluralize(); // → "People"
"PERSON".Pluralize(); // → "PEOPLE"
"CD-ROM".Pluralize(); // → "CD-ROMS"
- Uncountable words such as
"data","software","feedback","information","news"and"equipment"are returned unchanged. - An empty string returns an empty string.
nullthrowsArgumentNullException(parameterword).- The rules are English only. They are not affected by the current culture.
ToQuantity
ToQuantity(int count) writes the count followed by the right form of the word. Use it for labels such as "1 file" and "3 files" so you do not have to write the if yourself.
"order".ToQuantity(1); // → "1 order"
"order".ToQuantity(3); // → "3 orders"
"order".ToQuantity(0); // → "0 orders"
"person".ToQuantity(0); // → "0 people"
"person".ToQuantity(1); // → "1 person"
"item".ToQuantity(-1); // → "-1 item"
"item".ToQuantity(-2); // → "-2 items"
- The singular is used for
1and-1. Every other count, including0, uses the plural fromPluralize. - The count is always written with the invariant culture, without digit grouping:
"file".ToQuantity(12345)gives"12345 files"even when the current culture isde-DE. - The word is used as given for the singular, so pass the singular form.
nullthrowsArgumentNullException(parameterword).
Card numbers, Roman numerals and short ids
IsValidCreditCard
IsValidCreditCard() checks that the text looks like a payment card number: 12 to 19 digits that pass the Luhn checksum. Spaces and dashes between digits are allowed. Use it to catch typos in a checkout form before you call the payment provider.
"4111111111111111".IsValidCreditCard(); // → true
"4111 1111 1111 1111".IsValidCreditCard(); // → true
"4111-1111-1111-1111".IsValidCreditCard(); // → true
"5555555555554444".IsValidCreditCard(); // → true
"378282246310005".IsValidCreditCard(); // → true
"4111111111111112".IsValidCreditCard(); // → false (checksum fails)
"4111.1111.1111.1111".IsValidCreditCard(); // → false (dots are not allowed)
"4111a11111111111".IsValidCreditCard(); // → false
"00000000000".IsValidCreditCard(); // → false (11 digits)
"".IsValidCreditCard(); // → false
A true result means the number is well-formed. It does not mean the card exists, is active or belongs to the user. All zeros of a valid length ("000000000000") also pass, because they satisfy the Luhn checksum.
- A
nullreceiver returnsfalseand does not throw. - Only ASCII digits
0–9count as digits. - To hide a card number in output, use
Maskfrom String manipulation:"4111111111111111".Mask()gives"************1111".
FromRoman
FromRoman() parses a Roman numeral into an int from 1 to 3999. It ignores case and surrounding white space. It accepts only the standard form, so "IIII" and "VX" are rejected.
"MMXXIV".FromRoman(); // → 2024
"mmxxiv".FromRoman(); // → 2024
"McMxCiV".FromRoman(); // → 1994
" XIV ".FromRoman(); // → 14
"MMMCMXCIX".FromRoman(); // → 3999
FormatExceptionfor non-standard or invalid text:"IIII","VX","IIV","MMMM"(4000 is out of range),"ABC","X I",""and white space only.ArgumentNullExceptionwhen the receiver isnull(parameterroman).
The reverse, ToRoman() on an int (2024.ToRoman()), is on the Numbers page.
FromShortId
FromShortId() turns a 22-character short id back into the Guid it came from. Short ids come from ToShortId() on a Guid (orderId.ToShortId()), which writes a Guid as URL-safe Base64 (letters, digits, - and _). Use the pair when you want shorter ids in URLs but still store a Guid.
Guid orderId = Guid.NewGuid();
string shortId = orderId.ToShortId(); // 22 characters, safe in a URL
Guid restored = shortId.FromShortId();
bool same = restored == orderId; // → true
bool isEmpty = "AAAAAAAAAAAAAAAAAAAAAA".FromShortId() == Guid.Empty; // → true
FromShortId throws FormatException when:
- the text is not exactly 22 characters long;
- it contains a character other than
A–Z,a–z,0–9,-or_(so+,/,=and spaces are rejected); - it is not the canonical form. The last character carries 4 unused bits, so some 22-character strings, such as
"AAAAAAAAAAAAAAAAAAAAAB", decode but are not whatToShortIdwould write. They are rejected, so eachGuidhas exactly one short id.
A null receiver throws ArgumentNullException (parameter shortId).
ToShortId and Guid.NewShortId() are covered on the Types and reflection page.
Layout and measuring text
Wrap
Wrap(int width) word-wraps text so that no line is longer than width characters. Use it for console output, plain-text e-mails or fixed-width reports.
string description = "The quick brown fox jumps over the lazy dog";
string wrapped = description.Wrap(20);
The quick brown fox
jumps over the lazy
dog
Existing line breaks are kept, including empty lines.
string notes = "one two\r\nthree four five\n\nsix".Wrap(10);
one two
three four
five
six
A word longer than the width is split into pieces of width characters.
string wrapped = "hi abcdefghij".Wrap(4);
hi
abcd
efgh
ij
- Lines in the result are joined with
Environment.NewLine, whatever line endings the input used. - Inside a paragraph, runs of white space between words become a single space.
- Text that already fits comes back unchanged:
"short text".Wrap(80)gives"short text". An empty string gives an empty string. widthbelow 1 throwsArgumentOutOfRangeException. Anullreceiver throwsArgumentNullException(parametertext).
ReverseText
ReverseText() reverses text by user-visible characters. A plain char reverse splits emoji, skin-tone modifiers, flags and combining accents into broken pieces. ReverseText keeps each of them whole.
"abc".ReverseText(); // → "cba"
"👍🏽 héllo".ReverseText(); // → "olléh 👍🏽"
"🇯🇴🇪🇬".ReverseText(); // → "🇪🇬🇯🇴"
"".ReverseText(); // → ""
- Characters are grouped with
StringInfotext elements. On .NET 5 and later these follow the Unicode extended grapheme cluster rules. - A
nullreceiver throwsArgumentNullException(parametertext).
WordCount
WordCount() counts the words in a text. A word is a run of non-white-space characters that contains at least one letter or digit, so stand-alone punctuation such as - or ... is not counted.
"Hello, world!".WordCount(); // → 2
" one two\nthree\t four ".WordCount(); // → 4
"Hello - world ... !".WordCount(); // → 2
"it's 2026".WordCount(); // → 2
"-- ** !!".WordCount(); // → 0
"".WordCount(); // → 0
A null receiver throws ArgumentNullException (parameter text).
ReadingTime
ReadingTime() estimates how long a text takes to read at 200 words per minute. ReadingTime(int wordsPerMinute) uses the speed you give. Both return a TimeSpan and use WordCount to count words.
string article = string.Join(" ", Enumerable.Repeat("word", 400));
double minutes = article.ReadingTime().TotalMinutes;
// → 2
string label = $"{Math.Ceiling(article.ReadingTime().TotalMinutes)} min read";
// → "2 min read"
string summary = string.Join(" ", Enumerable.Repeat("word", 50));
double seconds = summary.ReadingTime(100).TotalSeconds;
// → 30
- The result is not rounded. Round it yourself for display, as with
Math.Ceilingabove. - An empty text returns
TimeSpan.Zero. wordsPerMinutebelow 1 throwsArgumentOutOfRangeException. Anullreceiver throwsArgumentNullException(parametertext), for both overloads.
TruncateWords
TruncateWords(int maxWords) keeps the first maxWords words and adds "..." when it cut something. It never cuts inside a word. Use it for previews in cards, lists and notifications.
string review = "The food was great and the service too";
string preview = review.TruncateWords(5);
// → "The food was great and..."
"one two".TruncateWords(5); // → "one two"
"one two".TruncateWords(2); // → "one two"
"one two three four".TruncateWords(2); // → "one two..."
"one two\n\nthree".TruncateWords(2); // → "one two..."
"one,\ttwo three".TruncateWords(1); // → "one,..."
" one two".TruncateWords(1); // → " one..."
"".TruncateWords(1); // → ""
- Here a word is any run of non-white-space characters, so punctuation attached to a word stays with it (
"one,"). This differs fromWordCount, which skips tokens with no letter or digit. - When the text is cut, white space before the cut is removed and
"..."(three dots) is added. When nothing is cut, the text is returned exactly as given, trailing white space included. - White space inside the kept part is not changed.
maxWordsbelow 1 throwsArgumentOutOfRangeException. Anullreceiver throwsArgumentNullException(parametertext).- To take a fixed number of characters instead of words, see
LeftandRighton the String manipulation page.
Search highlights and diffs
Highlight
Highlight(string term, string before, string after, bool htmlEncode = true) wraps every occurrence of term in before and after. Matching ignores case, and each match keeps its original case. Use it to mark search terms in result snippets.
By default the text is HTML-encoded first, so the result is safe to render as HTML. Only your markers are markup.
string snippet = "Wireless mouse, mouse pad";
string html = snippet.Highlight("mouse", "<mark>", "</mark>");
// → "Wireless <mark>mouse</mark>, <mark>mouse</mark> pad"
string mixedCase = "Hello hello HELLO".Highlight("hello", "<mark>", "</mark>");
// → "<mark>Hello</mark> <mark>hello</mark> <mark>HELLO</mark>"
"<b>bold</b> text".Highlight("text", "<mark>", "</mark>");
// → "<b>bold</b> <mark>text</mark>"
"use <b> tags".Highlight("<b>", "<mark>", "</mark>");
// → "use <mark><b></mark> tags"
"Tom & Jerry".Highlight("&", "<b>", "</b>");
// → "Tom <b>&</b> Jerry"
The term is matched against the raw text before encoding. A search for "amp" therefore never matches inside the & entity.
"Fish & chips, damp".Highlight("amp", "<b>", "</b>");
// → "Fish & chips, d<b>amp</b>"
Pass htmlEncode: false for plain-text output, such as a console or a log.
"<i>x</i> and x".Highlight("x", "[", "]", htmlEncode: false);
// → "<i>[x]</i> and [x]"
before and after are inserted as given and are never encoded. Do not build them from user input.
- When
termis empty or not found, the result is the text alone: encoded whenhtmlEncodeistrue("a < b"gives"a < b"), unchanged when it isfalse. - Matches do not overlap. The search continues after the end of each match.
- Matching is ordinal and ignores case. It does not depend on the current culture.
ArgumentNullExceptionis thrown whentext,term,beforeorafterisnull, with that parameter name.
DiffLines
DiffLines(string newText) compares the receiver (the old text) with newText line by line and returns the result in a git diff style. Removed lines start with "- ", added lines with "+ ", and unchanged lines with two spaces. Use it to show what changed in a configuration file, a template or a generated report.
string oldConfig = "name: api\nport: 80\nhost: local";
string newConfig = "name: api\nport: 8080\nhost: local";
string diff = oldConfig.DiffLines(newConfig);
name: api
- port: 80
+ port: 8080
host: local
Common lines stay unchanged, even when lines are inserted and removed around them.
string diff = "a\nb\nc\nd".DiffLines("a\nx\nb\nd");
a
+ x
b
- c
d
An empty text has no lines, and line endings are normalized before comparing.
string added = "".DiffLines("a\nb");
// → "+ a" and "+ b", one per line
string removed = "a\nb".DiffLines("");
// → "- a" and "- b", one per line
string nothing = "".DiffLines("");
// → ""
string sameText = "a\r\nb\rc".DiffLines("a\nb\nc");
// → " a", " b" and " c", one per line: only the line endings differ
Trailing white space is part of the line, so a change in it shows up.
string diff = "foo".DiffLines("foo ");
// → "- foo" then "+ foo " (with the two trailing spaces)
- Lines in the result are joined with
Environment.NewLine. Input line endings (\r\n,\n,\r) are normalized before comparing. - An empty line is written as only its marker (
"-"or"+"), or as an empty line when it is unchanged. - The diff is based on the longest common subsequence. Lines that match at the start and end are paired first, so small edits to large texts stay cheap. If the changed block in the middle is too large to align (more than 16 million table cells, for example 4,001 × 4,001 changed lines), it is shown as all old lines removed, then all new lines added. The output is still correct, just not minimal.
ArgumentNullExceptionis thrown whenoldText(the receiver) ornewTextisnull.- To diff sequences of objects rather than lines of text, see Diff and dependencies.
Compression
Compress
Compress() encodes the text as UTF-8, compresses it with GZip and returns the bytes as Base64. Use it to store or send large, repetitive text, such as a JSON snapshot in a database column or a cache entry. Reverse it with Decompress.
string json = string.Concat(Enumerable.Repeat("{\"name\":\"value\",\"count\":42},", 200));
string compressed = json.Compress();
bool isMuchSmaller = compressed.Length < json.Length / 10; // → true
- The result is standard Base64 (it may contain
+,/and=), not URL-safe Base64. - Very short text can come out longer than the input, because GZip adds a header and Base64 adds about a third.
- A
nullreceiver throwsArgumentNullException(parametertext).
Decompress
Decompress(long maxDecompressedBytes = 67108864) restores text produced by Compress. It stops once the decompressed output would exceed maxDecompressedBytes (64 MB by default). This protects you from a "zip bomb": a small hostile input that expands into gigabytes.
string original = "مرحبا 👍🏽 héllo — ünïcödé";
string restored = original.Compress().Decompress();
bool same = restored == original; // → true
string payload = new string('a', 2000).Compress();
// Throws InvalidDataException: the output would be 2,000 bytes and the limit is 100.
string text = payload.Decompress(maxDecompressedBytes: 100);
Set maxDecompressedBytes to the largest size you expect when the compressed text comes from outside your system, such as a request body or a message queue. Output of exactly the limit is allowed.
FormatExceptionwhen the text is not valid Base64 ("not base64!").InvalidDataExceptionwhen the bytes are not GZip data, or when the output would exceedmaxDecompressedBytes.ArgumentNullExceptionwhen the receiver isnull(parametercompressed).
See also
- String manipulation for masking, splitting lines and taking characters from either end.
- String parsing for
ExtractWith, durations, byte counts and other text you parse. - JSON and safe logging for
PrettyJson,MinifyJsonand masking sensitive values. - Encoding and validation for Base64, hashes and other validators.