Skip to content

Commit caba021

Browse files
authored
Strictly type check field validators (#409)
* fix: strictly type validators on FieldApi * chore: upgrade vitest to latest * chore: rename test files to remove tsx prefix * test(form-core): add initial type tests
1 parent 0b5c94d commit caba021

File tree

7 files changed

+317
-134
lines changed

7 files changed

+317
-134
lines changed

package.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,7 @@
5353
"@types/testing-library__jest-dom": "^5.14.5",
5454
"@typescript-eslint/eslint-plugin": "^6.4.1",
5555
"@typescript-eslint/parser": "^6.4.1",
56-
"@vitest/coverage-istanbul": "^0.27.1",
56+
"@vitest/coverage-istanbul": "^0.34.3",
5757
"axios": "^0.26.1",
5858
"babel-eslint": "^10.1.0",
5959
"babel-jest": "^27.5.1",
@@ -96,7 +96,7 @@
9696
"tsup": "^7.0.0",
9797
"type-fest": "^3.11.0",
9898
"typescript": "^5.2.2",
99-
"vitest": "^0.27.1",
99+
"vitest": "^0.34.3",
100100
"vue": "^3.2.47"
101101
},
102102
"bundlewatch": {

packages/form-core/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@
3131
"scripts": {
3232
"clean": "rimraf ./build && rimraf ./coverage",
3333
"test:eslint": "eslint --ext .ts,.tsx ./src",
34-
"test:types": "tsc --noEmit",
34+
"test:types": "tsc --noEmit && vitest typecheck",
3535
"test:lib": "vitest run --coverage",
3636
"test:lib:dev": "pnpm run test:lib --watch",
3737
"test:build": "publint --strict",

packages/form-core/src/FieldApi.ts

Lines changed: 24 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,20 @@ type ValidateAsyncFn<TData, TFormData> = (
1414
fieldApi: FieldApi<TData, TFormData>,
1515
) => ValidationError | Promise<ValidationError>
1616

17-
export interface FieldOptions<TData, TFormData> {
18-
name: unknown extends TFormData ? string : DeepKeys<TFormData>
17+
export interface FieldOptions<
18+
_TData,
19+
TFormData,
20+
/**
21+
* This allows us to restrict the name to only be a valid field name while
22+
* also assigning it to a generic
23+
*/
24+
TName = unknown extends TFormData ? string : DeepKeys<TFormData>,
25+
/**
26+
* If TData is unknown, we can use the TName generic to determine the type
27+
*/
28+
TData = unknown extends _TData ? DeepValue<TFormData, TName> : _TData,
29+
> {
30+
name: TName
1931
index?: TData extends any[] ? number : never
2032
defaultValue?: TData
2133
asyncDebounceMs?: number
@@ -75,7 +87,7 @@ export type FieldState<TData> = {
7587
}
7688

7789
/**
78-
* TData may not known at the time of FieldApi construction, so we need to
90+
* TData may not be known at the time of FieldApi construction, so we need to
7991
* use a conditional type to determine if TData is known or not.
8092
*
8193
* If TData is not known, we use the TFormData type to determine the type of
@@ -89,7 +101,13 @@ export class FieldApi<TData, TFormData> {
89101
uid: number
90102
form: FormApi<TFormData>
91103
name!: DeepKeys<TFormData>
92-
// This is a hack that allows us to use `GetTData` without calling it everywhere
104+
/**
105+
* This is a hack that allows us to use `GetTData` without calling it everywhere
106+
*
107+
* Unfortunately this hack appears to be needed alongside the `TName` hack
108+
* further up in this file. This properly types all of the internal methods,
109+
* while the `TName` hack types the options properly
110+
*/
93111
_tdata!: GetTData<typeof this.name, TData, TFormData>
94112
store!: Store<FieldState<typeof this._tdata>>
95113
state!: FieldState<typeof this._tdata>
@@ -253,7 +271,7 @@ export class FieldApi<TData, TFormData> {
253271
// track freshness of the validation
254272
const validationCount = (this.getInfo().validationCount || 0) + 1
255273
this.getInfo().validationCount = validationCount
256-
const error = normalizeError(validate(value, this as never))
274+
const error = normalizeError(validate(value as never, this as never))
257275

258276
if (this.state.meta.error !== error) {
259277
this.setMeta((prev) => ({
@@ -336,7 +354,7 @@ export class FieldApi<TData, TFormData> {
336354
// Only kick off validation if this validation is the latest attempt
337355
if (checkLatest()) {
338356
try {
339-
const rawError = await validate(value, this as never)
357+
const rawError = await validate(value as never, this as never)
340358

341359
if (checkLatest()) {
342360
const error = normalizeError(rawError)
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import { assertType } from 'vitest'
2+
import { FormApi } from '../FormApi'
3+
import { FieldApi } from '../FieldApi'
4+
5+
it('should type a subfield properly', () => {
6+
const form = new FormApi({
7+
defaultValues: {
8+
names: {
9+
first: 'one',
10+
second: 'two',
11+
},
12+
} as const,
13+
})
14+
15+
const field = new FieldApi({
16+
form,
17+
name: 'names',
18+
})
19+
20+
const subfield = field.getSubField('first')
21+
22+
assertType<'one'>(subfield.getValue())
23+
})
24+
25+
it('should type onChange properly', () => {
26+
const form = new FormApi({
27+
defaultValues: {
28+
name: 'test',
29+
},
30+
} as const)
31+
32+
const field = new FieldApi({
33+
form,
34+
name: 'name',
35+
onChange: (value) => {
36+
assertType<'test'>(value)
37+
38+
return undefined
39+
},
40+
})
41+
})

0 commit comments

Comments
 (0)