Skip to content

Commit 162d634

Browse files
feat: set pattern constraint for regular expressions
1 parent a9481c5 commit 162d634

File tree

10 files changed

+212
-2
lines changed

10 files changed

+212
-2
lines changed
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
---
2+
'@conform-to/dom': minor
3+
'@conform-to/react': minor
4+
'@conform-to/valibot': minor
5+
'@conform-to/zod': minor
6+
---
7+
8+
# Enable browser-native regex validation from schemas
9+
10+
Mapped regex constraints to `pattern`, enabling advanced client-side validation.
11+
12+
```tsx
13+
import { configureForms } from '@conform-to/react/future';
14+
import { getConstraints } from '@conform-to/zod/v4/future';
15+
import type { InputHTMLAttributes } from 'react';
16+
import { z } from 'zod';
17+
18+
const schema = z.object({
19+
password: z
20+
.string()
21+
.min(8)
22+
.regex(/[A-Z]/, 'uppercase')
23+
.regex(/[0-9]/, 'digit')
24+
.regex(/[!@#$%^&*]/, 'special'),
25+
});
26+
27+
const configuredForms = configureForms({
28+
extendFieldMetadata(metadata) {
29+
return {
30+
get inputProps() {
31+
return {
32+
// '^(?=.*(?:[A-Z]))(?=.*(?:[0-9]))(?=.*(?:[!@#$%^&*])).*$'
33+
pattern: metadata.pattern,
34+
minLength: metadata.minLength,
35+
name: metadata.name,
36+
required: metadata.required,
37+
} satisfies InputHTMLAttributes<HTMLInputElement>;
38+
},
39+
};
40+
},
41+
getConstraints,
42+
});
43+
```

packages/conform-dom/future/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ export {
2525
getFieldValue,
2626
normalizeFormError,
2727
} from '../formdata';
28-
export { isPlainObject, deepEqual } from '../util';
28+
export { isPlainObject, deepEqual, combinePatterns } from '../util';
2929
export {
3030
isFieldElement,
3131
isGlobalInstance,

packages/conform-dom/tests/util.test.ts

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { expect, test } from 'vitest';
2-
import { deepEqual } from '../util';
2+
import { combinePatterns, deepEqual } from '../util';
33

44
test('deepEqual', () => {
55
expect(deepEqual(null, null)).toBe(true);
@@ -22,3 +22,41 @@ test('deepEqual', () => {
2222
false,
2323
);
2424
});
25+
26+
test('combinePatterns', () => {
27+
// Empty pattern
28+
expect(combinePatterns([])).toBeUndefined();
29+
30+
// Single pattern
31+
expect(combinePatterns([/[A-Z]/])).toBe('^(?=.*(?:[A-Z])).*$');
32+
expect(combinePatterns([/^[A-Z]+$/])).toBe('^(?=.*(?:^[A-Z]+$)).*$');
33+
34+
// Multiple patterns without flags
35+
expect(combinePatterns([/[A-Z]/, /[0-9]/])).toBe(
36+
'^(?=.*(?:[A-Z]))(?=.*(?:[0-9])).*$',
37+
);
38+
39+
// Unsupported flags
40+
expect(combinePatterns([/[A-Z]/g])).toBeUndefined();
41+
expect(combinePatterns([/[A-Z]/, /[0-9]/i])).toBeUndefined();
42+
43+
// Supported flags
44+
expect(combinePatterns([/[A-Z]/u])).toBe('^(?=.*(?:[A-Z])).*$');
45+
46+
// Semantic validation: single pattern (uppercase-only)
47+
const single = combinePatterns([/^[A-Z]+$/]);
48+
expect(single).toBeDefined();
49+
const singleRegex = new RegExp(single || '');
50+
expect(singleRegex.test('ABC')).toBe(true);
51+
expect(singleRegex.test('abc')).toBe(false);
52+
expect(singleRegex.test('ABC123')).toBe(false);
53+
54+
// Semantic validation: multiple patterns (uppercase AND digit)
55+
const multi = combinePatterns([/[A-Z]/, /[0-9]/]);
56+
expect(multi).toBeDefined();
57+
const multiRegex = new RegExp(multi || '');
58+
expect(multiRegex.test('ABC')).toBe(false);
59+
expect(multiRegex.test('123')).toBe(false);
60+
expect(multiRegex.test('ABC123')).toBe(true);
61+
expect(multiRegex.test('abc123')).toBe(false);
62+
});

packages/conform-dom/util.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,3 +113,25 @@ export function getTypeName(value: unknown): string {
113113
}
114114
return typeof value;
115115
}
116+
117+
/**
118+
* Combines multiple regex patterns into a single HTML pattern attribute string.
119+
*
120+
* For multiple patterns, combines them using lookahead assertions that must all match.
121+
*
122+
* HTML pattern attributes implicitly run with the `v` flag, so `u` and `v` flags are
123+
* silently stripped. Returns undefined if any pattern has other flags (e.g., `g`, `i`).
124+
*
125+
* Example: [/[A-Z]/, /[0-9]/] -> '^(?=.*(?:[A-Z]))(?=.*(?:[0-9])).*$'
126+
*/
127+
export function combinePatterns(patterns: RegExp[]): string | undefined {
128+
if (patterns.length === 0) {
129+
return undefined;
130+
}
131+
132+
if (patterns.some((p) => p.flags.replace(/[uv]/g, ''))) {
133+
return undefined;
134+
}
135+
136+
return `^${patterns.map((p) => `(?=.*(?:${p.source}))`).join('')}.*$`;
137+
}

packages/conform-valibot/constraint.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import type { Constraint } from '@conform-to/dom';
22
import { getPaths, formatPaths, getRelativePath } from '@conform-to/dom';
3+
import { combinePatterns } from '@conform-to/dom/future';
34
import type { GenericSchema, GenericSchemaAsync } from 'valibot';
45

56
const keys: Array<keyof Constraint> = [
@@ -149,6 +150,22 @@ export function getValibotConstraint<
149150
if (maxLength && 'requirement' in maxLength) {
150151
constraint.maxLength = maxLength.requirement as number;
151152
}
153+
// @ts-expect-error
154+
const regexValidators = schema.pipe?.filter(
155+
// @ts-expect-error
156+
(v) => 'type' in v && v.type === 'regex',
157+
);
158+
if (regexValidators?.length) {
159+
const pattern = combinePatterns(
160+
regexValidators.map(
161+
// @ts-expect-error
162+
(v) => (v as { requirement: RegExp }).requirement,
163+
),
164+
);
165+
if (pattern) {
166+
constraint.pattern = pattern;
167+
}
168+
}
152169
} else if (
153170
schema.type === 'optional' ||
154171
schema.type === 'nullish' ||

packages/conform-valibot/tests/constraint.test.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import {
2424
objectWithRest,
2525
optional,
2626
pipe,
27+
regex,
2728
strictObject,
2829
string,
2930
tuple,
@@ -374,4 +375,30 @@ describe('constraint', () => {
374375
constraint['conditions[0].conditions[1].conditions[2].type'],
375376
).toEqual({ required: true });
376377
});
378+
379+
test('regex patterns', () => {
380+
const schema = object({
381+
empty: string(),
382+
single: pipe(string(), regex(/^[A-Z]+$/)),
383+
multiple: pipe(
384+
string(),
385+
regex(/[A-Z]/, 'uppercase'),
386+
regex(/[0-9]/, 'digit'),
387+
),
388+
});
389+
390+
expect(getValibotConstraint(schema)).toEqual({
391+
empty: {
392+
required: true,
393+
},
394+
single: {
395+
required: true,
396+
pattern: '^(?=.*(?:^[A-Z]+$)).*$',
397+
},
398+
multiple: {
399+
required: true,
400+
pattern: '^(?=.*(?:[A-Z]))(?=.*(?:[0-9])).*$',
401+
},
402+
});
403+
});
377404
});

packages/conform-zod/default/constraint.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
11
import type { Constraint } from '@conform-to/dom';
22
import { getPaths, formatPaths, getRelativePath } from '@conform-to/dom';
3+
import { combinePatterns } from '@conform-to/dom/future';
34
import type {
45
ZodTypeAny,
56
ZodFirstPartySchemaTypes,
67
ZodNumber,
78
ZodString,
9+
ZodStringCheck,
810
} from 'zod';
911

1012
const keys: Array<keyof Constraint> = [
@@ -129,6 +131,16 @@ export function getZodConstraint(
129131
if (_schema.maxLength !== null) {
130132
constraint.maxLength = _schema.maxLength;
131133
}
134+
const regexChecks = def.checks.filter(
135+
(check): check is ZodStringCheck & { kind: 'regex' } =>
136+
check.kind === 'regex',
137+
);
138+
if (regexChecks.length > 0) {
139+
const pattern = combinePatterns(regexChecks.map((c) => c.regex));
140+
if (pattern) {
141+
constraint.pattern = pattern;
142+
}
143+
}
132144
} else if (def.typeName === 'ZodOptional') {
133145
constraint.required = false;
134146
updateConstraint(def.innerType, data, name);

packages/conform-zod/default/tests/constraint.test.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -360,4 +360,26 @@ describe('getZodConstraint', () => {
360360
constraint['conditions[0].conditions[1].conditions[2].type'],
361361
).toEqual({ required: true });
362362
});
363+
364+
test('regex patterns', () => {
365+
const schema = z.object({
366+
empty: z.string(),
367+
single: z.string().regex(/^[A-Z]+$/),
368+
multiple: z.string().regex(/[A-Z]/, 'uppercase').regex(/[0-9]/, 'digit'),
369+
});
370+
371+
expect(getZodConstraint(schema)).toEqual({
372+
empty: {
373+
required: true,
374+
},
375+
single: {
376+
required: true,
377+
pattern: '^(?=.*(?:^[A-Z]+$)).*$',
378+
},
379+
multiple: {
380+
required: true,
381+
pattern: '^(?=.*(?:[A-Z]))(?=.*(?:[0-9])).*$',
382+
},
383+
});
384+
});
363385
});

packages/conform-zod/v4/constraint.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import type { Constraint } from '@conform-to/dom';
22
import { getPaths, formatPaths, getRelativePath } from '@conform-to/dom';
3+
import { combinePatterns } from '@conform-to/dom/future';
34
import {
45
$ZodType,
56
$ZodTypes,
@@ -131,6 +132,12 @@ export function getZodConstraint(schema: $ZodType): Record<string, Constraint> {
131132
if (_schema._zod.bag.maximum != null) {
132133
constraint.maxLength = _schema._zod.bag.maximum;
133134
}
135+
if (_schema._zod.bag.patterns?.size) {
136+
const pattern = combinePatterns([..._schema._zod.bag.patterns]);
137+
if (pattern) {
138+
constraint.pattern = pattern;
139+
}
140+
}
134141
} else if (def.type === 'optional') {
135142
constraint.required = false;
136143
updateConstraint(def.innerType, data, name);

packages/conform-zod/v4/tests/constraint.test.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -457,4 +457,26 @@ describe('getZodConstraint', () => {
457457
required: true,
458458
});
459459
});
460+
461+
test('regex patterns', () => {
462+
const schema = z.object({
463+
empty: z.string(),
464+
single: z.string().regex(/^[A-Z]+$/),
465+
multiple: z.string().regex(/[A-Z]/, 'uppercase').regex(/[0-9]/, 'digit'),
466+
});
467+
468+
expect(getZodConstraint(schema)).toEqual({
469+
empty: {
470+
required: true,
471+
},
472+
single: {
473+
required: true,
474+
pattern: '^(?=.*(?:^[A-Z]+$)).*$',
475+
},
476+
multiple: {
477+
required: true,
478+
pattern: '^(?=.*(?:[A-Z]))(?=.*(?:[0-9])).*$',
479+
},
480+
});
481+
});
460482
});

0 commit comments

Comments
 (0)