Fuzzy Matching and Natural Sort
These helpers deal with text that people type and read. The fuzzy methods answer "did you mean ...?" when a
command, option, enum value or setting name has a typo. The natural-sort methods put "file2" before "file10",
so lists of file names, invoice numbers and versions come out in the order a person expects.
At a glance
| Method | What it does |
|---|---|
LevenshteinDistance | Counts the single-character edits between two strings: "kitten" to "sitting" is 3. |
Similarity | Scores two strings from 0.0 (nothing alike) to 1.0 (identical). |
ClosestMatch | Picks the candidate string closest to what the user typed, or null. |
ToClosestEnum<T> | Typo-tolerant enum parsing by member name or [Description] text. |
GetClosestKey | Returns the dictionary key closest to a mistyped key. |
GetClosest | Returns the value under the closest key. |
NaturalStringComparer | IComparer<string> and IEqualityComparer<string> that compare digit runs by value. |
StringComparer.Natural / NaturalIgnoreCase | The same two comparers, reachable from StringComparer. |
OrderByNatural | Sorts strings, or items by a string key, in natural order ignoring case. |
OrderByNaturalDescending | The same, largest first. |
The string methods and NaturalStringComparer live in the System namespace, and the sequence and dictionary
methods in System.Collections.Generic, so none of them needs an extra using.
Measuring similarity
Both methods compare char by char (UTF-16 code units). They are the building blocks for the "did you mean"
helpers further down; most code calls those instead.
LevenshteinDistance
LevenshteinDistance(string other, bool ignoreCase = false) returns the smallest number of single-character
inserts, deletes and replacements that turn one string into the other. Equal strings return 0. Use it when you
need a raw edit count, for example "accept at most one typo".
int classic = "kitten".LevenshteinDistance("sitting"); // → 3
int reversed = "sitting".LevenshteinDistance("kitten"); // → 3
int same = "invoice".LevenshteinDistance("invoice"); // → 0
int fromEmpty = "".LevenshteinDistance("abc"); // → 3
int caseCounts = "ABC".LevenshteinDistance("abc"); // → 3
int caseIgnored = "ABC".LevenshteinDistance("abd", ignoreCase: true); // → 1
string typedCode = "SPRNG24";
string validCode = "SPRING24";
bool closeEnough = typedCode.LevenshteinDistance(validCode, ignoreCase: true) <= 1; // → true
- A
nullreceiver or anullotherthrowsArgumentNullException(ParamNameis"str"or"other"). ignoreCase: trueupper-cases both strings withToUpperInvariant, so the result does not depend on the current culture.- Swapping two neighbouring letters costs 2 (two replacements), not 1:
"teh".LevenshteinDistance("the")is2. This is plain Levenshtein, not Damerau-Levenshtein. - The method keeps two rows of the edit matrix, so memory grows with the shorter string. Time grows with the product of the two lengths.
Similarity
Similarity(string other, bool ignoreCase = false) turns the edit distance into a score between 0.0 and
1.0: 1.0 - distance / length of the longer string. A score is easier to compare across words of different
lengths than a raw edit count.
double spelling = "color".Similarity("colour"); // → 0.8333333333333334
double identical = "invoice".Similarity("invoice"); // → 1.0
double unrelated = "abc".Similarity("xyz"); // → 0.0
double againstEmpty = "abc".Similarity(""); // → 0.0
double bothEmpty = "".Similarity(""); // → 1.0
double anyCase = "HELLO".Similarity("hello", ignoreCase: true); // → 1.0
- A
nullreceiver or anullotherthrowsArgumentNullException. - Two empty strings score
1.0. An empty string against a non-empty one scores0.0. - Short strings lose a lot per edit. One typo in a four-letter word drops the score to
0.75; one typo in a twelve-letter word drops it only to about0.92.
"Did you mean ...?"
ClosestMatch, ToClosestEnum, GetClosestKey and GetClosest share one set of rules:
- Scoring is
SimilaritywithignoreCase: true. - The default threshold is
0.5. A candidate that scores exactly0.5still counts as a match. - Every method has an overload that takes
double minSimilarity. It must be between0.0and1.0, or the method throwsArgumentOutOfRangeException(ParamNameis"minSimilarity"). - When two candidates have the same best score, the earliest one wins: the first in the sequence, the enum member
that
Enum.GetValueslists first (sorted by unsigned value, so the first declared one under default numbering), the first dictionary entry. - No match returns
null(ordefaultforGetClosest). The methods never throw for "no match".
ClosestMatch
ClosestMatch(string input) and ClosestMatch(string input, double minSimilarity) run on any
IEnumerable<string> of known values and return the one most similar to input, ignoring case. Use it for
command names, file names, product codes or any list of valid choices.
string[] commands = { "start", "stop", "status" };
string? typo = commands.ClosestMatch("statsu"); // → "status"
string? keepsCandidateCase = new[] { "Deploy", "STATUS" }.ClosestMatch("status"); // → "STATUS"
string? nothingClose = new[] { "build", "test" }.ClosestMatch("xyzzy"); // → null
string? empty = Array.Empty<string>().ClosestMatch("status"); // → null
string[] codes = { "abcd" };
string? atThreshold = codes.ClosestMatch("abxy"); // → "abcd" (scores exactly 0.5)
string? strict = codes.ClosestMatch("abxy", 0.9); // → null
string? tie = new[] { "abcx", "abcy" }.ClosestMatch("abcz"); // → "abcx" (earliest wins)
string? anything = new[] { "xyz", "uvw" }.ClosestMatch("abc", 0.0); // → "xyz"
- A
nullsequence or anullinputthrowsArgumentNullException("candidates"or"input"). nullitems in the sequence are skipped.- The result is the candidate as stored, with its own casing, not the input.
- With
minSimilarity0.0any non-empty list returns a candidate, even an unrelated one. - Every candidate is scored, so the cost grows with the number and length of the candidates. That is fine for a few hundred commands or options. It is not a search index.
ClosestMatch scores every candidate even when the input is already valid. Test for an exact match first, as in
the CLI example below, so you only print "did you mean" when the input is really unknown.
ToClosestEnum
ToClosestEnum<T>() and ToClosestEnum<T>(double minSimilarity) find the enum member whose name, or whose
[Description] text, is closest to the string, ignoring case. They are a typo-tolerant version of ToEnum<T>
(see String parsing) for user input, query strings and configuration files.
using System.ComponentModel;
public enum OrderStatus
{
[Description("Waiting for payment")]
Pending,
Active,
Suspended,
Archived,
}
OrderStatus? typo = "pendng".ToClosestEnum<OrderStatus>(); // → OrderStatus.Pending
OrderStatus? exact = "Active".ToClosestEnum<OrderStatus>(); // → OrderStatus.Active
OrderStatus? padded = " ARCHIVED ".ToClosestEnum<OrderStatus>(); // → OrderStatus.Archived
OrderStatus? byText = "waiting for payment".ToClosestEnum<OrderStatus>(); // → OrderStatus.Pending
OrderStatus? unknown = "xyzqw".ToClosestEnum<OrderStatus>(); // → null
OrderStatus? blank = " ".ToClosestEnum<OrderStatus>(); // → null
OrderStatus? loose = "actv".ToClosestEnum<OrderStatus>(0.5); // → OrderStatus.Active
OrderStatus? strict = "actv".ToClosestEnum<OrderStatus>(0.9); // → null
DayOfWeek? day = "Sonday".ToClosestEnum<DayOfWeek>(); // → DayOfWeek.Sunday (one edit from Sunday and Monday)
- The receiver may be
null. Anull, empty or whitespace-only string returnsnull. - Leading and trailing spaces are trimmed before scoring.
- Each member is scored by its name and, when it has one, by its
[Description]text (System.ComponentModel.DescriptionAttribute). Either can win. Tmust be an enum (where T : struct, Enum). The return type isT?, so test it with.HasValueor a pattern such asis OrderStatus status.- An out-of-range
minSimilaritythrowsArgumentOutOfRangeExceptioneven when the text isnull; the threshold is checked first. - Only member names and descriptions are compared. A numeric string such as
"1"is not read as an underlying value, and a flags combination such as"Read, Write"is not split. UseToEnum<T>for those.
See Enums for GetDescription and the other enum helpers.
GetClosestKey
GetClosestKey(string key) and GetClosestKey(string key, double minSimilarity) return the key closest to
key, ignoring case. Use them to tell a user which setting, header or option name they probably meant. They work
on Dictionary<string, TValue>, IDictionary<string, TValue>, IReadOnlyDictionary<string, TValue> and any
IEnumerable<KeyValuePair<string, TValue>>.
Dictionary<string, int> settings = new Dictionary<string, int>
{
["timeout"] = 30,
["retries"] = 3,
["port"] = 8080,
};
string? timeoutKey = settings.GetClosestKey("tmeout"); // → "timeout"
string? retriesKey = settings.GetClosestKey("retrys"); // → "retries"
string? nothing = settings.GetClosestKey("zzzzzzzz"); // → null
string? loose = settings.GetClosestKey("tmout", 0.5); // → "timeout"
string? strict = settings.GetClosestKey("tmout", 0.9); // → null
Dictionary<string, int> limits = new Dictionary<string, int> { ["retries"] = 3, ["retry"] = 1 };
string? exact = limits.GetClosestKey("retry"); // → "retry"
- A
nullsource or anullkeythrowsArgumentNullException("source"or"key"). - An empty source returns
null. - For an
IDictionaryorIReadOnlyDictionary, the exact-key check uses the dictionary's ownTryGetValue, so its comparer applies. Any other sequence of pairs is read once, and its exact check is ordinal. - Entries with a
nullkey are skipped.
When the dictionary's comparer finds the key, GetClosestKey returns the key you passed in. With a
StringComparer.OrdinalIgnoreCase dictionary that holds "Timeout", GetClosestKey("TIMEOUT") returns
"TIMEOUT". Only the fuzzy path returns a key as stored.
At the default threshold of 0.5, "host" matches "port": two letters out of four differ. If your keys are
short, pass a higher minSimilarity, such as 0.7.
GetClosest
GetClosest(string key) and GetClosest(string key, double minSimilarity) return the value stored under the
closest key, with the same rules as GetClosestKey. Use them for a typo-tolerant lookup.
Dictionary<string, int> settings = new Dictionary<string, int>
{
["timeout"] = 30,
["retries"] = 3,
["port"] = 8080,
};
int timeout = settings.GetClosest("tmeout"); // → 30
int missing = settings.GetClosest("zzzzzzzz"); // → 0
Dictionary<string, string> headers = new Dictionary<string, string>
{
["Content-Type"] = "text/plain",
["Content-Length"] = "12",
};
string? length = headers.GetClosest("Content-Length"); // → "12"
Dictionary<string, string> limits = new Dictionary<string, string> { ["timeout"] = "30s" };
string? loose = limits.GetClosest("tmout", 0.5); // → "30s"
string? strict = limits.GetClosest("tmout", 0.9); // → null
- No match returns
default(TValue):nullfor reference types,0forint. - If
defaultis also a valid stored value, you cannot tell "no match" from "matched a zero". CallGetClosestKeyfirst and then index the dictionary.
A "did you mean?" CLI example
This small command-line front end accepts a command and an output format. It runs exact input directly and suggests a correction for a typo. Printing is the point here, so it writes to the console.
string[] commands = { "deploy", "status", "rollback", "logs", "restart" };
foreach (string input in new[] { "deplyo", "rolback", "log", "Status", "xyz" })
{
Console.WriteLine(Describe(input));
}
OutputFormat? format = "tabel".ToClosestEnum<OutputFormat>();
Console.WriteLine($"--format tabel is read as {format}");
string Describe(string input)
{
if (commands.Contains(input))
{
return $"Running '{input}'.";
}
string? suggestion = commands.ClosestMatch(input);
return suggestion is null
? $"Unknown command '{input}'. Run 'help' to list the commands."
: $"Unknown command '{input}'. Did you mean '{suggestion}'?";
}
public enum OutputFormat
{
Json,
Csv,
Table,
}
Unknown command 'deplyo'. Did you mean 'deploy'?
Unknown command 'rolback'. Did you mean 'rollback'?
Unknown command 'log'. Did you mean 'logs'?
Unknown command 'Status'. Did you mean 'status'?
Unknown command 'xyz'. Run 'help' to list the commands.
--format tabel is read as Table
commands.Contains(input) is an ordinal, case-sensitive check, so "Status" is not run directly. The
suggestion still finds "status" because ClosestMatch ignores case. If commands should be case-insensitive,
call commands.Contains(input, StringComparer.OrdinalIgnoreCase) instead.
Natural sort
A plain ordinal sort compares character by character, so "file10" lands before "file2" because '1' is less
than '2'. A natural sort reads every run of digits as a number.
NaturalStringComparer
NaturalStringComparer compares runs of ASCII digits by numeric value and everything else ordinally. It has no
public constructor; use one of its two instances:
NaturalStringComparer.Ordinalis case-sensitive. Upper-case letters sort before lower-case ones, as in an ordinal sort:"B2"comes before"a10".NaturalStringComparer.OrdinalIgnoreCaseignores case (invariant upper-casing):"a10"comes before"B2".
It implements both IComparer<string?> and IEqualityComparer<string?>, so it fits OrderBy, List<T>.Sort,
SortedDictionary, SortedSet, HashSet, Distinct and Dictionary.
string[] images = { "img12.png", "img10.png", "img2.png", "img1.png", "img02.png", "img1a.png" };
string[] sorted = images.OrderBy(name => name, NaturalStringComparer.Ordinal).ToArray();
// → ["img1.png", "img1a.png", "img2.png", "img02.png", "img10.png", "img12.png"]
string[] branches = { "b10", "b2", "B3" };
string[] caseSensitive = branches.OrderBy(name => name, NaturalStringComparer.Ordinal).ToArray();
// → ["B3", "b2", "b10"]
string[] caseInsensitive = branches.OrderBy(name => name, NaturalStringComparer.OrdinalIgnoreCase).ToArray();
// → ["b2", "B3", "b10"]
int versions = NaturalStringComparer.Ordinal.Compare("v1.2.9", "v1.2.10"); // → negative
int nullFirst = NaturalStringComparer.Ordinal.Compare(null, "a"); // → negative
int sameText = NaturalStringComparer.OrdinalIgnoreCase.Compare("FILE10", "file10"); // → 0
int huge = NaturalStringComparer.Ordinal.Compare(
"999999999999999999999",
"1000000000000000000000"); // → negative
Rules:
nullsorts before every string. Twonulls are equal.- Digit runs of any length compare without overflow: the run with more significant digits is larger, and runs of the same length compare digit by digit.
- At the same position a digit sorts before a letter, because
'0'to'9'come before letters in ordinal order:"1"sorts before"a". - A shorter string sorts before a longer one that starts with it:
"ab"before"abc". - Only
'0'to'9'count as digits. Other Unicode digits, such as Arabic-Indic digits, compare as ordinary characters.
Each digit run is a separate whole number. "1.5" sorts before "1.25" (5 is less than 25), which is right
for versions and wrong for decimal prices. A - is an ordinary character, so "-5" is not negative. Sort numeric
data as numbers, not as text.
Equality and leading zeros
Equals(x, y) is true exactly when Compare(x, y) returns 0. GetHashCode hashes the text with the leading
zeros of every digit run stripped (and upper-cased for OrdinalIgnoreCase), so it agrees with Equals. That makes
the comparer safe for HashSet, Dictionary and Distinct.
Runs with the same value but different leading zeros, such as "a1" and "a01", are not equal. They are only
told apart when the rest of the text is equal, and the one with fewer zeros comes first. This keeps the order total
and stable.
bool same = NaturalStringComparer.Ordinal.Equals("file10", "file10"); // → true
bool zeros = NaturalStringComparer.Ordinal.Equals("file10", "file010"); // → false
bool caseDiffers = NaturalStringComparer.Ordinal.Equals("file10", "FILE10"); // → false
bool caseIgnored = NaturalStringComparer.OrdinalIgnoreCase.Equals("File10", "FILE10"); // → true
bool bothNull = NaturalStringComparer.Ordinal.Equals(null, null); // → true
string[] parts = { "a01b", "a1c", "a001b", "a1b" };
List<string> ordered = parts.OrderBy(part => part, NaturalStringComparer.Ordinal).ToList();
// → ["a1b", "a01b", "a001b", "a1c"]
HashSet<string> chapters = new HashSet<string>(NaturalStringComparer.OrdinalIgnoreCase);
bool added = chapters.Add("Chapter10"); // → true
bool duplicate = chapters.Add("chapter10"); // → false
bool different = chapters.Add("chapter010"); // → true
CompareandEqualsacceptnull.GetHashCode(null)throwsArgumentNullException(ParamNameis"obj").HashSetandDictionarydo not call it withnull.NaturalStringCompareris asealedclass that does not derive fromStringComparer. You cannot pass it where aStringComparerparameter is required; pass it as anIComparer<string>orIEqualityComparer<string>.
StringComparer.Natural and NaturalIgnoreCase
StringComparer.Natural and StringComparer.NaturalIgnoreCase are static extension properties that return
NaturalStringComparer.Ordinal and NaturalStringComparer.OrdinalIgnoreCase. They put the natural comparers next
to the built-in ones.
string[] branches = { "b10", "b2", "B3" };
string[] natural = branches.OrderBy(name => name, StringComparer.Natural).ToArray();
// → ["B3", "b2", "b10"]
string[] naturalIgnoreCase = branches.OrderBy(name => name, StringComparer.NaturalIgnoreCase).ToArray();
// → ["b2", "B3", "b10"]
bool sameInstance = ReferenceEquals(StringComparer.Natural, NaturalStringComparer.Ordinal); // → true
Static extension properties are a C# 14 feature. The library runs on any supported runtime, but this syntax needs
a C# 14 compiler, such as the one in the .NET 10 SDK. With an older compiler, use NaturalStringComparer.Ordinal
and NaturalStringComparer.OrdinalIgnoreCase directly. See Extension members.
OrderByNatural
OrderByNatural() sorts an IEnumerable<string> in natural order, ignoring case.
OrderByNatural(Func<T, string?> keySelector) sorts any items by a string key the same way. Both are shorthands
for OrderBy(..., NaturalStringComparer.OrdinalIgnoreCase).
string[] invoices = { "INV-10.pdf", "inv-2.pdf", "INV-1.pdf", "inv-100.pdf", "inv-9.pdf" };
List<string> sorted = invoices.OrderByNatural().ToList();
// → ["INV-1.pdf", "inv-2.pdf", "inv-9.pdf", "INV-10.pdf", "inv-100.pdf"]
public sealed record Document(string? Name, int Id);
Document[] documents =
{
new Document("Chapter 10", 1),
new Document("chapter 2", 2),
new Document("Chapter 2", 3),
new Document(null, 4),
};
int[] ids = documents.OrderByNatural(document => document.Name).Select(document => document.Id).ToArray();
// → [4, 2, 3, 1]
- The result is an
IOrderedEnumerable<T>, soThenByandThenByDescendingchain after it:names.OrderByNatural().ThenBy(name => name.Length). - The sort is stable, like
OrderBy. Above,"chapter 2"and"Chapter 2"are equal ignoring case and keep their original order. nullstrings andnullkeys sort first.- A
nullsource throwsArgumentNullException("source"); anullkey selector throws with"keySelector". The check runs when you call the method, not when you enumerate. - An empty sequence returns an empty sequence.
- There is no case-sensitive overload. For that, call
OrderBy(x => x, NaturalStringComparer.Ordinal).
There is no ThenByNatural. Pass the comparer to ThenBy instead:
orders.OrderBy(order => order.CustomerId).ThenBy(order => order.Number, NaturalStringComparer.OrdinalIgnoreCase).
OrderByNaturalDescending
OrderByNaturalDescending() and OrderByNaturalDescending(Func<T, string?> keySelector) sort largest first,
ignoring case. Use them for "newest version first" lists.
string[] releases = { "v1.2.9", "v1.2.10", "v1.10.0" };
List<string> newestFirst = releases.OrderByNaturalDescending().ToList();
// → ["v1.10.0", "v1.2.10", "v1.2.9"]
Document[] documents =
{
new Document("Chapter 2", 1),
new Document("Chapter 10", 2),
new Document(null, 3),
};
int[] ids = documents
.OrderByNaturalDescending(document => document.Name)
.Select(document => document.Id)
.ToArray();
// → [2, 1, 3]
nullstrings andnullkeys sort last in descending order.- The same argument checks as
OrderByNaturalapply.
See also
- Enums:
GetDescriptionand strict enum parsing. - Dictionaries: the other dictionary lookups and helpers.
- Paging and sorting: more ways to order and page sequences.
- String parsing:
ToEnum<T>and other conversions from text.