← Applied algorithms
Text, names, and changes From a spelling difference to a useful suggestion

Levenshtein distance

Find the place behind the spelling.

You have probably seen one today: Property 'lenght' does not exist on type 'string'. Did you mean 'length'? TypeScript found length by scoring how many small edits separate the two names. That count has a name, and it is just as useful when the typo is in your data.

You’re joining two files for a report. One calls a county ST LOUIS, another Saint Louis, and then a row arrives with St. Lous. We can teach the importer the variations we already know. For the spelling left over, we need to ask: how many edits apart are these names?

TypeScriptGoOne location-review workflow · the same behavior in each language

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.

Known variation

ST → Saint

A rule supplies knowledge about a supported abbreviation.

Remaining typo

Lous → Louis

Insert one i. The edit distance is 1.

Location identity

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.

Follow the example in your languages.

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.

Original names remain intact. These keys are only for comparison.
Imported namePrepared keyWhat remains?
ST LOUISsaint louisExact prepared name
Saint Louissaint louisExact prepared name
St. Loussaint lousOne missing i
TypeScriptPrepare the name and state
locations.ts
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.');
}
GoPrepare the name and state
locations.go
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.

Levenshtein

One answer at a time.

NAME SEGMENTlous → louis0 / 20 answers
Source ↓ · Target → · ∅ = empty
∅louis
∅012345
l1·····
o2·····
u3·····
s4·····

∅ → lo

Insert two symbols into empty text.

0 + 2 = 2
SourceTarget
··…
··…
··…
··…
··…

Alignment follows the completed table.

01/ 04
Empty prefixes

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.

TypeScriptCompute prefix distances
locations.ts
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) };
}
GoCompute prefix distances
locations.go
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.

One typo. Two counties. What should the importer do?

The row says St. Lous, its type is county, and its state is missing. St. Louis County in Minnesota and St. Louis County in Missouri both score 1.

06 / Follow the cost

More characters, more prefix pairs.

Levenshtein: time and extra space, for names of m and n scalars and R directory records
OperationTimeExtra spaceWhat it assumes
Fill the tableO(mn)O(mn)(m + 1)(n + 1) cells, borders included. m and n count Unicode scalars, not bytes.
Recover one alignmentO(m + n)O(m + n)Walk back from the last cell, one step per kept or edited symbol.
Distance only, two rowsO(mn)O(n)Not shown here. Keep the previous row and the current one; no prefix inspection and no alignment.
Prepare a nameO(L)O(L)Fold ASCII case and spaces, then expand a leading St or St. L is the name’s length.
Review one imported rowO(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.

A close spelling is available for review.

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.

St. Louis County, Missouri

1 edit

Compare saint lous with saint louis.

Explain these edits ↗
Why 2 records were excluded before scoring
  • St. Louis County, Minnesota — different state.
  • St. Louis city, Missouri — different jurisdiction type.
An authored four-record US directory, using real place names and teaching identifiers. This local review runs the displayed TypeScript. Selecting a candidate proposes a mapping; it does not import or save a record.
TypeScriptSuggest compatible locations
locations.ts
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;
}
GoSuggest compatible locations
locations.go
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.

TypeScriptUse the result at the call site
locations.ts
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.
	}
}
GoUse the result at the call site
locations.go
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.

TypeScriptComplete locations example
locations.ts
// 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();
GoComplete locations example
locations.go
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.

email-hint.ts
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

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

Copy the complete example, change the three imported rows, and predict both the distances and the review outcomes before running it. Notice which answers change when only the state changes.

Back to applied algorithms →