Skip to content

Commit

Permalink
Strictly type check field validators (#409)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
crutchcorn authored Aug 29, 2023
1 parent 0b5c94d commit caba021
Show file tree
Hide file tree
Showing 7 changed files with 317 additions and 134 deletions.
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@
"@types/testing-library__jest-dom": "^5.14.5",
"@typescript-eslint/eslint-plugin": "^6.4.1",
"@typescript-eslint/parser": "^6.4.1",
"@vitest/coverage-istanbul": "^0.27.1",
"@vitest/coverage-istanbul": "^0.34.3",
"axios": "^0.26.1",
"babel-eslint": "^10.1.0",
"babel-jest": "^27.5.1",
Expand Down Expand Up @@ -96,7 +96,7 @@
"tsup": "^7.0.0",
"type-fest": "^3.11.0",
"typescript": "^5.2.2",
"vitest": "^0.27.1",
"vitest": "^0.34.3",
"vue": "^3.2.47"
},
"bundlewatch": {
Expand Down
2 changes: 1 addition & 1 deletion packages/form-core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@
"scripts": {
"clean": "rimraf ./build && rimraf ./coverage",
"test:eslint": "eslint --ext .ts,.tsx ./src",
"test:types": "tsc --noEmit",
"test:types": "tsc --noEmit && vitest typecheck",
"test:lib": "vitest run --coverage",
"test:lib:dev": "pnpm run test:lib --watch",
"test:build": "publint --strict",
Expand Down
30 changes: 24 additions & 6 deletions packages/form-core/src/FieldApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,20 @@ type ValidateAsyncFn<TData, TFormData> = (
fieldApi: FieldApi<TData, TFormData>,
) => ValidationError | Promise<ValidationError>

export interface FieldOptions<TData, TFormData> {
name: unknown extends TFormData ? string : DeepKeys<TFormData>
export interface FieldOptions<
_TData,
TFormData,
/**
* This allows us to restrict the name to only be a valid field name while
* also assigning it to a generic
*/
TName = unknown extends TFormData ? string : DeepKeys<TFormData>,
/**
* If TData is unknown, we can use the TName generic to determine the type
*/
TData = unknown extends _TData ? DeepValue<TFormData, TName> : _TData,
> {
name: TName
index?: TData extends any[] ? number : never
defaultValue?: TData
asyncDebounceMs?: number
Expand Down Expand Up @@ -75,7 +87,7 @@ export type FieldState<TData> = {
}

/**
* TData may not known at the time of FieldApi construction, so we need to
* TData may not be known at the time of FieldApi construction, so we need to
* use a conditional type to determine if TData is known or not.
*
* If TData is not known, we use the TFormData type to determine the type of
Expand All @@ -89,7 +101,13 @@ export class FieldApi<TData, TFormData> {
uid: number
form: FormApi<TFormData>
name!: DeepKeys<TFormData>
// This is a hack that allows us to use `GetTData` without calling it everywhere
/**
* This is a hack that allows us to use `GetTData` without calling it everywhere
*
* Unfortunately this hack appears to be needed alongside the `TName` hack
* further up in this file. This properly types all of the internal methods,
* while the `TName` hack types the options properly
*/
_tdata!: GetTData<typeof this.name, TData, TFormData>
store!: Store<FieldState<typeof this._tdata>>
state!: FieldState<typeof this._tdata>
Expand Down Expand Up @@ -253,7 +271,7 @@ export class FieldApi<TData, TFormData> {
// track freshness of the validation
const validationCount = (this.getInfo().validationCount || 0) + 1
this.getInfo().validationCount = validationCount
const error = normalizeError(validate(value, this as never))
const error = normalizeError(validate(value as never, this as never))

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

if (checkLatest()) {
const error = normalizeError(rawError)
Expand Down
41 changes: 41 additions & 0 deletions packages/form-core/src/tests/FieldApi.test-d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { assertType } from 'vitest'
import { FormApi } from '../FormApi'
import { FieldApi } from '../FieldApi'

it('should type a subfield properly', () => {
const form = new FormApi({
defaultValues: {
names: {
first: 'one',
second: 'two',
},
} as const,
})

const field = new FieldApi({
form,
name: 'names',
})

const subfield = field.getSubField('first')

assertType<'one'>(subfield.getValue())
})

it('should type onChange properly', () => {
const form = new FormApi({
defaultValues: {
name: 'test',
},
} as const)

const field = new FieldApi({
form,
name: 'name',
onChange: (value) => {
assertType<'test'>(value)

return undefined
},
})
})
Loading

0 comments on commit caba021

Please sign in to comment.