HTML input pattern validation
Read el.pattern off the field, put values at the edges into it, and read validity.patternMismatch in Chrome. Run the same strings through new RegExp(el.pattern) in Node and compare the two columns. Here AB1234CD passed the Node test and the browser rejected it, because the attribute anchors the match.
Why check this
Run this on any field whose rule lives in markup, before release and after the rule is edited. The same expression behaves differently in an attribute than in a script, and both differences are silent.
The attribute is matched against the whole value, as if the source were wrapped in ^(?: and )$. A developer who confirmed it in a console with test() answered a different question: whether the value contains a match. Step 2 shows them disagreeing.
The second difference is worse. The attribute is compiled with the v flag, so a character class an ordinary RegExp accepts can be a syntax error here, and a pattern that fails to compile is dropped. The field then takes anything, and step 7 stores a sentence in a phone column.
Prerequisites
- Node 22, for the fixture below.
- Chrome and
puppeteer-core. This run drove Chrome 152.0.7977.76, and a browser figure is one capture on one machine. - The probe passes
--lang=en-US:validationMessagefollows the browser UI locale, and without the flag this machine answers in Ukrainian. - curl, for step 8. Every request goes to
127.0.0.1. - Port 8935, confirmed free before you bind it.
- The rule is in the HTML standard, under the pattern attribute.
Save the form as form.html.
<!doctype html>
<meta charset="utf-8">
<title>pattern fixture</title>
<form id="f" method="post" action="/save">
<label>Ticket <input id="ticket" name="ticket" type="text" pattern="\d{4}" required></label>
<label>Ticket, with a title <input id="ticketh" name="ticketh" type="text" pattern="\d{4}" title="Four digits, for example 4821"></label>
<label>Phone <input id="phone" name="phone" type="tel" pattern="\+?[0-9 \(\)\-]{7,20}"></label>
<label>Phone, unescaped class <input id="phonebad" name="phonebad" type="tel" pattern="[0-9+()-]{7,15}"></label>
<button id="go" type="submit">Save</button>
</form>
Save the endpoint as target.mjs, which stores every non-empty field.
import { createServer } from 'node:http';
import { readFileSync } from 'node:fs';
const PORT = 8935;
const rows = [];
const cps = (s) => [...s].map((c) => 'U+' + c.codePointAt(0).toString(16).toUpperCase().padStart(4, '0')).join(' ');
createServer((req, res) => {
const url = new URL(req.url, `http://127.0.0.1:${PORT}`);
if (req.method === 'GET' && url.pathname === '/') {
res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' });
return res.end(readFileSync(new URL('form.html', import.meta.url)));
}
if (url.pathname === '/favicon.ico') {
res.writeHead(204);
return res.end();
}
if (req.method === 'GET' && url.pathname === '/rows') {
res.writeHead(200, { 'content-type': 'text/plain; charset=utf-8' });
return res.end(rows.map((r) => 'id ' + r.id + ' ' + r.field.padEnd(9) + '"' + r.value + '" ' + cps(r.value)).join('\n') + '\n');
}
if (!(req.method === 'POST' && url.pathname === '/save')) {
res.writeHead(404, { 'content-type': 'text/plain' });
return res.end('not found');
}
const chunks = [];
req.on('data', (c) => chunks.push(c));
req.on('end', () => {
const body = new URLSearchParams(Buffer.concat(chunks).toString('utf8'));
const kept = [];
for (const [field, value] of body) {
if (value === '') continue;
rows.push({ id: rows.length + 1, field, value });
kept.push({ field, value });
}
res.writeHead(200, { 'content-type': 'application/json; charset=utf-8' });
res.end(JSON.stringify({ stored: kept, rows: rows.length }) + '\n');
});
}).listen(PORT, '127.0.0.1', () => console.log('pattern fixture on http://127.0.0.1:' + PORT));
Save the driver as probe.mjs. Each subcommand below is one step.
import { launch } from 'puppeteer-core';
import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
const CHROME = process.env.CHROME || 'C:/Program Files/Google/Chrome/Application/chrome.exe';
const ORIGIN = 'http://127.0.0.1:8935';
const pad = (s, w) => String(s).padEnd(w);
const cps = (s) => [...s].map((c) => 'U+' + c.codePointAt(0).toString(16).toUpperCase().padStart(4, '0')).join(' ');
const profile = mkdtempSync(join(tmpdir(), 'h2c-pat-'));
const browser = await launch({ executablePath: CHROME, headless: true, userDataDir: profile, args: ['--lang=en-US'] });
const page = (await browser.pages())[0];
const seen = [];
page.on('console', (m) => seen.push(m.type() + ': ' + m.text()));
page.on('pageerror', (e) => seen.push('pageerror: ' + e.message));
await page.goto(ORIGIN + '/', { waitUntil: 'networkidle2' });
const set = (id, v) => page.evaluate((i, t) => { document.getElementById(i).value = t; }, id, v);
const src = (id) => page.evaluate((i) => document.getElementById(i).pattern, id);
const state = (id) => page.evaluate((i) => {
const el = document.getElementById(i);
return {
value: el.value,
patternMismatch: el.validity.patternMismatch,
valueMissing: el.validity.valueMissing,
valid: el.validity.valid,
message: el.validationMessage,
};
}, id);
const compiles = (s, flags) => { try { new RegExp(s, flags); return 'compiles'; } catch (e) { return e.name + ': ' + e.message; } };
const mode = process.argv[2];
if (mode === 'inventory') {
const rows = await page.evaluate(() => [...document.querySelectorAll('input')].map((el) => ({
id: el.id, type: el.type, prop: el.pattern, title: el.getAttribute('title') })));
for (const r of rows) {
console.log(pad(r.id, 10) + 'type ' + pad(r.type, 6) + 'el.pattern ' + pad(r.prop, 26)
+ 'title ' + (r.title === null ? 'absent' : JSON.stringify(r.title)));
}
} else if (mode === 'anchor') {
const s = await src('ticket');
const re = new RegExp(s);
console.log('el.pattern is ' + s);
console.log(pad('value', 12) + pad('RegExp(pattern).test()', 24) + pad('validity.patternMismatch', 26) + 'browser verdict');
for (const v of ['4821', 'AB1234CD', '48210', '4821 ', '482']) {
await set('ticket', v);
const st = await state('ticket');
console.log(pad(JSON.stringify(v), 12) + pad(re.test(v), 24) + pad(st.patternMismatch, 26)
+ (st.patternMismatch ? 'rejected' : 'accepted'));
}
} else if (mode === 'empty') {
await set('ticket', '');
const a = await state('ticket');
console.log('ticket (pattern + required), value ""');
console.log(' patternMismatch ' + a.patternMismatch + ' valueMissing ' + a.valueMissing
+ ' valid ' + a.valid + ' message "' + a.message + '"');
await set('phone', '');
const b = await state('phone');
console.log('phone (pattern, no required), value ""');
console.log(' patternMismatch ' + b.patternMismatch + ' valueMissing ' + b.valueMissing
+ ' valid ' + b.valid + ' message "' + b.message + '"');
} else if (mode === 'title') {
for (const id of ['ticket', 'ticketh']) {
await set(id, 'AB1234CD');
const st = await state(id);
console.log(pad(id, 10) + 'title ' + pad(JSON.stringify(await page.evaluate((i) => document.getElementById(i).title, id)), 34)
+ 'message "' + st.message + '"');
}
} else if (mode === 'phone') {
const s = await src('phone');
console.log('el.pattern is ' + s + ' on type="tel"');
const values = ['+1 555 123 4567', '(555) 123-4567', '555-123-4567', ' 555-123-4567',
'555-123-4567 ext. 89', '--------', '\u0665\u0665\u0665\u0661\u0662\u0663\u0664'];
for (const v of values) {
await set('phone', v);
const st = await state('phone');
console.log(pad(JSON.stringify(v), 24) + pad(st.patternMismatch ? 'rejected' : 'accepted', 10) + cps(v));
}
} else if (mode === 'broken') {
const s = await src('phonebad');
console.log('el.pattern is ' + s);
for (const f of ['', 'u', 'v']) console.log(' new RegExp(pattern, ' + JSON.stringify(f) + ') ' + compiles(s, f));
for (const v of ['555-1234', 'not a phone at all', '']) {
await set('phonebad', v);
const st = await state('phonebad');
console.log(pad(JSON.stringify(v), 22) + 'patternMismatch ' + pad(st.patternMismatch, 7)
+ 'valid ' + pad(st.valid, 7) + 'message "' + st.message + '"');
}
console.log('console messages and page errors since load: ' + seen.length);
for (const m of seen) console.log(' ' + m);
} else if (mode === 'version') {
console.log(await browser.version());
} else if (mode === 'submit') {
await set('ticket', '4821');
await set('phone', '--------');
await set('phonebad', 'not a phone at all');
console.log('form.checkValidity() ' + await page.evaluate(() => document.getElementById('f').checkValidity()));
await Promise.all([page.waitForNavigation({ waitUntil: 'networkidle2' }), page.click('#go')]);
console.log('landed on ' + new URL(page.url()).pathname);
console.log(await page.evaluate(() => document.body.textContent.trim()));
}
await browser.close();
rmSync(profile, { recursive: true, force: true });
Start the endpoint.
node target.mjs
Steps
- Step 1.
Read the pattern off every field, from the DOM property.
node probe.mjs inventoryticket type text el.pattern \d{4} title absent ticketh type text el.pattern \d{4} title "Four digits, for example 4821" phone type tel el.pattern \+?[0-9 \(\)\-]{7,20} title absent phonebad type tel el.pattern [0-9+()-]{7,15} title absentel.patternis the string the browser compiles. Take the test data from it, not from the source file. - Step 2.
Put five values in and print the browser's verdict beside Node's.
node probe.mjs anchorel.pattern is \d{4} value RegExp(pattern).test() validity.patternMismatch browser verdict "4821" true false accepted "AB1234CD" true true rejected "48210" true true rejected "4821 " true true rejected "482" false true rejectedThree rows disagree.
test()answers whether the value contains four digits; the attribute answers whether the whole value is four digits. - Step 3.
Empty both patterned fields and read the flags.
node probe.mjs emptyticket (pattern + required), value "" patternMismatch false valueMissing true valid false message "Please fill out this field." phone (pattern, no required), value "" patternMismatch false valueMissing false valid true message ""The pattern does not fire on an empty value.
requiredcatches the first field; the second reports itself valid and empty. - Step 4.
Break the same pattern on two fields, one carrying a
title.node probe.mjs titleticket title "" message "Please match the requested format." ticketh title "Four digits, for example 4821" message "Please match the requested format."The two messages are identical. The
titledoes not reachvalidationMessage, so an assertion on it cannot say which rule failed. - Step 5.
Compile the unescaped phone class three ways, then measure the field.
node probe.mjs brokenel.pattern is [0-9+()-]{7,15} new RegExp(pattern, "") compiles new RegExp(pattern, "u") compiles new RegExp(pattern, "v") SyntaxError: Invalid regular expression: /[0-9+()-]{7,15}/v: Invalid character in character class "555-1234" patternMismatch false valid true message "" "not a phone at all" patternMismatch false valid true message "" "" patternMismatch false valid true message "" console messages and page errors since load: 1 error: Pattern attribute value [0-9+()-]{7,15} is not a valid regular expression: Uncaught SyntaxError: Failed to read the 'patternMismatch' property from 'ValidityState': Invalid regular expression: /[0-9+()-]{7,15}/v: Invalid character in character classThe same source compiles with no flag and with
u, and throws underv. The field then accepts a sentence. One console error is the only sign. - Step 6.
Feed a phone pattern the forms a real number arrives in.
node probe.mjs phoneel.pattern is \+?[0-9 \(\)\-]{7,20} on type="tel" "+1 555 123 4567" accepted U+002B U+0031 U+0020 U+0035 U+0035 U+0035 U+0020 U+0031 U+0032 U+0033 U+0020 U+0034 U+0035 U+0036 U+0037 "(555) 123-4567" accepted U+0028 U+0035 U+0035 U+0035 U+0029 U+0020 U+0031 U+0032 U+0033 U+002D U+0034 U+0035 U+0036 U+0037 "555-123-4567" accepted U+0035 U+0035 U+0035 U+002D U+0031 U+0032 U+0033 U+002D U+0034 U+0035 U+0036 U+0037 " 555-123-4567" accepted U+0020 U+0035 U+0035 U+0035 U+002D U+0031 U+0032 U+0033 U+002D U+0034 U+0035 U+0036 U+0037 "555-123-4567 ext. 89" rejected U+0035 U+0035 U+0035 U+002D U+0031 U+0032 U+0033 U+002D U+0034 U+0035 U+0036 U+0037 U+0020 U+0065 U+0078 U+0074 U+002E U+0020 U+0038 U+0039 "--------" accepted U+002D U+002D U+002D U+002D U+002D U+002D U+002D U+002D "٥٥٥١٢٣٤" rejected U+0665 U+0665 U+0665 U+0661 U+0662 U+0663 U+0664Eight hyphens are accepted and an extension rejected.
[0-9]matches ASCII digits only, so Arabic-Indic digits fail. A pattern decides which characters may appear, nothing more. - Step 7.
Submit the form with two values the fields were meant to block.
node probe.mjs submitform.checkValidity() true landed on /save {"stored":[{"field":"ticket","value":"4821"},{"field":"phone","value":"--------"},{"field":"phonebad","value":"not a phone at all"}],"rows":3}The form reports itself valid, the browser navigates, and the endpoint stores a sentence in a phone column.
- Step 8.
Send the refused values with no browser in the path.
curl -s -X POST http://127.0.0.1:8935/save --data-urlencode "ticket=AB1234CD" --data-urlencode "phone=555-123-4567 ext. 89" --data-urlencode "phonebad=nonsense" -w 'HTTP %{http_code}\n'{"stored":[{"field":"ticket","value":"AB1234CD"},{"field":"phone","value":"555-123-4567 ext. 89"},{"field":"phonebad","value":"nonsense"}],"rows":6} HTTP 200Every value steps 2 and 6 marked rejected is stored. The attribute never left the page.
- Step 9.
Read back what the endpoint kept.
curl -s http://127.0.0.1:8935/rowsid 1 ticket "4821" U+0034 U+0038 U+0032 U+0031 id 2 phone "--------" U+002D U+002D U+002D U+002D U+002D U+002D U+002D U+002D id 3 phonebad "not a phone at all" U+006E U+006F U+0074 U+0020 U+0061 U+0020 U+0070 U+0068 U+006F U+006E U+0065 U+0020 U+0061 U+0074 U+0020 U+0061 U+006C U+006C id 4 ticket "AB1234CD" U+0041 U+0042 U+0031 U+0032 U+0033 U+0034 U+0043 U+0044 id 5 phone "555-123-4567 ext. 89" U+0035 U+0035 U+0035 U+002D U+0031 U+0032 U+0033 U+002D U+0034 U+0035 U+0036 U+0037 U+0020 U+0065 U+0078 U+0074 U+002E U+0020 U+0038 U+0039 id 6 phonebad "nonsense" U+006E U+006F U+006E U+0073 U+0065 U+006E U+0073 U+0065Six rows, three from the form. Only
4821satisfies the markup. Stop the endpoint by its process id, never by image name.
How to read the result
| What you see | What it means | What to do |
| --- | --- | --- |
| test() true and patternMismatch true on one value | The attribute anchors the match and the console test did not | Re-test the expression as ^(?:source)$, or state the anchors in the attribute and keep them. |
| A console error naming the pattern attribute | The pattern did not compile, so the browser dropped it | Raise it as a field with no client rule at all. Fix the class, then re-run step 5. |
| patternMismatch false on every value of a field | Either the pattern matches everything, or it failed to compile | Read the console before deciding which. The two look identical from the DOM. |
| patternMismatch false and valueMissing true | The field is empty, and pattern does not apply | Test the empty case against required, separately from the format case. |
| A hyphen run accepted by a phone pattern | The class allows those characters in any order | Decide whether the field needs a shape check or a reachability check, and say which in the defect. |
| A value in non-ASCII digits rejected | [0-9] and \d cover ASCII only | Confirm the rule is intended, and test the same field with the scripts your users type in. |
| The endpoint stores a value the field rejected | No server rule mirrors the attribute | Raise one defect per missing rule, naming the attribute it should match. |
Common mistakes
What to check next
- How to test that client validation is enforced on the server: the general case behind steps 8 and 9.
- How to check field length limits in a form: the other attribute an assigned value gets past.
- How to check required field validation: the flag that fires on the empty value.
- Input type email validation: the rule applied when no
patternis written. - How to test whitespace trimming in a form field: why a trailing space reached the pattern.
FAQ
What does the input pattern attribute do?
It holds a regular expression the browser matches against the whole field value. A value that does not match sets validity.patternMismatch and blocks submission. An empty value is exempt.
Why does my pattern work in JavaScript and fail in the attribute?
Two reasons, both measured above. The attribute is anchored, so a partial match is not enough. It is also compiled with the v flag, which rejects character classes an ordinary RegExp accepts.
How do I do HTML input type phone number validation?
Use type="tel" for the keyboard and a pattern for the accepted characters, then treat both as hints. Step 6 accepted eight hyphens. Reachability is decided by sending a code.
Does pattern replace server side validation?
No. Step 8 posts every rejected value to the endpoint and all of them are stored. The attribute runs in the visitor's browser, so the route needs the same rule written again.
How do I test a pattern attribute that is generated at runtime?
Read el.pattern from the DOM, as step 1 does, and build the test data from it. Reading the source file misses a pattern a script replaced after load.
Verified
Verified by Maks VernyChrome 152.0.7977.76puppeteer-core 25.10.0node 22.23.2curl 8.21.0Windows 11 build 22631
Each output block is what the command above it printed on that date, on the host named in the step. Figures read from a live site move between runs. Compare the shape of the answer rather than the digits, and see the methodology for how a page is re-verified.
Related on this site
intermediate10 minpublished updated Maks Verny