01 / The idea
A name is how the data arrived.
Imagine a service-coverage report built from several imported files. The report groups rows by location. The files give us names, sometimes a state, and sometimes a type such as county or city. Our directory gives each place a stable identifier. We need to connect a row to the right identifier before grouping it.
Exact lookup is a good first step. If both sides already share an identifier, use it. If names are consistent and the context identifies a unique record, a text lookup can work too. The awkward rows are the ones a person can recognize but the lookup cannot.
Levenshtein distance is the fewest insertions, deletions, and substitutions needed to turn one string into another. Each edit costs one here. Keeping an equal symbol costs zero. It gives us a spelling distance we can use to suggest records for review.
ST → Saint
A rule supplies knowledge about a supported abbreviation.
Lous → Louis
Insert one i. The edit distance is 1.
Which place?
Use state and jurisdiction information to distinguish records.
Those are three different jobs. A spelling distance does not know that St. can mean Saint, or that a city and a county can have the same name. We’ll keep each responsibility visible as the example grows.
Does this happen outside a teaching example?A documented location-search problem
St. Louis County Library’s June 2020 research guide describes records appearing under “Saint Louis,” “St. Louis,” and “St Louis,” with different place-search results. It also describes confusion between city and county records. Read page 1 of the library’s guide.
Our importer is an authored example of that kind of problem, not a claim about how the library’s search works.
The same choices apply to every code comparison below. The labs run the TypeScript; the other languages are checked natively against the same cases.
Use what you already know.
Our small directory accepts a leading St or St. as Saint. We standardize
ASCII letter case and spaces, then expand that leading token. Both the imported name and the directory
name go through the same preparation.
ST LOUIS, St. Louis, and Saint Louis now have the
comparison key saint louis. With an unambiguous state and jurisdiction, an
exact lookup on that key is enough. We do not need to calculate a distance for equal keys.
But St. Lous becomes saint lous. The rule recognized the
abbreviation; it did not fix the missing letter. This is where edit distance starts helping.
| Imported name | Prepared key | What remains? |
|---|---|---|
ST LOUIS | saint louis | Exact prepared name |
Saint Louis | saint louis | Exact prepared name |
St. Lous | saint lous | One missing i |
export function prepareName(text: string, expandSaint = true): string {
const folded = fold(text);
const key = expandSaint ? folded.replace(/^st\.? /, 'saint ') : folded;
symbols(key); // Expansion must still fit the documented allocation limit.
return key;
}
export function parseState(text: string): 'MO' | 'MN' | null {
const key = fold(text);
if (key === '') return null;
if (key === 'mo' || key === 'missouri') return 'MO';
if (key === 'mn' || key === 'minnesota') return 'MN';
throw new Error('Use MO/Missouri or MN/Minnesota, or leave state missing.');
} func PrepareName(text string, expandSaint bool) (string, error) {
key, err := fold(text)
if err != nil {
return "", err
}
if expandSaint {
if strings.HasPrefix(key, "st. ") {
key = "saint " + key[4:]
} else if strings.HasPrefix(key, "st ") {
key = "saint " + key[3:]
}
}
if _, err := symbols(key); err != nil {
return "", err
}
return key, nil
}
func ParseState(text string) (string, error) {
key, err := fold(text)
if err != nil {
return "", err
}
switch key {
case "":
return "", nil
case "mo", "missouri":
return "MO", nil
case "mn", "minnesota":
return "MN", nil
default:
return "", errors.New("Use MO/Missouri or MN/Minnesota, or leave state missing.")
}
} The shared fold helper handles ASCII case and whitespace; the complete files
include it and validation. State names use their own exact lookup: MO and Missouri identify the same supported state. We never ask a spelling distance
to guess a two-letter state code.
Why not replace every ‘st’ or remove every suffix?Rules need a field and a scope
Stanton contains those letters but does not start with a standalone St token.
In a street suffix, ST means Street, as the USPS suffix table documents. Our
rule applies to this location-name field and its supported names.
The input already has separate name and jurisdiction fields. We do not throw away “County” or “City” from an arbitrary address: that information may distinguish two records. A full address parser needs a different contract.
Even the lowercase strings st louis and saint louis are three
raw edits apart; adding the period makes st. louis four edits away. Our preparation
makes known variants equal before scoring. It does not add a special free edit to Levenshtein.
02 / Name the rule
A table of answers, not guesses.
For saint lous → saint louis, inserting one i works. It must also be
the minimum: the target has one more symbol, so at least one insertion or deletion is necessary.
We have found a one-edit transformation and a reason no cheaper answer exists.
Let’s first focus on the shorter name segment, lous → louis. To handle less
obvious changes, we’ll answer smaller questions: how many edits turn the first two letters, lo, into the first three, lou? These beginnings of a string are
called prefixes.
Start with the easy borders. Turning nothing into lo needs two insertions.
Turning lo into nothing needs two deletions. Then fill the interior one answer at
a time.
Each cell stores the minimum cost for its two prefixes. The cell above tells us the cost before a deletion; the cell to the left, before an insertion; the diagonal, before keeping or replacing the last symbol. We add the final operation’s cost and take the smallest total.
This is dynamic programming: solve smaller problems once, store their answers, and reuse them. The table lets us avoid asking the same prefix question again along every possible sequence of edits.
The recurrence, once the cells have a meaningOptional notation
Let D(i, j) mean the distance between the first i source symbols
and the first j target symbols. The borders are D(i, 0) = i and D(0, j) = j.
For an interior cell, take the minimum of D(i−1, j) + 1, D(i, j−1) + 1, and D(i−1, j−1) + change. Here change is zero for equal trailing symbols and one otherwise. Stanford’s edit-distance treatment develops this prefix formulation.
03 / Follow one operation
How much work is left?
Watch the short example, select its completed steps, or choose Try it to predict and reveal individual cells with your own text.
One answer at a time.
| ∅ | l | o | u | i | s | |
|---|---|---|---|---|---|---|
| ∅ | 0 | 1 | 2 | 3 | 4 | 5 |
| l | 1 | · | · | · | · | · |
| o | 2 | · | · | · | · | · |
| u | 3 | · | · | · | · | · |
| s | 4 | · | · | · | · | · |
∅ → lo
Insert two symbols into empty text.
0 + 2 = 2Alignment follows the completed table.
Start with nothing.
Empty text → lo needs two insertions. The borders give us answers without an earlier cell.
Reduced motion: choose a scene to see its completed state.
Read this scene
Empty text → lo needs two insertions. The borders give us answers without an earlier cell.
0 of 20 interior answers revealed. Selected prefixes: empty text → lo; 2 edits. 0 of 5 alignment operations shown.
Watch and Step through use the same short name segment. Try it starts a fresh table each time you open it; changing text clears its revealed answers.
At the cell for lo → lou, the three totals are 3, 1, and 2. Inserting u wins: we already know that lo → lo costs zero, so extending it by one insertion costs
one.
For deletion, the earlier answer transforms l into lou while the
source’s trailing o waits to be deleted. For substitution, it transforms l into lo before replacing the trailing o with u. Each choice includes the work already needed for the smaller
comparison.
04 / Read the shape
Reuse those answers in code.
export function align(sourceText: string, targetText: string): Alignment {
const source = symbols(sourceText),
target = symbols(targetText);
const m = source.length,
n = target.length;
const table = Array.from({ length: m + 1 }, () => Array<number>(n + 1).fill(0));
for (let i = 0; i <= m; i++) table[i][0] = i;
for (let j = 0; j <= n; j++) table[0][j] = j;
for (let i = 1; i <= m; i++) {
for (let j = 1; j <= n; j++) {
const change = source[i - 1] === target[j - 1] ? 0 : 1;
table[i][j] = Math.min(
table[i - 1][j] + 1, // delete the trailing source symbol
table[i][j - 1] + 1, // insert the trailing target symbol
table[i - 1][j - 1] + change // keep or replace
);
}
}
return { source, target, table, distance: table[m][n], edits: recover(source, target, table) };
} func Align(sourceText, targetText string) (Alignment, error) {
source, err := symbols(sourceText)
if err != nil {
return Alignment{}, err
}
target, err := symbols(targetText)
if err != nil {
return Alignment{}, err
}
m, n := len(source), len(target)
table := make([][]int, m+1)
for i := range table {
table[i] = make([]int, n+1)
table[i][0] = i
}
for j := 0; j <= n; j++ {
table[0][j] = j
}
for i := 1; i <= m; i++ {
for j := 1; j <= n; j++ {
change := 1
if source[i-1] == target[j-1] {
change = 0
}
table[i][j] = min(
table[i-1][j]+1, // delete the trailing source symbol
table[i][j-1]+1, // insert the trailing target symbol
table[i-1][j-1]+change, // keep or replace
)
}
}
return Alignment{source, target, table, table[m][n], recoverEdits(source, target, table)}, nil
} The initialized borders are exact. Moving left to right and top to bottom means all three dependencies are already exact when we fill a cell. Every optimal alignment ends with one of these permitted operations, so the minimum covers the possibilities. That is the rule each filled cell preserves.
The row and column indices only move forward through a finite table. At the final cell, the
prefixes are the complete inputs, so its value is the answer we wanted. The recover helper then walks backward through equally good predecessors to explain one optimal sequence
of edits. It is included in the complete files.
What counts as a character across these languages?Unicode scalar values, not bytes or visible letters
Every example compares Unicode scalar values. TypeScript iterates a string, and Go converts valid UTF-8 to runes. Indexing a JavaScript string by number would instead visit UTF-16 code units; Go string indexing visits bytes. Those are different units.
A visible character can contain more than one scalar. Precomposed café and cafe followed by a combining acute accent look alike but have raw
distance 2 here. An emoji can also contain several scalars. Unicode text segmentation explains grapheme
clusters, the relevant unit if your product needs user-perceived characters.
Reading the TypeScriptfor…of, nested arrays, and null context
symbols walks the string with for…of, which visits Unicode
scalars, and throws on an unpaired surrogate. align builds table as m + 1 rows of n + 1 numbers with Array.from, and Math.min picks the smallest of the three totals.
In review, a missing state or kind is null, and status is one of six strings, such as alias or ambiguous. Errors are plain Errors with a message the labs
show as written.
Reading the GoRunes, returned errors, and empty strings
symbols checks utf8.ValidString, then converts the text to []rune, so the table’s indexes count scalars, not bytes. Align returns (Alignment, error), and every caller checks the error before it
reads the table. The built-in min takes all three totals at once.
A missing state or kind is the empty string, where TypeScript uses null. sort.Slice orders candidates by distance, then by identifier.
What is refusedText, cutoffs, states, and kinds
The distance function performs no case conversion or normalization. It rejects malformed text where the language permits it and caps each input at 64 scalars to bound this teaching table. The browser table has a separate 24-scalar limit so it remains inspectable.
review checks the cutoff first, a whole number from 0 through 8, then the state
(MO, Missouri, MN, Minnesota, or missing), then the kind (county, city, or missing), then
prepares the name. An expanded name must still fit in 64 scalars. Nothing is truncated. A
missing state or kind is not an error: it keeps the row in review.
Both languages run the same shared cases.
05 / Try a decision
Two good spellings can still be two places.
The city of St. Louis and St. Louis County are separate governmental entities. The city’s explanation makes that distinction explicit. Minnesota also has a St. Louis County. The same prepared name can therefore point to several valid records.
A different string metric cannot supply that missing geographic information. Try making the decision with exactly the evidence the importer has.
06 / Follow the cost
More characters, more prefix pairs.
| Operation | Time | Extra space | What it assumes |
|---|---|---|---|
| Fill the table | O(mn) | O(mn) | (m + 1)(n + 1) cells, borders included. m and n count Unicode scalars, not bytes. |
| Recover one alignment | O(m + n) | O(m + n) | Walk back from the last cell, one step per kept or edited symbol. |
| Distance only, two rows | O(mn) | O(n) | Not shown here. Keep the previous row and the current one; no prefix inspection and no alignment. |
| Prepare a name | O(L) | O(L) | Fold ASCII case and spaces, then expand a leading St or St. L is the name’s length. |
| Review one imported row | O(R·mn + R log R) | O(mn + R) | Filter the R directory records by state and type first. Each remaining record with a different key costs one table; the survivors are sorted. |
The table for lous → louis fills 20 interior cells and contains 30 cells
including borders. For saint lous → saint louis, those counts become 110 and
132. More characters mean more pairs of prefixes. Comparing many directory records repeats
that work.
For lengths m and n, the full table takes O(mn) time and space: (m + 1)(n + 1) cells, borders included. Preparing names, filtering records, sorting,
and recovering the path come on top.
What would change for a larger directory?Keep the required result in view
Use known state and jurisdiction information to narrow candidates, and cache prepared directory names rather than rebuilding them for every row. If you need only the number, retaining the previous row and the current row reduces the distance table to O(n) working space. Our full table also supports inspecting every prefix and recovering an alignment.
A threshold-aware algorithm can avoid some work when the caller only cares about distances
within a bound. PostgreSQL’s fuzzystrmatch extension exposes both levenshtein and levenshtein_less_equal, for strings
of up to 255 characters. For a nonnegative bound, the latter returns the exact distance
within that bound, but only some value above it when the true distance is larger. Do not
rank distant candidates by those above-bound values as though they were exact.
Our source caps inputs at 64 scalars and cutoffs at 8. The review lab caps imported names at 20 scalars; abbreviation expansion still fits the table lab’s 24. A larger application needs limits suited to its workload. Parsing files, maintaining the reference directory, persistence, historical jurisdiction changes, and coordinates remain separate responsibilities.
07 / Give it a real job
A number becomes a suggestion.
Return to the whole row: St. Lous, MO, county. First
keep the directory records compatible with the known state and type. Then compare the full
prepared names, retaining candidates within a chosen maximum number of edits.
Our four-record directory contains St. Louis County in Missouri, St. Charles County in Missouri, St. Louis County in Minnesota, and St. Louis city in Missouri. The state excludes Minnesota; the type excludes the city. Of the remaining names, St. Louis is one edit away and St. Charles is six.
Before removing the state, predict what will happen to the suggestions. Then try “City or county?”: the name will be spelled correctly, but useful context will be missing.
Which location belongs in this row?
An imported service-coverage row needs a directory record before it can join a report. Try a known spelling, a typo, then missing context.
The name field excludes a County/City suffix; jurisdiction is recorded separately. This directory accepts MO/Missouri and MN/Minnesota. A blank context field means missing; an unrecognized value needs correction.
Original: St. Lous → comparison key: saint lous
ASCII letter case and whitespace are standardized. The leading Saint abbreviation rule is on. These are application rules, separate from counting edits.
Why 2 records were excluded before scoring
- St. Louis County, Minnesota — different state.
- St. Louis city, Missouri — different jurisdiction type.
Static example: St. Lous, MO, county suggests St. Louis County, Missouri at distance 1. Supplying the state excludes the Minnesota county; supplying “county” excludes the Missouri city.
export function review(
name: string,
stateText: string,
kindText: string,
cutoff: number,
expandSaint = true
): Review {
if (!Number.isInteger(cutoff) || cutoff < 0 || cutoff > 8)
throw new Error('Cutoff must be an integer from 0 through 8.');
const state = parseState(stateText);
if (kindText !== '' && kindText !== 'county' && kindText !== 'city')
throw new Error('Kind must be county, city, or missing.');
const kind = kindText || null;
const key = prepareName(name, expandSaint);
const result: Review = { key, state, kind, status: 'blank', candidates: [], excluded: [] };
if (!key) return result;
for (const place of directory()) {
if (state && place.state !== state) {
result.excluded.push({ place, reason: 'Different state' });
continue;
}
if (kind && place.kind !== kind) {
result.excluded.push({ place, reason: 'Different jurisdiction type' });
continue;
}
const candidateKey = prepareName(place.name, expandSaint);
// Exact prepared keys need no dynamic-programming table.
const distance = key === candidateKey ? 0 : align(key, candidateKey).distance;
if (distance <= cutoff) result.candidates.push({ place, key: candidateKey, distance });
}
result.candidates.sort(
(a, b) =>
a.distance - b.distance || (a.place.id < b.place.id ? -1 : a.place.id > b.place.id ? 1 : 0)
);
const first = result.candidates[0];
if (!first) result.status = 'none';
else if (!state || !kind || result.candidates[1]?.distance === first.distance)
result.status = 'ambiguous';
else if (first.distance === 0) result.status = name === first.place.name ? 'exact' : 'alias';
else result.status = 'suggestions';
return result;
} func Review(name, stateText, kind string, cutoff int, expandSaint bool) (ReviewResult, error) {
if cutoff < 0 || cutoff > 8 {
return ReviewResult{}, errors.New("Cutoff must be an integer from 0 through 8.")
}
state, err := ParseState(stateText)
if err != nil {
return ReviewResult{}, err
}
if kind != "" && kind != "county" && kind != "city" {
return ReviewResult{}, errors.New("Kind must be county, city, or missing.")
}
key, err := PrepareName(name, expandSaint)
if err != nil {
return ReviewResult{}, err
}
result := ReviewResult{key, state, kind, "blank", []Candidate{}, []Excluded{}}
if key == "" {
return result, nil
}
for _, place := range Directory() {
if state != "" && place.State != state {
result.Excluded = append(result.Excluded, Excluded{place, "Different state"})
continue
}
if kind != "" && place.Kind != kind {
result.Excluded = append(result.Excluded, Excluded{place, "Different jurisdiction type"})
continue
}
candidateKey, err := PrepareName(place.Name, expandSaint)
if err != nil {
return ReviewResult{}, err
}
distance := 0 // Exact prepared keys need no dynamic-programming table.
if key != candidateKey {
alignment, err := Align(key, candidateKey)
if err != nil {
return ReviewResult{}, err
}
distance = alignment.Distance
}
if distance <= cutoff {
result.Candidates = append(result.Candidates, Candidate{place, candidateKey, distance})
}
}
sort.Slice(result.Candidates, func(i, j int) bool {
a, b := result.Candidates[i], result.Candidates[j]
if a.Distance != b.Distance {
return a.Distance < b.Distance
}
return a.Place.ID < b.Place.ID
})
if len(result.Candidates) == 0 {
result.Status = "none"
} else {
first := result.Candidates[0]
if state == "" || kind == "" || (len(result.Candidates) > 1 && result.Candidates[1].Distance == first.Distance) {
result.Status = "ambiguous"
} else if first.Distance == 0 {
result.Status = "alias"
if name == first.Place.Name {
result.Status = "exact"
}
} else {
result.Status = "suggestions"
}
}
return result, nil
} The cutoff counts edits; it is not a confidence. Ties stay, ordered by identifier. A missing state or type keeps the row in review even at distance zero, and an invalid state is an error rather than a reason to search wider.
The distance function knows nothing about places. The wrapper owns the alias rule, the filtering, and the cutoff. The caller owns the proposed mapping and clears it whenever an input changes.
export function runExample(): void {
for (const row of [
{ name: 'ST LOUIS', state: 'MO', kind: 'county' },
{ name: 'St. Lous', state: 'Missouri', kind: 'county' },
{ name: 'Saint Louis', state: '', kind: 'county' }
]) {
const result = review(row.name, row.state, row.kind, 2);
console.log(
result.status +
'\t' +
result.candidates.map(({ place, distance }) => `${place.id}:${distance}`).join(',')
);
// The caller presents candidates. A reviewer chooses a record before any join.
}
} func main() {
for _, row := range []struct{ Name, State, Kind string }{
{"ST LOUIS", "MO", "county"},
{"St. Lous", "Missouri", "county"},
{"Saint Louis", "", "county"},
} {
result, err := Review(row.Name, row.State, row.Kind, 2, true)
if err != nil {
fmt.Println(err)
continue
}
matches := []string{}
for _, candidate := range result.Candidates {
matches = append(matches, fmt.Sprintf("%s:%d", candidate.Place.ID, candidate.Distance))
}
fmt.Printf("%s\t%s\n", result.Status, strings.Join(matches, ","))
// The caller presents candidates. A reviewer chooses a record before any join.
}
} The example prints an alias match, a typo suggestion, and an ambiguous pair. It does not join records automatically. In an application, a confirmed selection supplies the location identifier to the next stage of the import.
Complete runnable filesIncludes validation, directory, alignment recovery, and invocation
Copy the complete file. For TypeScript, compile with tsc locations.ts --target ES2022 --module commonjs --outDir out, then run node out/locations.js in a standalone folder without a package
configured as an ES module. Go: go run locations.go (Go 1.22+).
These use no external libraries. The TypeScript complete view includes runExample(); Go includes main.
// A bounded, inspectable teaching implementation. No external dependencies.
export const MAX_SYMBOLS = 64;
export function symbols(text: string): string[] {
const result: string[] = [];
for (const symbol of text) {
const point = symbol.codePointAt(0)!;
if (point >= 0xd800 && point <= 0xdfff) throw new Error('Text must be well-formed Unicode.');
result.push(symbol);
if (result.length > MAX_SYMBOLS)
throw new Error('Text is limited to 64 Unicode scalar values.');
}
return result;
}
export type Edit = {
kind: 'keep' | 'replace' | 'delete' | 'insert';
from: string;
to: string;
row: number;
column: number;
};
export type Alignment = {
source: string[];
target: string[];
table: number[][];
distance: number;
edits: Edit[];
};
export function align(sourceText: string, targetText: string): Alignment {
const source = symbols(sourceText),
target = symbols(targetText);
const m = source.length,
n = target.length;
const table = Array.from({ length: m + 1 }, () => Array<number>(n + 1).fill(0));
for (let i = 0; i <= m; i++) table[i][0] = i;
for (let j = 0; j <= n; j++) table[0][j] = j;
for (let i = 1; i <= m; i++) {
for (let j = 1; j <= n; j++) {
const change = source[i - 1] === target[j - 1] ? 0 : 1;
table[i][j] = Math.min(
table[i - 1][j] + 1, // delete the trailing source symbol
table[i][j - 1] + 1, // insert the trailing target symbol
table[i - 1][j - 1] + change // keep or replace
);
}
}
return { source, target, table, distance: table[m][n], edits: recover(source, target, table) };
}
// Ties prefer diagonal (keep/replace), then deletion, then insertion.
function recover(source: string[], target: string[], table: number[][]): Edit[] {
const edits: Edit[] = [];
let i = source.length,
j = target.length;
while (i > 0 || j > 0) {
const from = i > 0 ? source[i - 1] : '',
to = j > 0 ? target[j - 1] : '';
if (i > 0 && j > 0 && table[i][j] === table[i - 1][j - 1] + (from === to ? 0 : 1)) {
edits.push({ kind: from === to ? 'keep' : 'replace', from, to, row: i, column: j });
i--;
j--;
} else if (i > 0 && table[i][j] === table[i - 1][j] + 1) {
edits.push({ kind: 'delete', from, to: '', row: i, column: j });
i--;
} else {
edits.push({ kind: 'insert', from: '', to, row: i, column: j });
j--;
}
}
return edits.reverse();
}
function fold(text: string): string {
symbols(text);
return text
.replace(/[A-Z]/g, (letter) => letter.toLowerCase())
.replace(/[\t\n\v\f\r ]+/g, ' ')
.replace(/^ | $/g, '');
}
export function prepareName(text: string, expandSaint = true): string {
const folded = fold(text);
const key = expandSaint ? folded.replace(/^st\.? /, 'saint ') : folded;
symbols(key); // Expansion must still fit the documented allocation limit.
return key;
}
export function parseState(text: string): 'MO' | 'MN' | null {
const key = fold(text);
if (key === '') return null;
if (key === 'mo' || key === 'missouri') return 'MO';
if (key === 'mn' || key === 'minnesota') return 'MN';
throw new Error('Use MO/Missouri or MN/Minnesota, or leave state missing.');
}
export type Place = {
id: string;
name: string;
label: string;
state: 'MO' | 'MN';
kind: 'county' | 'city';
};
export function directory(): Place[] {
// Authored stable keys, not official geographic codes. Fresh value records per call.
return [
{
id: 'mo-st-louis-county',
name: 'St. Louis',
label: 'St. Louis County, Missouri',
state: 'MO',
kind: 'county'
},
{
id: 'mo-st-charles-county',
name: 'St. Charles',
label: 'St. Charles County, Missouri',
state: 'MO',
kind: 'county'
},
{
id: 'mn-st-louis-county',
name: 'St. Louis',
label: 'St. Louis County, Minnesota',
state: 'MN',
kind: 'county'
},
{
id: 'mo-st-louis-city',
name: 'St. Louis',
label: 'St. Louis city, Missouri',
state: 'MO',
kind: 'city'
}
];
}
export type Candidate = { place: Place; key: string; distance: number };
export type Review = {
key: string;
state: 'MO' | 'MN' | null;
kind: 'county' | 'city' | null;
status: 'blank' | 'none' | 'ambiguous' | 'exact' | 'alias' | 'suggestions';
candidates: Candidate[];
excluded: { place: Place; reason: string }[];
};
export function review(
name: string,
stateText: string,
kindText: string,
cutoff: number,
expandSaint = true
): Review {
if (!Number.isInteger(cutoff) || cutoff < 0 || cutoff > 8)
throw new Error('Cutoff must be an integer from 0 through 8.');
const state = parseState(stateText);
if (kindText !== '' && kindText !== 'county' && kindText !== 'city')
throw new Error('Kind must be county, city, or missing.');
const kind = kindText || null;
const key = prepareName(name, expandSaint);
const result: Review = { key, state, kind, status: 'blank', candidates: [], excluded: [] };
if (!key) return result;
for (const place of directory()) {
if (state && place.state !== state) {
result.excluded.push({ place, reason: 'Different state' });
continue;
}
if (kind && place.kind !== kind) {
result.excluded.push({ place, reason: 'Different jurisdiction type' });
continue;
}
const candidateKey = prepareName(place.name, expandSaint);
// Exact prepared keys need no dynamic-programming table.
const distance = key === candidateKey ? 0 : align(key, candidateKey).distance;
if (distance <= cutoff) result.candidates.push({ place, key: candidateKey, distance });
}
result.candidates.sort(
(a, b) =>
a.distance - b.distance || (a.place.id < b.place.id ? -1 : a.place.id > b.place.id ? 1 : 0)
);
const first = result.candidates[0];
if (!first) result.status = 'none';
else if (!state || !kind || result.candidates[1]?.distance === first.distance)
result.status = 'ambiguous';
else if (first.distance === 0) result.status = name === first.place.name ? 'exact' : 'alias';
else result.status = 'suggestions';
return result;
}
export function runExample(): void {
for (const row of [
{ name: 'ST LOUIS', state: 'MO', kind: 'county' },
{ name: 'St. Lous', state: 'Missouri', kind: 'county' },
{ name: 'Saint Louis', state: '', kind: 'county' }
]) {
const result = review(row.name, row.state, row.kind, 2);
console.log(
result.status +
'\t' +
result.candidates.map(({ place, distance }) => `${place.id}:${distance}`).join(',')
);
// The caller presents candidates. A reviewer chooses a record before any join.
}
}
runExample();
package main
import (
"errors"
"fmt"
"sort"
"strings"
"unicode/utf8"
)
const MaxSymbols = 64
func symbols(text string) ([]rune, error) {
if !utf8.ValidString(text) {
return nil, errors.New("Text must be well-formed Unicode.")
}
if utf8.RuneCountInString(text) > MaxSymbols {
return nil, errors.New("Text is limited to 64 Unicode scalar values.")
}
return []rune(text), nil
}
type Edit struct {
Kind, From, To string
Row, Column int
}
type Alignment struct {
Source, Target []rune
Table [][]int
Distance int
Edits []Edit
}
func Align(sourceText, targetText string) (Alignment, error) {
source, err := symbols(sourceText)
if err != nil {
return Alignment{}, err
}
target, err := symbols(targetText)
if err != nil {
return Alignment{}, err
}
m, n := len(source), len(target)
table := make([][]int, m+1)
for i := range table {
table[i] = make([]int, n+1)
table[i][0] = i
}
for j := 0; j <= n; j++ {
table[0][j] = j
}
for i := 1; i <= m; i++ {
for j := 1; j <= n; j++ {
change := 1
if source[i-1] == target[j-1] {
change = 0
}
table[i][j] = min(
table[i-1][j]+1, // delete the trailing source symbol
table[i][j-1]+1, // insert the trailing target symbol
table[i-1][j-1]+change, // keep or replace
)
}
}
return Alignment{source, target, table, table[m][n], recoverEdits(source, target, table)}, nil
}
// Ties prefer diagonal (keep/replace), then deletion, then insertion.
func recoverEdits(source, target []rune, table [][]int) []Edit {
edits := []Edit{}
i, j := len(source), len(target)
for i > 0 || j > 0 {
from, to := "", ""
if i > 0 {
from = string(source[i-1])
}
if j > 0 {
to = string(target[j-1])
}
change := 1
if from == to {
change = 0
}
if i > 0 && j > 0 && table[i][j] == table[i-1][j-1]+change {
kind := "replace"
if change == 0 {
kind = "keep"
}
edits = append(edits, Edit{kind, from, to, i, j})
i--
j--
} else if i > 0 && table[i][j] == table[i-1][j]+1 {
edits = append(edits, Edit{"delete", from, "", i, j})
i--
} else {
edits = append(edits, Edit{"insert", "", to, i, j})
j--
}
}
for left, right := 0, len(edits)-1; left < right; left, right = left+1, right-1 {
edits[left], edits[right] = edits[right], edits[left]
}
return edits
}
func fold(text string) (string, error) {
if _, err := symbols(text); err != nil {
return "", err
}
var out strings.Builder
space := false
for _, c := range text {
if c == ' ' || (c >= '\t' && c <= '\r') {
space = out.Len() > 0
continue
}
if space {
out.WriteByte(' ')
space = false
}
if c >= 'A' && c <= 'Z' {
c += 'a' - 'A'
}
out.WriteRune(c)
}
return out.String(), nil
}
func PrepareName(text string, expandSaint bool) (string, error) {
key, err := fold(text)
if err != nil {
return "", err
}
if expandSaint {
if strings.HasPrefix(key, "st. ") {
key = "saint " + key[4:]
} else if strings.HasPrefix(key, "st ") {
key = "saint " + key[3:]
}
}
if _, err := symbols(key); err != nil {
return "", err
}
return key, nil
}
func ParseState(text string) (string, error) {
key, err := fold(text)
if err != nil {
return "", err
}
switch key {
case "":
return "", nil
case "mo", "missouri":
return "MO", nil
case "mn", "minnesota":
return "MN", nil
default:
return "", errors.New("Use MO/Missouri or MN/Minnesota, or leave state missing.")
}
}
type Place struct{ ID, Name, Label, State, Kind string }
func Directory() []Place {
// Authored stable keys, not official geographic codes. Fresh value records per call.
return []Place{
{"mo-st-louis-county", "St. Louis", "St. Louis County, Missouri", "MO", "county"},
{"mo-st-charles-county", "St. Charles", "St. Charles County, Missouri", "MO", "county"},
{"mn-st-louis-county", "St. Louis", "St. Louis County, Minnesota", "MN", "county"},
{"mo-st-louis-city", "St. Louis", "St. Louis city, Missouri", "MO", "city"},
}
}
type Candidate struct {
Place Place
Key string
Distance int
}
type Excluded struct {
Place Place
Reason string
}
type ReviewResult struct {
Key, State, Kind, Status string
Candidates []Candidate
Excluded []Excluded
}
func Review(name, stateText, kind string, cutoff int, expandSaint bool) (ReviewResult, error) {
if cutoff < 0 || cutoff > 8 {
return ReviewResult{}, errors.New("Cutoff must be an integer from 0 through 8.")
}
state, err := ParseState(stateText)
if err != nil {
return ReviewResult{}, err
}
if kind != "" && kind != "county" && kind != "city" {
return ReviewResult{}, errors.New("Kind must be county, city, or missing.")
}
key, err := PrepareName(name, expandSaint)
if err != nil {
return ReviewResult{}, err
}
result := ReviewResult{key, state, kind, "blank", []Candidate{}, []Excluded{}}
if key == "" {
return result, nil
}
for _, place := range Directory() {
if state != "" && place.State != state {
result.Excluded = append(result.Excluded, Excluded{place, "Different state"})
continue
}
if kind != "" && place.Kind != kind {
result.Excluded = append(result.Excluded, Excluded{place, "Different jurisdiction type"})
continue
}
candidateKey, err := PrepareName(place.Name, expandSaint)
if err != nil {
return ReviewResult{}, err
}
distance := 0 // Exact prepared keys need no dynamic-programming table.
if key != candidateKey {
alignment, err := Align(key, candidateKey)
if err != nil {
return ReviewResult{}, err
}
distance = alignment.Distance
}
if distance <= cutoff {
result.Candidates = append(result.Candidates, Candidate{place, candidateKey, distance})
}
}
sort.Slice(result.Candidates, func(i, j int) bool {
a, b := result.Candidates[i], result.Candidates[j]
if a.Distance != b.Distance {
return a.Distance < b.Distance
}
return a.Place.ID < b.Place.ID
})
if len(result.Candidates) == 0 {
result.Status = "none"
} else {
first := result.Candidates[0]
if state == "" || kind == "" || (len(result.Candidates) > 1 && result.Candidates[1].Distance == first.Distance) {
result.Status = "ambiguous"
} else if first.Distance == 0 {
result.Status = "alias"
if name == first.Place.Name {
result.Status = "exact"
}
} else {
result.Status = "suggestions"
}
}
return result, nil
}
func main() {
for _, row := range []struct{ Name, State, Kind string }{
{"ST LOUIS", "MO", "county"},
{"St. Lous", "Missouri", "county"},
{"Saint Louis", "", "county"},
} {
result, err := Review(row.Name, row.State, row.Kind, 2, true)
if err != nil {
fmt.Println(err)
continue
}
matches := []string{}
for _, candidate := range result.Candidates {
matches = append(matches, fmt.Sprintf("%s:%d", candidate.Place.ID, candidate.Distance))
}
fmt.Printf("%s\t%s\n", result.Status, strings.Join(matches, ","))
// The caller presents candidates. A reviewer chooses a record before any join.
}
}
Build UIs?Your compiler already suggests names this way, and one day a form field will need you to.
Where it already is in your components
Misspell a property and TypeScript answers Property 'lenght' does not exist on type 'string'. Did you mean 'length'? Its getSpellingSuggestion only compares your name with the properties that type actually has, or the names in scope, then
scores the survivors with a weighted edit distance: a change of letter case costs a tenth of
an edit, any other substitution costs two. Context first, spelling second, the same order as
filtering by state before scoring a county.
Svelte’s compiler does it for ARIA roles. <div role="buton"> warns Unknown role 'buton'. Did you mean 'button'? Its fuzzy matcher shortlists roles that share runs of letters, then ranks them with Levenshtein.
React’s hint looks the same and works differently. Write <label class="hint"> in JSX and the development build warns Invalid DOM property `class`. Did you mean `className`? No distance is
computed. React lowercases the name and looks it up in possibleStandardNames, a table that maps class to className and for to htmlFor. That is section 01’s alias rule. A variation you know about gets a
table; only the typo nobody listed needs a distance.
When you have to own it
A signup form, and someone types sam@gmial.com. The account is created, the
welcome email bounces, and they never find out why. No framework catches that for you. A
hint under the field, “Did you mean sam@gmail.com?”, can catch it before they submit.
The snippet below is this lesson at the size of a form field. The list of domains is the
knowledge, and the lesson’s own align measures the spelling. It stays quiet
for a listed domain, because mail.com is a real provider one edit from gmail.com. It stays quiet when two domains tie, because a guess is worse than
silence. And gmial.com is two edits, not one: a swapped pair is not a
Levenshtein operation, as luois showed.
Keep it a suggestion the person accepts with a click. A real domain missing from your list can still sit an edit or two from one on it, and nobody should have their address rewritten for them. Clear the hint when the field changes, the way the review lab clears a proposed mapping. The widely used mailcheck library does the same job with a different metric, sift4, and a threshold of two edits. Choosing the metric is part of the job.
import { align } from '../locations';
// The domains your signups actually use. The list is the knowledge; distance only measures spelling.
export const KNOWN_DOMAINS = [
'gmail.com',
'yahoo.com',
'hotmail.com',
'outlook.com',
'icloud.com',
'aol.com',
'mail.com'
];
export type Hint = { suggestion: string; domain: string; distance: number };
// Suggest, never correct: return a hint for the form to show, or null.
export function emailHint(
email: string,
domains: readonly string[] = KNOWN_DOMAINS,
maxEdits = 2
): Hint | null {
const at = email.lastIndexOf('@');
if (at < 1 || at === email.length - 1) return null; // not an address yet
const typed = email.slice(at + 1).toLowerCase();
// A listed domain is real, even when it sits one edit from another (mail.com, gmail.com).
if (domains.includes(typed)) return null;
let best: { domain: string; distance: number } | null = null;
let tied = false;
for (const domain of domains) {
let distance: number;
try {
distance = align(typed, domain).distance;
} catch {
return null; // longer than the lesson's implementation accepts
}
if (distance > maxEdits) continue;
if (!best || distance < best.distance) {
best = { domain, distance };
tied = false;
} else if (distance === best.distance) {
tied = true;
}
}
// Two equally close domains: a guess would be worse than no hint.
if (!best || tied) return null;
return { suggestion: email.slice(0, at + 1) + best.domain, ...best };
}
08 / Make the call
Spend fuzzy matching on the uncertain rows.
This approach fits short imported names, spelling mistakes, a plausible candidate pool, and a person who benefits from fewer manual searches. If a shared geographic identifier or an approved alias already settles the row, use that direct information. If names have changed over time, a maintained alias directory may matter more than a smaller spelling score.
One typing mistake is not always one allowed edit.
Try luois → louis in the table. Swapping the u and o feels like one mistake, but ordinary Levenshtein gives distance 2: a swap is not
one of its operations. An edit distance that explicitly supports adjacent transpositions, such
as Damerau–Levenshtein, may better match that requirement. Check the chosen variant’s contract.
Jaro–Winkler is another option for comparing names, with a different scoring rule and shared-prefix behavior. It does not turn a score into geographic identity. For locations, choosing suitable context and aliases remains part of the job whichever string comparison you choose.
Sources and the scope of this example
- NIST: Levenshtein distance defines the minimum edit count.
- Stanford: edit distance explains prefix dynamic programming and spelling suggestions.
- St. Charles County’s official site, the city and Census references above identify the example’s jurisdictions. Our slug identifiers are teaching keys, not official geographic codes.
- JavaScript string iteration and Go strings and runes explain the text units used by these implementations.
- NIST: Jaro–Winkler describes the alternative similarity measure.
These are algorithm, API, and geographic references. They do not establish that a named organization uses this lesson’s implementation. Checked 10 September 2026.
09 / Take the idea with you
Explain a spelling suggestion without saying “Levenshtein.”
“Store the cheapest way to compare smaller beginnings of the two strings. Each next answer adds a deletion, insertion, or possible replacement to an earlier answer. The last cell gives the fewest edits for the whole pair. Known variants get explicit rules, remaining spelling differences produce suggestions, and context and review decide which location the row describes.”
From memory, explain why St. Louis and Saint Louis need an alias
rule, why Lous and Louis are one edit apart, and why even a distance
of zero may leave a row unresolved. Then name a messy field in your own work where this would
help, or where an exact identifier would solve the problem earlier.
And the next time your editor asks “Did you mean…?”, you know what it counted, and what it had to know before it could count.
Connections to follow nextRelated lessons
- Dynamic programming explains how the stored prefix answers can be reused, and Dynamic array gives them indexed storage.
- Jaro–Winkler similarity compares names under another scoring rule.
- Myers diff explains the changes between two whole sequences.