dawg-text
v0.5.0
Published
Frequency-ranked word autocomplete + fuzzy spell-check, backed by a compact trie/DAWG. Works in plain JS, React, and Node.
Maintainers
Readme
dawg-text
Frequency-ranked word autocomplete (Tab-complete) + fuzzy spell-check, backed by a single trie/DAWG-style structure. One dictionary, two traversals — exact-prefix walk for autocomplete, bounded edit-distance walk for spell-check. No separate SymSpell index needed (see reasoning below).
Ships with a production-ready ~108k-word English dictionary built in — install and use it, no separate dictionary file to fetch, build, or serve yourself.
Install
npm install dawg-textQuick start (plain JS/TS)
import { DawgText } from 'dawg-text';
const spell = new DawgText({ editDistance: 2, maxSuggestions: 3, learningBoost: 25 });
await spell.whenReady(); // the bundled English dictionary auto-loads on construction — see "The bundled dictionary" below
spell.suggestNextWord('hel'); // ["hello", "help", "held"] — frequency ranked
spell.checkText('I recieve you mesage zzqx');
// [{ word: 'recieve', start: 2, end: 9, suggestions: ['receive'] },
// { word: 'mesage', start: 15, end: 21, suggestions: ['message', 'massage'] },
// { word: 'zzqx', start: 22, end: 26, suggestions: [], message: 'No edits available' }]
// suggestions is capped at `maxSuggestions` (default 3, ranked best-first); when a
// misspelled word has no close-enough match, suggestions is [] and `message` explains why.
spell.checkText('I recieve you mesag'); // 'mesag' still mid-word — flagged too, by default
spell.checkText('I recieve you mesag', true);
// [{ word: 'recieve', ... }] — last word skipped since nothing follows it yet (no
// trailing space/./,/etc). Pass `ignoreIncompleteLastWord: true` to the constructor
// instead to make this the default for every checkText() call.
spell.disable(); // turns both features off; methods return [] while disabled
spell.enable();
spell.addWord('DoubleTick', 50); // "Add to dictionary" — word won't be flagged by checkText again
spell.clearCustomWords(); // undo every addWord() acceptance, back to just the loaded dictionary
spell.ignoreWord('lorem'); // don't flag, don't suggest
spell.learn('doubletick'); // Tab-accepted (or sent) — auto-prioritize it next time, weighted by `learningBoost`
const backup = spell.exportUserDictionary(); // persist custom words + ignore list
spell.importUserDictionary(backup);React (recommended for web apps — runs off the main thread)
The dictionary parse + fuzzy search runs in a Web Worker via useDawgText, so
it never blocks typing or rendering.
import { useDawgText } from 'dawg-text/react';
function MessageInput() {
const spell = useDawgText({ editDistance: 2 }); // bundled English dictionary auto-loads in the worker
const [text, setText] = useState('');
const [suggestions, setSuggestions] = useState<string[]>([]);
const onChange = async (value: string) => {
setText(value);
setSuggestions(await spell.suggestNextWord(value));
};
const onTab = async (e: React.KeyboardEvent) => {
if (e.key === 'Tab' && suggestions[0]) {
e.preventDefault();
// replace the in-progress word with suggestions[0]
spell.learn(suggestions[0]); // auto-prioritize it for next time (see `learningBoost`)
}
};
if (!spell.isReady) return <input disabled placeholder="Loading dictionary…" />;
return <input value={text} onChange={(e) => onChange(e.target.value)} onKeyDown={onTab} />;
}Bundler setup for the Worker
DawgTextWorkerClient (used internally by useDawgText) constructs the worker via
new Worker(new URL('./worker.ts', import.meta.url), { type: 'module' }), which
Vite, webpack 5+, and modern Rollup all support natively — no extra config needed
in most setups. If your bundler needs an explicit worker chunk path, pass one:
useDawgText({
workerUrl: new URL('dawg-text/dist/worker.js', import.meta.url),
});If you're in a non-Worker environment (SSR, Node scripts), import DawgText
directly from dawg-text instead of the React hook / worker client.
The bundled dictionary
DawgText and useDawgText auto-load a dictionary on construction — controlled
by the dictionary config option:
| dictionary | Behavior |
|---|---|
| 'full' (default) | The bundled ~108k-word English dictionary (SCOWL-derived) |
| 'sample' | A tiny ~250-word demo list — fast to load, handy for tests |
| false | Skip auto-load; call loadDictionary()/loadDictionaryFromUrl() yourself |
| a TrieNodeJSON object | Load that dictionary directly, equivalent to calling loadDictionary() up front |
The default ('full') is what makes install-and-use work with no dictionary
file to fetch, build, or serve — new DawgText() / useDawgText() starts
loading it immediately; await whenReady() (plain JS/TS) or check isReady
(React) before using it.
Under the hood, the full and sample dictionaries are bundled into the package
as separate, dynamically-imported chunks — in ESM builds (what Vite, webpack
5+, and modern Rollup all use) bundlers code-split them out, so you only pay
for the one you actually load. CJS consumers (plain require()) don't get
that split — dist/index.cjs inlines both dictionaries unconditionally
(~3.9MB) regardless of the dictionary option, since CommonJS doesn't support
dynamic chunk loading the same way. Stick to import (the useDawgText/ESM
path) if that matters for your bundle size.
Pass dictionary: false to opt out entirely — e.g. you only need a different
language, or want to keep your bundle free of the English word list. You're
then responsible for calling loadDictionary()/loadDictionaryFromUrl() with
your own dictionary before use; isReady() stays false until you do.
Building your own dictionary
For other languages, custom vocab sizes, or domain-specific word lists, build
your own dictionary.json and load it (with dictionary: false so the
bundled one doesn't load too):
npx tsx node_modules/dawg-text/scripts/build-dictionary.ts words.txt public/dictionary.jsonInput format: one word per line, optionally word<TAB>frequency. Without a
frequency column, line order is used as rank (earlier = more frequent).
Recommended source: SCOWL (also on GitHub as
kevina/wordlist) — it's the word list Hunspell/aspell are built from,
purpose-built for spell-checkers, and lets you pick a vocabulary size
(10/20/35/.../95) so you control size vs. coverage yourself. Pair it with a
real frequency source (e.g. a Google Books ngram unigram export, or SUBTLEX)
for ranking that reflects actual usage rather than dictionary order. This is
in fact how the bundled dictionary was generated.
The raw JSON also ships at node_modules/dawg-text/data/dictionary.full.json
(and data/dictionary.sample.json) if you want it outside the auto-load path —
e.g. to serve your own copy via dictionaryUrl/loadDictionaryFromUrl, or to
read it directly in Node:
import { readFileSync } from 'node:fs';
import { createRequire } from 'node:module';
const require = createRequire(import.meta.url);
const dictionary = JSON.parse(
readFileSync(require.resolve('dawg-text/data/dictionary.full.json'), 'utf-8')
);
await spell.loadDictionary(dictionary);Why one structure instead of DAWG + SymSpell
Both structures answer the same underlying question — "what word is this close to, and how frequent is it" — and at chat/CRM scale (checking one word at a time as someone types, not bulk-processing documents), the speed difference between a live bounded edit-distance walk and SymSpell's precomputed O(1) hash lookup is imperceptible to a human, while SymSpell's index costs roughly 3-15x more memory for the same coverage. If you ever profile this in production and find the live walk is genuinely a bottleneck, that's the signal to add SymSpell — not before.
API reference
DawgText
| Method | Description |
|---|---|
| enable() / disable() / isEnabled() | Toggle both features globally |
| loadDictionary(json) / loadDictionaryFromUrl(url) | Load a built dictionary |
| suggestNextWord(text, limit?) | Frequency-ranked completions for the word being typed |
| checkText(text, ignoreIncompleteLastWord?) | Returns { word, start, end, suggestions, message? }[] for misspelled words — up to maxSuggestions candidates (default 3); message is 'No edits available' when suggestions is empty. Pass ignoreIncompleteLastWord (or set it in the constructor config, default false) to skip the last word while it's still being typed — i.e. nothing follows it yet |
| suggestForWord(word, limit?) | "Did you mean" for a single known-bad word |
| addWord(word, frequency?) / removeWord(word) | Custom vocabulary ("Add to dictionary") |
| clearCustomWords() | Undo every addWord() acceptance, back to just the loaded dictionary |
| ignoreWord(word) / unignoreWord(word) | Skip flagging without adding to suggestions |
| learn(word) | Auto-prioritize a word after Tab-accept/real usage, weighted by the learningBoost config option (default 1) |
| exportUserDictionary() / importUserDictionary(data) | Persist custom words + ignore list + learned (Tab-accepted) boosts |
useDawgText(options) (React)
Same surface as above, worker-backed and Promise-returning, plus isReady /
isEnabled state for your UI.
Note on "DAWG" naming
This ships as a plain trie (one node per character prefix-sharing only). A
true DAWG additionally merges identical suffixes via a minimization pass
(Daciuk et al.), shrinking the structure further — useful if you're
memory-constrained with a very large dictionary, but not required for
correctness. Autocomplete, spell-check, and serialization all work
identically either way; only in-memory/on-disk size changes. A minimization
pass over Trie.toJSON()'s node array can be added later without touching
the public API.
