Grammar checkers feel like magic until you build the simplest possible one. Then you see exactly what they do, and exactly where "just add more rules" stops working.
In this post we write a rule-based checker in about 50 lines of JavaScript, test it three times, and fix it twice. The last test uses 21,355 words of real articles, and it humbled my first version. The code is at the end of each step, so you can copy it.
What a grammar checker actually is
At its core, a checker does three things:
-
Find a pattern in the text (a repeated word,
abefore a vowel, a space before a comma). - Decide if it is an error (this is the hard part).
- Suggest a fix and tell the user where it is (start and end offset).
Every match we return has the same shape, so a UI can underline it and offer a button:
{ rule: 'REPEAT', start: 12, end: 19, message: 'Repeated word', fix: 'the' }
Step 1: repeated words
The easiest rule. A back-reference in a regex finds a word followed by itself:
const rep = /\b([A-Za-z']+)\s+\1\b/gi;
let m;
while ((m = rep.exec(text)))
add('REPEAT', m.index, m.index + m[0].length, 'Repeated word', m[1]);
It catches the the and is is. But it also flags She had had enough. and I know that that is true, which are correct. The first fix is a tiny allow-list:
const KEEP_DOUBLE = new Set(['had', 'that']);
Already, "find a pattern" and "decide if it is an error" are two different jobs.
Step 2: a or an
The rule is about sound, not spelling. an hour is right (the h is silent) and a university is right (it starts with a "y" sound). A checker that only looks at the first letter will get both wrong, so we add two exception lists:
const A_EXC = /^(uni([^n]|$)|use|usu|uti|eu|one|once|ubiq|ukr|uran)/i; // vowel letter, consonant sound
const AN_EXC = /^(hour|honest|honou?r|heir|herb)/i; // consonant letter, vowel sound
const art = /\b(a|an)\s+([A-Za-z][A-Za-z']*)/gi;
while ((m = art.exec(text))) {
const w = m[2], isAn = m[1].toLowerCase() === 'an', vowel = /^[aeiou]/i.test(w);
const needAn = (vowel && !A_EXC.test(w)) || (!vowel && AN_EXC.test(w));
if (isAn !== needAn && w.length > 1)
add('A_AN', m.index, m.index + m[1].length, `Use "${needAn ? 'an' : 'a'}" before "${w}"`, needAn ? 'an' : 'a');
}
This will go wrong on acronyms: an MBA is right because you say "em-bee-ay". Remember this one.
Steps 3 to 8: the rest of the rules
I added six more small rules. Each is a regex plus a decision:
| Rule | Pattern | Example |
|---|---|---|
| Spacing | two spaces; space before ,;:!?; no space after a comma |
tea , but |
| Capital letters | lowercase letter after a full stop; lowercase i
|
It ended. then we left |
| Modal "of" | could/would/should/must/might of |
could of told you |
| Misspellings | a small dictionary of 17 common ones |
definately, seperate
|
| Agreement |
he/she/it don't, they is, we was, I is
|
She don't know |
| Confusions |
your welcome, their is, its a, better then
|
faster then the old one |
The full file is at the end.
A bug that crashed my first run
My first test run ran out of memory (JavaScript heap out of memory). The cause is a classic. A regex with the g flag keeps its position in lastIndex. I used the same regex inside the loop to build the fix text:
while ((m = re.exec(text))) {
add('CONFUSION', ..., m[0].replace(re, fix)); // BUG: replace() with a /g regex resets lastIndex to 0
}
replace with a global regex starts from the beginning and leaves lastIndex at 0, so exec found the same match forever. The fix is to build a fresh regex for the replacement:
m[0].replace(new RegExp(re.source, 'i'), fix)
It only hung on text that actually matched, which is why small tests passed.
Testing it properly
I wrote four sets of sentences:
- Seeded errors (56): one error each, in the categories I had built rules for.
- Held-out errors (30): the same categories, written after the rules were finished and run once. They are still my own sentences, so they are less independent than you'd like.
-
Clean sentences (59): correct text with traps, such as
an hour,a university,Dr. Smith,3.14, a URL,had had. -
Out-of-scope errors (24): mistakes that need context, such as
Their going to the store,She went the store,I bought car,depends of.
And one real test: 21 published how-to articles, 21,355 words of ordinary technical writing.
Here is what each version showed.
v1 caught 56 of 56 seeded errors and 29 of 30 held-out ones. It missed The new model is cheaper then the old one, because my comparative list had faster and bigger but not cheaper. It also flagged one clean sentence: The U.S. team won an MBA case contest (the acronym problem from Step 2). Looks good so far.
On the real articles, v1 raised 45 flags. Every one was a false alarm. I read them all:
-
... a USB stick,... an XLSX,... an HTML report: all correct English, flagged as wrong. The check looks at letters, not at how an acronym is said aloud. -
Done! and Your PDF document is ready: the articles quote button messages likeRotated Successfully!, followed by lowercase text. My capital-letter rule treated!as the end of a sentence. -
Save .doc files as DOCX: I flagged the space before.docas a space before a full stop. -
Nguyen Van A and ...: a name, read as the article "A".
v2 fixed the two failures my own tests had shown: skip the a/an check for all-caps words, and match any comparative ending in -er before then. Seeded 56 of 56, held-out 30 of 30, clean 0 flagged. A reviewer would say "great". On the real text it still gave 41 false alarms. My test sets had not contained the things real writing contains.
v3 used the real text to fix the rules:
- a space before
.is only an error if no letter or digit follows (so.docis fine); - the capital-letter rule only fires after a full stop, not after
!or?; - a capital
Aafter a capitalised word is treated as a name.
That left 2 flags on 21,355 words (both false: Encrypting... and ends a quoted status message). But there was a cost. The capital-letter change lost two of my seeded errors (It rained all day! we stayed inside.), so seeded recall dropped from 56 to 54.
That is the central trade-off of rule-based checking, in numbers: every fix that removes a false alarm can also remove a real catch.
The ceiling: errors that need context
All three versions found 0 of 24 out-of-scope errors. They are not edge cases:
| Type | Example |
|---|---|
| Wrong homophone |
Their going to the store. / Please wait for you're turn.
|
| Missing word | She went the store after work. |
| Verb tense |
Yesterday I go to school. / She has went to Paris.
|
| Missing article | I bought car last month. |
| Preposition |
This depends of the weather. / We discussed about the plan.
|
| Plural | There are two cat in the garden. |
| Comparative | This box is more heavier than that one. |
| Double negative | I don't have no time today. |
Notice that Their going and Their is a problem use the same word. My their is rule catches the second only because it is a fixed phrase. To catch the first you need to know that going is a verb that wants they're. That takes a model of grammar, not a list of strings. Real checkers combine much larger rule sets with statistics or machine learning to cover this. I could not run any of them in my environment, so I cannot tell you how many of my 24 sentences they would catch.
What online checkers say (read Nov 2026)
I did not test these tools' accuracy; this is what each page states. "Not stated" means I did not find it on the page I read.
| Tool | Account and limits | What the page says about your text | Notes |
|---|---|---|---|
| LanguageTool | Account for the free browser version not stated; limit per check not stated | "No text is stored by the browser add-on"; for the online editor, "Texts are securely stored" (for account users) | "More than 30 languages and dialects"; free version "corrects spelling as well as simple punctuation and some style mistakes"; Premium shows "all errors" |
| Grammarly | Sign-in not stated as required; no length limit stated; 100 monthly generative-AI uses on the free plan | "We don't sell your data"; whether text is stored or used for training is not stated | Free: "basic writing suggestions and tone detection" |
| QuillBot | Sign-up not stated; limits not stated | Calls itself "safe to use" and points to its privacy policy; no details on the page | English only is implied; languages not listed |
| Scribbr | "No registration or sign-up required"; "Unlimited text length" | Inputs are processed under Learneo's privacy policy | "Powered by the QuillBot service"; English dialects US, UK, CA, AU |
| ToolTiny | No account; the page has no length limit of its own and waits 2 seconds between checks | From the page source: your text is sent from your browser to LanguageTool's public API (api.languagetool.org/v2/check), in "picky" mode |
Auto-detect plus 24 listed languages and variants |
The ToolTiny row follows from its source code: the check button posts your text straight to LanguageTool's public endpoint. That endpoint has its own documented rules: 20 requests per IP per minute, 75 KB of text per IP per minute, 20 KB per request, "no guarantees about performance or availability", and text handling described in LanguageTool's privacy policy. So a long document can fail on any tool built on that API, and "where does my text go" has the same answer for all of them: to LanguageTool's servers. If that matters to you, LanguageTool says you can run your own instance.
What I would do with this
-
Use a rule-based checker for the boring stuff: repeated words, double spaces,
could of, capital letters, known misspellings. It is fast, runs offline and costs nothing. - Don't trust a green result. A clean pass only means your text avoided the patterns the checker knows. My 24 out-of-scope errors passed all three versions.
- Test on your own real text, not on test sentences. My v2 looked perfect on my sentences and produced 41 false alarms on real writing. If you build a checker, run it on a pile of real documents and read every flag.
- Check where the text goes before pasting anything private. Browser-based tools can still send your text to a server; the page source or network tab tells you.
The full checker
// tiny rule-based grammar checker: check(text) -> [{rule, start, end, message, fix}]
const A_EXC = /^(uni([^n]|$)|use|usu|uti|eu|one|once|ubiq|ukr|uran)/i;
const AN_EXC = /^(hour|honest|honou?r|heir|herb)/i;
const MISSPELL = { definately:'definitely', seperate:'separate', recieve:'receive', occured:'occurred', alot:'a lot',
untill:'until', teh:'the', adress:'address', accomodate:'accommodate', begining:'beginning', enviroment:'environment',
goverment:'government', tommorow:'tomorrow', wierd:'weird', truely:'truly', wich:'which', neccessary:'necessary' };
const AGREE = [ [/\b(he|she|it)\s+(don't)\b/gi, "doesn't"], [/\b(he|she|it)\s+(have)\b/gi, 'has'],
[/\b(they|we|you)\s+(is)\b/gi, 'are'], [/\b(they|we|you)\s+(was)\b/gi, 'were'], [/\bI\s+(is|are)\b/g, 'am'],
[/\b(he|she|it)\s+(are)\b/gi, 'is'] ];
const NO_CAP_AFTER = /(?:\b(?:e\.g|i\.e|etc|vs|mr|mrs|ms|dr|prof|a\.m|p\.m|no|fig|approx)|\b[a-z])\.$/i;
const KEEP_DOUBLE = new Set(['had','that']);
function check(text) {
const out = []; const add = (rule, start, end, message, fix) => out.push({ rule, start, end, message, fix });
let m;
const rep = /\b([A-Za-z']+)\s+\1\b/gi; // 1 repeated word
while ((m = rep.exec(text))) if (!KEEP_DOUBLE.has(m[1].toLowerCase())) add('REPEAT', m.index, m.index + m[0].length, 'Repeated word', m[1]);
const art = /\b(a|an)\s+([A-Za-z][A-Za-z']*)/gi; // 2 a / an
while ((m = art.exec(text))) {
const w = m[2], isAn = m[1].toLowerCase() === 'an', vowel = /^[aeiou]/i.test(w);
if (m[1] === 'A' && /[A-Z][a-z]+\s$/.test(text.slice(Math.max(0, m.index - 14), m.index))) continue; // 'Van A and'
if (w.length > 1 && w === w.toUpperCase()) continue; // acronyms
const needAn = (vowel && !A_EXC.test(w)) || (!vowel && AN_EXC.test(w));
if (isAn !== needAn && w.length > 1) add('A_AN', m.index, m.index + m[1].length, `Use "${needAn ? 'an' : 'a'}" before "${w}"`, needAn ? 'an' : 'a');
}
const dbl = /[^\S\n]{2,}/g; while ((m = dbl.exec(text))) add('SPACE', m.index, m.index + m[0].length, 'Extra space', ' ');
const bef = /[^\S\n]+([,!?;:]|\.(?![A-Za-z\d]))/g; // ' .docx' is a file extension
while ((m = bef.exec(text))) add('SPACE_BEFORE_PUNCT', m.index, m.index + m[0].length, 'No space before punctuation', m[1]);
const aft = /,(?=[A-Za-z])/g; while ((m = aft.exec(text))) add('SPACE_AFTER_COMMA', m.index, m.index + 1, 'Missing space after comma', ', ');
const cap = /(\.)\s+([a-z])/g; // only after a full stop
while ((m = cap.exec(text))) { const before = text.slice(0, m.index + 1); if (!NO_CAP_AFTER.test(before.slice(-12))) add('CAPITAL', m.index + m[0].length - 1, m.index + m[0].length, 'Sentence should start with a capital', m[2].toUpperCase()); }
const first = /^\s*([a-z])/.exec(text); if (first) add('CAPITAL', first[0].length - 1, first[0].length, 'Sentence should start with a capital', first[1].toUpperCase());
const lowI = /(^|[\s(])i(?=[\s,.!?;:]|$)/g; while ((m = lowI.exec(text))) add('LOWER_I', m.index + m[1].length, m.index + m[1].length + 1, 'Use capital "I"', 'I');
const of = /\b(could|would|should|must|might)\s+of\b/gi; while ((m = of.exec(text))) add('MODAL_OF', m.index, m.index + m[0].length, `Use "${m[1]} have"`, m[1] + ' have');
const wd = /\b[A-Za-z]+\b/g; while ((m = wd.exec(text))) { const k = m[0].toLowerCase(); if (MISSPELL[k]) add('SPELL', m.index, m.index + m[0].length, `Did you mean "${MISSPELL[k]}"?`, MISSPELL[k]); }
for (const [re, fix] of AGREE) { re.lastIndex = 0; while ((m = re.exec(text))) add('AGREEMENT', m.index, m.index + m[0].length, `Subject-verb agreement: use "${fix}"`, fix); }
const conf = [[/\byour welcome\b/gi, "you're welcome"], [/\btheir (is|are)\b/gi, 'there $1'], [/\bits a\b/gi, "it's a"],
[/\b(more|less|better|worse|\w+er)\s+then\b/gi, '$1 than']];
for (const [re, fix] of conf) { while ((m = re.exec(text))) add('CONFUSION', m.index, m.index + m[0].length, 'Commonly confused words', m[0].replace(new RegExp(re.source, 'i'), fix)); }
return out.sort((a, b) => a.start - b.start);
}
module.exports = { check };
Limits of this post
- All test sentences are mine, including the held-out set, which I wrote after finishing the rules but with the same categories in mind. Treat the recall numbers as a demonstration, not a benchmark.
- The 21,355 words come from one site's how-to articles (technical writing with many file names and button labels). Essays or emails would give different false-alarm patterns.
- I did not run LanguageTool, Grammarly, QuillBot or Scribbr, so I make no accuracy claims about them. My environment could not reach the LanguageTool API.
- The "out-of-scope" list is a sample of grammar mistakes, not a taxonomy.
- Vendor statements are as they appeared on the day I read them.
If you build your own rules, tell me in the comments which one produced the most false alarms on your text.

Top comments (0)