@structuralists/scaffolding 0.4.1 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bun.lock +192 -76
- package/package.json +15 -15
- package/src/forms/CLAUDE.md +41 -18
- package/src/forms/plan.md +270 -0
- package/src/forms/useFormState/types.ts +10 -0
- package/src/forms/useFormState/useFormState.stories.tsx +196 -0
- package/src/forms/useFormState/useFormState.test-d.ts +209 -0
- package/src/forms/useFormState/useFormState.test.tsx +134 -0
- package/src/forms/useFormState/useFormState.ts +41 -5
- package/src/forms/validations/perField.ts +11 -0
- package/src/forms/validations/types.test-d.ts +74 -1
- package/src/forms/validations/types.ts +23 -0
- package/src/forms/validators/validators.test.ts +99 -0
- package/src/forms/validators/validators.ts +95 -0
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
import { describe, it, expectTypeOf } from 'vitest';
|
|
2
|
+
import { useFormState } from './useFormState';
|
|
3
|
+
import { allOf, matches, min, minLength, notEmpty } from '../validators/validators';
|
|
4
|
+
import type { Refine, Validations } from '../validations/types';
|
|
5
|
+
import type { Path, ValueAt } from '../path/types';
|
|
6
|
+
|
|
7
|
+
// These tests exercise the headline feature end-to-end at the hook boundary:
|
|
8
|
+
// the type `onSubmit` receives must be the *refined* form type. The two
|
|
9
|
+
// call-site shapes under test:
|
|
10
|
+
// 1. inline constraints — no ceremony at all; the hook's `const V` type
|
|
11
|
+
// parameter preserves each validator's Refinement marker
|
|
12
|
+
// 2. pre-built constraints — `as const satisfies Validations<T>` outside
|
|
13
|
+
// the call
|
|
14
|
+
|
|
15
|
+
describe('useFormState onSubmit narrowing — inline constraints', () => {
|
|
16
|
+
it('narrows a refined field and passes others through, with no call-site ceremony', () => {
|
|
17
|
+
useFormState({
|
|
18
|
+
initialValues: {
|
|
19
|
+
a: undefined as string | undefined,
|
|
20
|
+
b: 0,
|
|
21
|
+
c: null as string | null,
|
|
22
|
+
},
|
|
23
|
+
constraints: {
|
|
24
|
+
a: notEmpty('a'),
|
|
25
|
+
c: notEmpty('c'),
|
|
26
|
+
},
|
|
27
|
+
onSubmit: (values) => {
|
|
28
|
+
expectTypeOf(values).toEqualTypeOf<{
|
|
29
|
+
a: string;
|
|
30
|
+
b: number;
|
|
31
|
+
c: string;
|
|
32
|
+
}>();
|
|
33
|
+
},
|
|
34
|
+
});
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('bare inline validator functions narrow nothing', () => {
|
|
38
|
+
useFormState({
|
|
39
|
+
initialValues: { a: undefined as string | undefined },
|
|
40
|
+
constraints: {
|
|
41
|
+
a: (val) => (val ? null : 'required'),
|
|
42
|
+
},
|
|
43
|
+
onSubmit: (values) => {
|
|
44
|
+
expectTypeOf(values).toEqualTypeOf<{ a: string | undefined }>();
|
|
45
|
+
},
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('without constraints, onSubmit receives the unrefined form type', () => {
|
|
50
|
+
useFormState({
|
|
51
|
+
initialValues: { a: undefined as string | undefined, b: 0 },
|
|
52
|
+
onSubmit: (values) => {
|
|
53
|
+
expectTypeOf(values).toEqualTypeOf<{
|
|
54
|
+
a: string | undefined;
|
|
55
|
+
b: number;
|
|
56
|
+
}>();
|
|
57
|
+
},
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('rejects constraints for keys not in the form type', () => {
|
|
62
|
+
useFormState({
|
|
63
|
+
initialValues: { a: '' },
|
|
64
|
+
constraints: {
|
|
65
|
+
// @ts-expect-error 'nope' is not a field of the form
|
|
66
|
+
nope: notEmpty('nope'),
|
|
67
|
+
},
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('rejects a validator whose input is incompatible with the field', () => {
|
|
72
|
+
useFormState({
|
|
73
|
+
initialValues: { a: 0 },
|
|
74
|
+
constraints: {
|
|
75
|
+
// @ts-expect-error minLength validates strings, `a` is a number
|
|
76
|
+
a: minLength('a', 3),
|
|
77
|
+
},
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
describe('useFormState onSubmit narrowing — pre-built constraints', () => {
|
|
83
|
+
type FormType = {
|
|
84
|
+
email: string | undefined;
|
|
85
|
+
nickname: string | undefined;
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
it('preserves refinements from an `as const satisfies` constraints object', () => {
|
|
89
|
+
const constraints = {
|
|
90
|
+
email: allOf(notEmpty('email'), matches('email', /@/, 'a valid email')),
|
|
91
|
+
} as const satisfies Validations<FormType>;
|
|
92
|
+
|
|
93
|
+
useFormState({
|
|
94
|
+
initialValues: { email: undefined, nickname: undefined } as FormType,
|
|
95
|
+
constraints,
|
|
96
|
+
onSubmit: (values) => {
|
|
97
|
+
expectTypeOf(values).toEqualTypeOf<{
|
|
98
|
+
email: string;
|
|
99
|
+
nickname: string | undefined;
|
|
100
|
+
}>();
|
|
101
|
+
},
|
|
102
|
+
});
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
// ---------------------------------------------------------------------------
|
|
107
|
+
// The recursion probe. Prior explorations of this design narrowed fine on
|
|
108
|
+
// trivial examples but hit TS recursion limits on realistic form state. This
|
|
109
|
+
// form type is deliberately chunky — ~30 leaf fields, 3 levels of object
|
|
110
|
+
// nesting, lists of objects with nested objects inside. If the machinery
|
|
111
|
+
// scales, this file typechecks; if it regresses, tsc fails here first.
|
|
112
|
+
// ---------------------------------------------------------------------------
|
|
113
|
+
|
|
114
|
+
type UsAddress = {
|
|
115
|
+
line1: string | undefined;
|
|
116
|
+
line2: string | undefined;
|
|
117
|
+
city: string | undefined;
|
|
118
|
+
state: string | undefined;
|
|
119
|
+
postalCode: string | undefined;
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
type InsuranceQuoteForm = {
|
|
123
|
+
firstName: string | undefined;
|
|
124
|
+
lastName: string | undefined;
|
|
125
|
+
email: string | undefined;
|
|
126
|
+
phone: string | undefined;
|
|
127
|
+
dateOfBirth: string | undefined;
|
|
128
|
+
homeAddress: UsAddress;
|
|
129
|
+
mailingAddress: UsAddress;
|
|
130
|
+
employer: string | null;
|
|
131
|
+
jobTitle: string | null;
|
|
132
|
+
yearsEmployed: number | null;
|
|
133
|
+
annualIncomeUsd: number | null;
|
|
134
|
+
coverageType: string | undefined;
|
|
135
|
+
deductibleUsd: number | undefined;
|
|
136
|
+
startDate: string | undefined;
|
|
137
|
+
drivers: Array<{
|
|
138
|
+
name: string | undefined;
|
|
139
|
+
licenseNumber: string | undefined;
|
|
140
|
+
licenseState: string | undefined;
|
|
141
|
+
incidents: Array<{
|
|
142
|
+
date: string | undefined;
|
|
143
|
+
kind: string | undefined;
|
|
144
|
+
claimAmountUsd: number | null;
|
|
145
|
+
}>;
|
|
146
|
+
}>;
|
|
147
|
+
vehicles: Array<{
|
|
148
|
+
vin: string | undefined;
|
|
149
|
+
year: number | undefined;
|
|
150
|
+
make: string | undefined;
|
|
151
|
+
model: string | undefined;
|
|
152
|
+
primaryDriverName: string | undefined;
|
|
153
|
+
garagingAddress: UsAddress;
|
|
154
|
+
}>;
|
|
155
|
+
discountCodes: string[];
|
|
156
|
+
referralSource: string | null;
|
|
157
|
+
notes: string | undefined;
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
describe('useFormState narrowing at realistic scale', () => {
|
|
161
|
+
it('narrows constrained fields on a ~30-field, 3-level-deep form', () => {
|
|
162
|
+
useFormState({
|
|
163
|
+
initialValues: {} as InsuranceQuoteForm,
|
|
164
|
+
constraints: {
|
|
165
|
+
firstName: notEmpty('firstName'),
|
|
166
|
+
lastName: notEmpty('lastName'),
|
|
167
|
+
email: allOf(notEmpty('email'), matches('email', /@/, 'a valid email')),
|
|
168
|
+
phone: matches('phone', /^[\d\s()+-]+$/, 'a phone number'),
|
|
169
|
+
dateOfBirth: notEmpty('dateOfBirth'),
|
|
170
|
+
yearsEmployed: min('yearsEmployed', 0),
|
|
171
|
+
annualIncomeUsd: min('annualIncomeUsd', 0),
|
|
172
|
+
coverageType: notEmpty('coverageType'),
|
|
173
|
+
startDate: notEmpty('startDate'),
|
|
174
|
+
referralSource: notEmpty('referralSource'),
|
|
175
|
+
notes: minLength('notes', 10),
|
|
176
|
+
},
|
|
177
|
+
onSubmit: (values) => {
|
|
178
|
+
// Refined: notEmpty strips null/undefined/'' from the union.
|
|
179
|
+
expectTypeOf(values.firstName).toEqualTypeOf<string>();
|
|
180
|
+
expectTypeOf(values.email).toEqualTypeOf<string>();
|
|
181
|
+
expectTypeOf(values.referralSource).toEqualTypeOf<string>();
|
|
182
|
+
// Constrained but non-refining validators leave the type alone.
|
|
183
|
+
expectTypeOf(values.phone).toEqualTypeOf<string | undefined>();
|
|
184
|
+
expectTypeOf(values.annualIncomeUsd).toEqualTypeOf<number | null>();
|
|
185
|
+
expectTypeOf(values.notes).toEqualTypeOf<string | undefined>();
|
|
186
|
+
// Unconstrained fields — including all nested structure — untouched.
|
|
187
|
+
expectTypeOf(values.homeAddress).toEqualTypeOf<UsAddress>();
|
|
188
|
+
expectTypeOf(values.drivers).toEqualTypeOf<
|
|
189
|
+
InsuranceQuoteForm['drivers']
|
|
190
|
+
>();
|
|
191
|
+
expectTypeOf(values.vehicles[0].garagingAddress).toEqualTypeOf<UsAddress>();
|
|
192
|
+
},
|
|
193
|
+
});
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
it('Path<T> still expands on the realistic form type', () => {
|
|
197
|
+
// Path is the most recursion-prone type in forms/ — its union grows with
|
|
198
|
+
// every key at every depth. Probe it with deep representative paths
|
|
199
|
+
// rather than equality on the (huge) full union.
|
|
200
|
+
type P = Path<InsuranceQuoteForm>;
|
|
201
|
+
expectTypeOf<['homeAddress', 'city']>().toMatchTypeOf<P>();
|
|
202
|
+
expectTypeOf<['drivers', number, 'incidents', number, 'claimAmountUsd']>().toMatchTypeOf<P>();
|
|
203
|
+
expectTypeOf<['vehicles', number, 'garagingAddress', 'postalCode']>().toMatchTypeOf<P>();
|
|
204
|
+
|
|
205
|
+
expectTypeOf<
|
|
206
|
+
ValueAt<InsuranceQuoteForm, ['drivers', number, 'incidents', number, 'claimAmountUsd']>
|
|
207
|
+
>().toEqualTypeOf<number | null>();
|
|
208
|
+
});
|
|
209
|
+
});
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import { describe, test, expect, mock } from 'bun:test';
|
|
2
|
+
import { act, renderHook } from '@testing-library/react';
|
|
3
|
+
import { useFormState } from './useFormState';
|
|
4
|
+
import { allOf, matches, notEmpty } from '../validators/validators';
|
|
5
|
+
|
|
6
|
+
type SignupForm = {
|
|
7
|
+
email: string | undefined;
|
|
8
|
+
nickname: string | undefined;
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
const initialValues: SignupForm = { email: undefined, nickname: undefined };
|
|
12
|
+
|
|
13
|
+
describe('useFormState', () => {
|
|
14
|
+
test('exposes initial values', () => {
|
|
15
|
+
const { result } = renderHook(() => useFormState({ initialValues }));
|
|
16
|
+
expect(result.current.values).toEqual(initialValues);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
test('onValueChanges accepts a replacement object', () => {
|
|
20
|
+
const { result } = renderHook(() => useFormState({ initialValues }));
|
|
21
|
+
act(() => {
|
|
22
|
+
result.current.onValueChanges({ email: 'a@b.co', nickname: 'will' });
|
|
23
|
+
});
|
|
24
|
+
expect(result.current.values.email).toBe('a@b.co');
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test('onValueChanges accepts an updater function', () => {
|
|
28
|
+
const { result } = renderHook(() => useFormState({ initialValues }));
|
|
29
|
+
act(() => {
|
|
30
|
+
result.current.onValueChanges((prev) => ({ ...prev, nickname: 'will' }));
|
|
31
|
+
});
|
|
32
|
+
expect(result.current.values).toEqual({ email: undefined, nickname: 'will' });
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test('errors are derived live from the current values', () => {
|
|
36
|
+
const { result } = renderHook(() =>
|
|
37
|
+
useFormState({
|
|
38
|
+
initialValues,
|
|
39
|
+
constraints: { email: notEmpty('email') },
|
|
40
|
+
}),
|
|
41
|
+
);
|
|
42
|
+
expect(result.current.errors.email).toBe("'email' cannot be empty");
|
|
43
|
+
expect(result.current.isValid).toBe(false);
|
|
44
|
+
|
|
45
|
+
act(() => {
|
|
46
|
+
result.current.onValueChanges((prev) => ({ ...prev, email: 'a@b.co' }));
|
|
47
|
+
});
|
|
48
|
+
expect(result.current.errors.email).toBeUndefined();
|
|
49
|
+
expect(result.current.isValid).toBe(true);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test('fields without constraints never produce errors', () => {
|
|
53
|
+
const { result } = renderHook(() =>
|
|
54
|
+
useFormState({
|
|
55
|
+
initialValues,
|
|
56
|
+
constraints: { email: notEmpty('email') },
|
|
57
|
+
}),
|
|
58
|
+
);
|
|
59
|
+
expect(result.current.errors.nickname).toBeUndefined();
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test('a form without constraints is always valid', () => {
|
|
63
|
+
const { result } = renderHook(() => useFormState({ initialValues }));
|
|
64
|
+
expect(result.current.isValid).toBe(true);
|
|
65
|
+
expect(result.current.errors).toEqual({});
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test('submit on an invalid form marks the attempt and skips onSubmit', () => {
|
|
69
|
+
const onSubmit = mock(() => {});
|
|
70
|
+
const { result } = renderHook(() =>
|
|
71
|
+
useFormState({
|
|
72
|
+
initialValues,
|
|
73
|
+
constraints: { email: notEmpty('email') },
|
|
74
|
+
onSubmit,
|
|
75
|
+
}),
|
|
76
|
+
);
|
|
77
|
+
expect(result.current.submitAttempted).toBe(false);
|
|
78
|
+
|
|
79
|
+
act(() => {
|
|
80
|
+
result.current.submit();
|
|
81
|
+
});
|
|
82
|
+
expect(result.current.submitAttempted).toBe(true);
|
|
83
|
+
expect(onSubmit).not.toHaveBeenCalled();
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test('submit on a valid form calls onSubmit with the current values', () => {
|
|
87
|
+
const onSubmit = mock(() => {});
|
|
88
|
+
const { result } = renderHook(() =>
|
|
89
|
+
useFormState({
|
|
90
|
+
initialValues,
|
|
91
|
+
constraints: {
|
|
92
|
+
email: allOf(notEmpty('email'), matches('email', /@/, 'a valid email')),
|
|
93
|
+
},
|
|
94
|
+
onSubmit,
|
|
95
|
+
}),
|
|
96
|
+
);
|
|
97
|
+
|
|
98
|
+
act(() => {
|
|
99
|
+
result.current.onValueChanges({ email: 'a@b.co', nickname: undefined });
|
|
100
|
+
});
|
|
101
|
+
act(() => {
|
|
102
|
+
result.current.submit();
|
|
103
|
+
});
|
|
104
|
+
expect(onSubmit).toHaveBeenCalledTimes(1);
|
|
105
|
+
expect(onSubmit).toHaveBeenCalledWith({
|
|
106
|
+
email: 'a@b.co',
|
|
107
|
+
nickname: undefined,
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
test('a failed submit followed by a fix allows the next submit through', () => {
|
|
112
|
+
const onSubmit = mock(() => {});
|
|
113
|
+
const { result } = renderHook(() =>
|
|
114
|
+
useFormState({
|
|
115
|
+
initialValues,
|
|
116
|
+
constraints: { email: notEmpty('email') },
|
|
117
|
+
onSubmit,
|
|
118
|
+
}),
|
|
119
|
+
);
|
|
120
|
+
|
|
121
|
+
act(() => {
|
|
122
|
+
result.current.submit();
|
|
123
|
+
});
|
|
124
|
+
expect(onSubmit).not.toHaveBeenCalled();
|
|
125
|
+
|
|
126
|
+
act(() => {
|
|
127
|
+
result.current.onValueChanges((prev) => ({ ...prev, email: 'a@b.co' }));
|
|
128
|
+
});
|
|
129
|
+
act(() => {
|
|
130
|
+
result.current.submit();
|
|
131
|
+
});
|
|
132
|
+
expect(onSubmit).toHaveBeenCalledTimes(1);
|
|
133
|
+
});
|
|
134
|
+
});
|
|
@@ -1,14 +1,50 @@
|
|
|
1
1
|
import { useState } from 'react';
|
|
2
|
-
import type { FormHelpers, FormValuesObject } from './types';
|
|
2
|
+
import type { FormErrors, FormHelpers, FormValuesObject } from './types';
|
|
3
|
+
import type { Refine, Validations } from '../validations/types';
|
|
3
4
|
|
|
4
|
-
type
|
|
5
|
+
// `const V` freezes the inferred type of an inline `constraints` object —
|
|
6
|
+
// each validator's precise type and Refinement marker survive without any
|
|
7
|
+
// `as const` at the call site. Constraint objects built outside the call
|
|
8
|
+
// still need `as const satisfies Validations<T>` (see src/forms/CLAUDE.md).
|
|
9
|
+
type Args<T extends FormValuesObject, V extends Validations<T>> = {
|
|
5
10
|
initialValues: T;
|
|
11
|
+
constraints?: V;
|
|
12
|
+
onSubmit?: (values: Refine<T, V>) => void;
|
|
6
13
|
};
|
|
7
14
|
|
|
8
|
-
export const useFormState = <
|
|
9
|
-
|
|
15
|
+
export const useFormState = <
|
|
16
|
+
T extends FormValuesObject,
|
|
17
|
+
const V extends Validations<T> = Validations<T>,
|
|
18
|
+
>(
|
|
19
|
+
args: Args<T, V>,
|
|
20
|
+
): FormHelpers<T> => {
|
|
21
|
+
const { initialValues, constraints, onSubmit } = args;
|
|
10
22
|
|
|
11
23
|
const [values, onValueChanges] = useState<T>(initialValues);
|
|
24
|
+
const [submitAttempted, setSubmitAttempted] = useState(false);
|
|
12
25
|
|
|
13
|
-
|
|
26
|
+
const errors: FormErrors<T> = {};
|
|
27
|
+
if (constraints) {
|
|
28
|
+
for (const key of Object.keys(constraints) as (keyof T & string)[]) {
|
|
29
|
+
// Field type and validator input are correlated per key, but TS can't
|
|
30
|
+
// track that through the union of keys — widen the input to unknown.
|
|
31
|
+
const validator = constraints[key] as
|
|
32
|
+
| ((val: unknown) => string | null)
|
|
33
|
+
| undefined;
|
|
34
|
+
const error = validator?.(values[key]);
|
|
35
|
+
if (error != null) errors[key] = error;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const isValid = Object.keys(errors).length === 0;
|
|
40
|
+
|
|
41
|
+
const submit = () => {
|
|
42
|
+
setSubmitAttempted(true);
|
|
43
|
+
if (!isValid) return;
|
|
44
|
+
// Every constrained field's validator just passed at runtime — exactly
|
|
45
|
+
// the guarantee Refine<T, V> encodes.
|
|
46
|
+
onSubmit?.(values as Refine<T, V>);
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
return { values, onValueChanges, errors, isValid, submitAttempted, submit };
|
|
14
50
|
};
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
// Aggregation entry point for building a `Validations<T>`-shaped constraints
|
|
2
|
+
// object. Runtime identity; the value is in the type: the `const` type
|
|
3
|
+
// parameter freezes each validator's precise type (including `Refinement`
|
|
4
|
+
// markers) instead of letting function types widen. Callers still apply
|
|
5
|
+
// `as const satisfies Validations<FormType>` to shape-check against their
|
|
6
|
+
// form type without losing that precision — see src/forms/CLAUDE.md.
|
|
7
|
+
export const perField = <
|
|
8
|
+
const V extends Record<string, (val: never) => string | null>,
|
|
9
|
+
>(
|
|
10
|
+
validations: V,
|
|
11
|
+
): V => validations;
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { describe, it, expectTypeOf } from 'vitest';
|
|
2
|
-
import
|
|
2
|
+
import { perField } from './perField';
|
|
3
|
+
import { allOf, matches, minLength, notEmpty } from '../validators/validators';
|
|
4
|
+
import type { Refine, Refinement, Validations, Validator } from './types';
|
|
3
5
|
|
|
4
6
|
describe('Validator<Input, Excluded>', () => {
|
|
5
7
|
it('preserves the input type as the call parameter', () => {
|
|
@@ -24,3 +26,74 @@ describe('Validator<Input, Excluded>', () => {
|
|
|
24
26
|
expectTypeOf<Extracted>().toBeNever();
|
|
25
27
|
});
|
|
26
28
|
});
|
|
29
|
+
|
|
30
|
+
type FormType = {
|
|
31
|
+
a: string | undefined;
|
|
32
|
+
b: number;
|
|
33
|
+
c: string | null;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
describe('Refine<T, V>', () => {
|
|
37
|
+
it('narrows fields whose validator carries a refinement', () => {
|
|
38
|
+
const constraints = {
|
|
39
|
+
a: notEmpty('a'),
|
|
40
|
+
} as const satisfies Validations<FormType>;
|
|
41
|
+
|
|
42
|
+
type Result = Refine<FormType, typeof constraints>;
|
|
43
|
+
expectTypeOf<Result>().toEqualTypeOf<{
|
|
44
|
+
a: string;
|
|
45
|
+
b: number;
|
|
46
|
+
c: string | null;
|
|
47
|
+
}>();
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('preserves precision through perField without as const', () => {
|
|
51
|
+
const constraints = perField({
|
|
52
|
+
a: notEmpty('a'),
|
|
53
|
+
c: notEmpty('c'),
|
|
54
|
+
}) satisfies Validations<FormType>;
|
|
55
|
+
|
|
56
|
+
type Result = Refine<FormType, typeof constraints>;
|
|
57
|
+
expectTypeOf<Result>().toEqualTypeOf<{
|
|
58
|
+
a: string;
|
|
59
|
+
b: number;
|
|
60
|
+
c: string;
|
|
61
|
+
}>();
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('bare validator functions narrow nothing', () => {
|
|
65
|
+
const constraints = {
|
|
66
|
+
a: (val: string | undefined) => (val ? null : 'required'),
|
|
67
|
+
} as const satisfies Validations<FormType>;
|
|
68
|
+
|
|
69
|
+
type Result = Refine<FormType, typeof constraints>;
|
|
70
|
+
expectTypeOf<Result>().toEqualTypeOf<FormType>();
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('a constraints object widened by annotation refines nothing', () => {
|
|
74
|
+
// Annotating with `: Validations<FormType>` (instead of `satisfies`)
|
|
75
|
+
// widens every validator to a bare function — markers are lost. This is
|
|
76
|
+
// the failure mode the `as const satisfies` / const-generic patterns exist
|
|
77
|
+
// to prevent.
|
|
78
|
+
const constraints: Validations<FormType> = { a: notEmpty('a') };
|
|
79
|
+
|
|
80
|
+
type Result = Refine<FormType, typeof constraints>;
|
|
81
|
+
expectTypeOf<Result>().toEqualTypeOf<FormType>();
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('allOf carries the union of its parts’ refinements', () => {
|
|
85
|
+
const constraints = {
|
|
86
|
+
a: allOf(notEmpty('a'), minLength('a', 3), matches('a', /^\S+$/, 'no spaces')),
|
|
87
|
+
} as const satisfies Validations<FormType>;
|
|
88
|
+
|
|
89
|
+
type Result = Refine<FormType, typeof constraints>;
|
|
90
|
+
expectTypeOf<Result['a']>().toEqualTypeOf<string>();
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it('an empty constraints object leaves the form type unchanged', () => {
|
|
94
|
+
const constraints = {} as const satisfies Validations<FormType>;
|
|
95
|
+
|
|
96
|
+
type Result = Refine<FormType, typeof constraints>;
|
|
97
|
+
expectTypeOf<Result>().toEqualTypeOf<FormType>();
|
|
98
|
+
});
|
|
99
|
+
});
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import type { FormValuesObject } from '../useFormState/types';
|
|
2
|
+
|
|
1
3
|
// Phantom marker carried by validators. The runtime value of `__excludes`
|
|
2
4
|
// is meaningless and never read — only its declared type matters. Required
|
|
3
5
|
// (not optional) so validators can't accidentally drop the marker, and so
|
|
@@ -13,3 +15,24 @@ export type Refinement<Excluded = never> = {
|
|
|
13
15
|
// validates at runtime; it just doesn't shrink the field's submit type).
|
|
14
16
|
export type Validator<Input, Excluded = never> =
|
|
15
17
|
((val: Input) => string | null) & Refinement<Excluded>;
|
|
18
|
+
|
|
19
|
+
// The constraint for a per-field validation map. Deliberately does NOT demand
|
|
20
|
+
// the `Refinement` marker: a bare `(val) => string | null` is a legal
|
|
21
|
+
// constraint that narrows nothing. Markers on standard-library validators
|
|
22
|
+
// ride along in the *inferred* type of a concrete constraints object and are
|
|
23
|
+
// recovered structurally by `Refine<>`.
|
|
24
|
+
export type Validations<T extends FormValuesObject> = {
|
|
25
|
+
readonly [K in keyof T]?: (val: T[K]) => string | null;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
// Applies each field's validator marker to the form type: the submit-time
|
|
29
|
+
// type. Fields whose validator carries `Refinement<X>` become
|
|
30
|
+
// `Exclude<T[K], X>`; unconstrained fields and bare (marker-less) validators
|
|
31
|
+
// pass through unchanged. Shallow by design — one mapped type, no recursion.
|
|
32
|
+
export type Refine<T extends FormValuesObject, V extends Validations<T>> = {
|
|
33
|
+
[K in keyof T]: K extends keyof V
|
|
34
|
+
? V[K] extends Refinement<infer Excluded>
|
|
35
|
+
? Exclude<T[K], Excluded>
|
|
36
|
+
: T[K]
|
|
37
|
+
: T[K];
|
|
38
|
+
};
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { describe, test, expect } from 'bun:test';
|
|
2
|
+
import { allOf, matches, min, minLength, notEmpty } from './validators';
|
|
3
|
+
|
|
4
|
+
describe('notEmpty', () => {
|
|
5
|
+
const validator = notEmpty('email');
|
|
6
|
+
|
|
7
|
+
test('rejects null, undefined, and the empty string', () => {
|
|
8
|
+
expect(validator(null)).toBe("'email' cannot be empty");
|
|
9
|
+
expect(validator(undefined)).toBe("'email' cannot be empty");
|
|
10
|
+
expect(validator('')).toBe("'email' cannot be empty");
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
test('passes any other value', () => {
|
|
14
|
+
expect(validator('a@b.co')).toBeNull();
|
|
15
|
+
expect(validator(0)).toBeNull();
|
|
16
|
+
expect(validator(' ')).toBeNull();
|
|
17
|
+
});
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
describe('minLength', () => {
|
|
21
|
+
const validator = minLength('nickname', 3);
|
|
22
|
+
|
|
23
|
+
test('rejects strings shorter than the minimum', () => {
|
|
24
|
+
expect(validator('ab')).toBe("'nickname' must be at least 3 characters");
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test('passes strings at or above the minimum', () => {
|
|
28
|
+
expect(validator('abc')).toBeNull();
|
|
29
|
+
expect(validator('abcd')).toBeNull();
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test('passes nullish and empty values — requiredness is notEmpty’s job', () => {
|
|
33
|
+
expect(validator(null)).toBeNull();
|
|
34
|
+
expect(validator(undefined)).toBeNull();
|
|
35
|
+
expect(validator('')).toBeNull();
|
|
36
|
+
});
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
describe('matches', () => {
|
|
40
|
+
const validator = matches('email', /@/, 'a valid email');
|
|
41
|
+
|
|
42
|
+
test('rejects values that fail the pattern', () => {
|
|
43
|
+
expect(validator('not-an-email')).toBe("'email' must be a valid email");
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test('passes values matching the pattern', () => {
|
|
47
|
+
expect(validator('a@b.co')).toBeNull();
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test('passes nullish and empty values', () => {
|
|
51
|
+
expect(validator(null)).toBeNull();
|
|
52
|
+
expect(validator(undefined)).toBeNull();
|
|
53
|
+
expect(validator('')).toBeNull();
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
describe('min', () => {
|
|
58
|
+
const validator = min('age', 18);
|
|
59
|
+
|
|
60
|
+
test('rejects numbers below the minimum', () => {
|
|
61
|
+
expect(validator(17)).toBe("'age' must be at least 18");
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test('passes numbers at or above the minimum', () => {
|
|
65
|
+
expect(validator(18)).toBeNull();
|
|
66
|
+
expect(validator(99)).toBeNull();
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test('passes nullish values', () => {
|
|
70
|
+
expect(validator(null)).toBeNull();
|
|
71
|
+
expect(validator(undefined)).toBeNull();
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
describe('allOf', () => {
|
|
76
|
+
const validator = allOf(
|
|
77
|
+
notEmpty('email'),
|
|
78
|
+
minLength('email', 6),
|
|
79
|
+
matches('email', /@/, 'a valid email'),
|
|
80
|
+
);
|
|
81
|
+
|
|
82
|
+
test('returns the first failing part’s error', () => {
|
|
83
|
+
expect(validator(undefined)).toBe("'email' cannot be empty");
|
|
84
|
+
expect(validator('a@b')).toBe("'email' must be at least 6 characters");
|
|
85
|
+
expect(validator('abcdefg')).toBe("'email' must be a valid email");
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test('passes when every part passes', () => {
|
|
89
|
+
expect(validator('a@b.com')).toBeNull();
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test('accepts bare validator functions alongside marked ones', () => {
|
|
93
|
+
const noAdmin = (val: string | null | undefined) =>
|
|
94
|
+
val === 'admin' ? 'reserved' : null;
|
|
95
|
+
const composed = allOf(notEmpty('name'), noAdmin);
|
|
96
|
+
expect(composed('admin')).toBe('reserved');
|
|
97
|
+
expect(composed('will')).toBeNull();
|
|
98
|
+
});
|
|
99
|
+
});
|