Skip to content

Commit 160f712

Browse files
authored
fix: Field components should now infer state.value properly
* chore: refactor TS typings for React * fix: field should now infer state.value properly in React adapter * chore: fix Vue package typings * chore: fix linting * chore: fix React adapter * chore: improve performance of TData type in FieldApi * chore: add back index and parent type * chore: add Vue TSC dep on Vue example * chore: fix lint and type test * chore: update Vite stuff * chore: add implicit dep for Vue and React examples * chore: add type test pre-req * chore: install deps from examples in PR CI * chore: remove filter from more installation
1 parent b5a768f commit 160f712

File tree

14 files changed

+425
-516
lines changed

14 files changed

+425
-516
lines changed

.github/workflows/pr.yml

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ jobs:
2222
node-version: 18.15.0
2323
cache: 'pnpm'
2424
- name: Install dependencies
25-
run: pnpm --filter "./packages/**" --filter form --prefer-offline install --no-frozen-lockfile
25+
run: pnpm --prefer-offline install --no-frozen-lockfile
2626
- name: Run Tests
2727
uses: nick-fields/[email protected]
2828
with:
@@ -48,7 +48,7 @@ jobs:
4848
node-version: 16.14.2
4949
cache: 'pnpm'
5050
- name: Install dependencies
51-
run: pnpm --filter "./packages/**" --filter form --prefer-offline install --no-frozen-lockfile
51+
run: pnpm --prefer-offline install --no-frozen-lockfile
5252
- run: pnpm run test:eslint --base=${{ github.event.pull_request.base.sha }}
5353
typecheck:
5454
name: 'Typecheck'
@@ -67,7 +67,7 @@ jobs:
6767
node-version: 16.14.2
6868
cache: 'pnpm'
6969
- name: Install dependencies
70-
run: pnpm --filter "./packages/**" --filter form --prefer-offline install --no-frozen-lockfile
70+
run: pnpm --prefer-offline install --no-frozen-lockfile
7171
- run: pnpm run test:types --base=${{ github.event.pull_request.base.sha }}
7272
format:
7373
name: 'Format'
@@ -86,7 +86,7 @@ jobs:
8686
node-version: 16.14.2
8787
cache: 'pnpm'
8888
- name: Install dependencies
89-
run: pnpm --filter "./packages/**" --filter form --prefer-offline install --no-frozen-lockfile
89+
run: pnpm --prefer-offline install --no-frozen-lockfile
9090
- run: pnpm run test:format --base=${{ github.event.pull_request.base.sha }}
9191
test-build:
9292
name: 'Test Build'
@@ -105,7 +105,7 @@ jobs:
105105
node-version: 16.14.2
106106
cache: 'pnpm'
107107
- name: Install dependencies
108-
run: pnpm --filter "./packages/**" --filter form --prefer-offline install --no-frozen-lockfile
108+
run: pnpm --prefer-offline install --no-frozen-lockfile
109109
- name: Get appropriate base and head commits for `nx affected` commands
110110
uses: nrwl/nx-set-shas@v3
111111
with:

examples/react/simple/package.json

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,12 +13,11 @@
1313
"react": "^18.0.0",
1414
"react-dom": "^18.0.0",
1515
"zod": "^3.21.4",
16-
"@tanstack/form-core": "0.3.2",
17-
"@tanstack/vue-form": "0.3.2"
16+
"@tanstack/form-core": "0.3.2"
1817
},
1918
"devDependencies": {
20-
"@vitejs/plugin-react": "^2.0.0",
21-
"vite": "^3.0.0"
19+
"@vitejs/plugin-react": "^4.0.4",
20+
"vite": "^4.4.9"
2221
},
2322
"browserslist": {
2423
"production": [
@@ -31,5 +30,18 @@
3130
"last 1 firefox version",
3231
"last 1 safari version"
3332
]
33+
},
34+
"nx": {
35+
"implicitDependencies": [
36+
"@tanstack/form-core",
37+
"@tanstack/react-form"
38+
],
39+
"targets": {
40+
"test:types": {
41+
"dependsOn": [
42+
"build"
43+
]
44+
}
45+
}
3446
}
3547
}

examples/vue/simple/package.json

Lines changed: 19 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,17 +5,31 @@
55
"dev": "vite",
66
"build": "vite build",
77
"build:dev": "vite build -m development",
8+
"test:types": "vue-tsc --noEmit",
89
"serve": "vite preview"
910
},
1011
"dependencies": {
11-
"@tanstack/vue-form": "0.3.2",
12-
"vue": "^3.3.4",
1312
"@tanstack/form-core": "0.3.2",
14-
"@tanstack/react-form": "0.3.2"
13+
"@tanstack/vue-form": "0.3.2",
14+
"vue": "^3.3.4"
1515
},
1616
"devDependencies": {
17-
"@vitejs/plugin-vue": "^4.2.3",
17+
"@vitejs/plugin-vue": "^4.3.4",
1818
"typescript": "^5.0.4",
19-
"vite": "^4.4.4"
19+
"vite": "^4.4.9",
20+
"vue-tsc": "^1.8.10"
21+
},
22+
"nx": {
23+
"implicitDependencies": [
24+
"@tanstack/form-core",
25+
"@tanstack/vue-form"
26+
],
27+
"targets": {
28+
"test:types": {
29+
"dependsOn": [
30+
"build"
31+
]
32+
}
33+
}
2034
}
2135
}

examples/vue/simple/src/App.vue

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ const form = useForm({
1515
1616
form.provideFormContext()
1717
18-
async function onChangeFirstName(value) {
18+
async function onChangeFirstName(value: string) {
1919
await new Promise((resolve) => setTimeout(resolve, 1000))
2020
return value.includes(`error`) && `No 'error' allowed in first name`
2121
}

package.json

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -56,8 +56,6 @@
5656
"@types/testing-library__jest-dom": "^5.14.5",
5757
"@typescript-eslint/eslint-plugin": "^6.4.1",
5858
"@typescript-eslint/parser": "^6.4.1",
59-
"@vitejs/plugin-vue": "^4.3.4",
60-
"@vitejs/plugin-vue-jsx": "^3.0.2",
6159
"@vitest/coverage-istanbul": "^0.34.3",
6260
"axios": "^0.26.1",
6361
"babel-eslint": "^10.1.0",

packages/form-core/src/FieldApi.ts

Lines changed: 62 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -43,10 +43,19 @@ export interface FieldOptions<
4343
defaultMeta?: Partial<FieldMeta>
4444
}
4545

46-
export type FieldApiOptions<TData, TFormData> = FieldOptions<
47-
TData,
48-
TFormData
49-
> & {
46+
export interface FieldApiOptions<
47+
_TData,
48+
TFormData,
49+
/**
50+
* This allows us to restrict the name to only be a valid field name while
51+
* also assigning it to a generic
52+
*/
53+
TName = unknown extends TFormData ? string : DeepKeys<TFormData>,
54+
/**
55+
* If TData is unknown, we can use the TName generic to determine the type
56+
*/
57+
TData = unknown extends _TData ? DeepValue<TFormData, TName> : _TData,
58+
> extends FieldOptions<_TData, TFormData, TName, TData> {
5059
form: FormApi<TFormData>
5160
}
5261

@@ -65,35 +74,45 @@ export type FieldState<TData> = {
6574
meta: FieldMeta
6675
}
6776

68-
/**
69-
* TData may not be known at the time of FieldApi construction, so we need to
70-
* use a conditional type to determine if TData is known or not.
71-
*
72-
* If TData is not known, we use the TFormData type to determine the type of
73-
* the field value based on the field name.
74-
*/
75-
type GetTData<Name, TData, TFormData> = unknown extends TData
76-
? DeepValue<TFormData, Name>
77-
: TData
78-
79-
export class FieldApi<TData, TFormData> {
77+
type GetTData<
78+
TData,
79+
TFormData,
80+
Opts extends FieldApiOptions<TData, TFormData>,
81+
> = Opts extends FieldApiOptions<
82+
infer _TData,
83+
infer _TFormData,
84+
infer _TName,
85+
infer RealTData
86+
>
87+
? RealTData
88+
: never
89+
90+
export class FieldApi<
91+
_TData,
92+
TFormData,
93+
Opts extends FieldApiOptions<_TData, TFormData> = FieldApiOptions<
94+
_TData,
95+
TFormData
96+
>,
97+
TData extends GetTData<_TData, TFormData, Opts> = GetTData<
98+
_TData,
99+
TFormData,
100+
Opts
101+
>,
102+
> {
80103
uid: number
81-
form: FormApi<TFormData>
104+
form: Opts['form']
82105
name!: DeepKeys<TFormData>
83-
/**
84-
* This is a hack that allows us to use `GetTData` without calling it everywhere
85-
*
86-
* Unfortunately this hack appears to be needed alongside the `TName` hack
87-
* further up in this file. This properly types all of the internal methods,
88-
* while the `TName` hack types the options properly
89-
*/
90-
_tdata!: GetTData<typeof this.name, TData, TFormData>
91-
store!: Store<FieldState<typeof this._tdata>>
92-
state!: FieldState<typeof this._tdata>
93-
prevState!: FieldState<typeof this._tdata>
94-
options: FieldOptions<typeof this._tdata, TFormData> = {} as any
95-
96-
constructor(opts: FieldApiOptions<TData, TFormData>) {
106+
options: Opts = {} as any
107+
store!: Store<FieldState<TData>>
108+
state!: FieldState<TData>
109+
prevState!: FieldState<TData>
110+
111+
constructor(
112+
opts: Opts & {
113+
form: FormApi<TFormData>
114+
},
115+
) {
97116
this.form = opts.form
98117
this.uid = uid++
99118
// Support field prefixing from FieldScope
@@ -104,7 +123,7 @@ export class FieldApi<TData, TFormData> {
104123

105124
this.name = opts.name as any
106125

107-
this.store = new Store<FieldState<typeof this._tdata>>(
126+
this.store = new Store<FieldState<TData>>(
108127
{
109128
value: this.getValue(),
110129
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
@@ -138,7 +157,7 @@ export class FieldApi<TData, TFormData> {
138157

139158
mount = () => {
140159
const info = this.getInfo()
141-
info.instances[this.uid] = this
160+
info.instances[this.uid] = this as never
142161

143162
const unsubscribe = this.form.store.subscribe(() => {
144163
this.store.batch(() => {
@@ -167,7 +186,7 @@ export class FieldApi<TData, TFormData> {
167186
}
168187
}
169188

170-
update = (opts: FieldApiOptions<typeof this._tdata, TFormData>) => {
189+
update = (opts: FieldApiOptions<TData, TFormData>) => {
171190
// Default Value
172191
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
173192
if (this.state.value === undefined) {
@@ -189,12 +208,12 @@ export class FieldApi<TData, TFormData> {
189208
this.options = opts as never
190209
}
191210

192-
getValue = (): typeof this._tdata => {
211+
getValue = (): TData => {
193212
return this.form.getFieldValue(this.name)
194213
}
195214

196215
setValue = (
197-
updater: Updater<typeof this._tdata>,
216+
updater: Updater<TData>,
198217
options?: { touch?: boolean; notify?: boolean },
199218
) => {
200219
this.form.setFieldValue(this.name, updater as never, options)
@@ -218,26 +237,21 @@ export class FieldApi<TData, TFormData> {
218237

219238
getInfo = () => this.form.getFieldInfo(this.name)
220239

221-
pushValue = (
222-
value: typeof this._tdata extends any[]
223-
? (typeof this._tdata)[number]
224-
: never,
225-
) => this.form.pushFieldValue(this.name, value as any)
240+
pushValue = (value: TData extends any[] ? TData[number] : never) =>
241+
this.form.pushFieldValue(this.name, value as any)
226242

227243
insertValue = (
228244
index: number,
229-
value: typeof this._tdata extends any[]
230-
? (typeof this._tdata)[number]
231-
: never,
245+
value: TData extends any[] ? TData[number] : never,
232246
) => this.form.insertFieldValue(this.name, index, value as any)
233247

234248
removeValue = (index: number) => this.form.removeFieldValue(this.name, index)
235249

236250
swapValues = (aIndex: number, bIndex: number) =>
237251
this.form.swapFieldValues(this.name, aIndex, bIndex)
238252

239-
getSubField = <TName extends DeepKeys<typeof this._tdata>>(name: TName) =>
240-
new FieldApi<DeepValue<typeof this._tdata, TName>, TFormData>({
253+
getSubField = <TName extends DeepKeys<TData>>(name: TName) =>
254+
new FieldApi<DeepValue<TData, TName>, TFormData>({
241255
name: `${this.name}.${name}` as never,
242256
form: this.form,
243257
})
@@ -371,7 +385,7 @@ export class FieldApi<TData, TFormData> {
371385

372386
validate = (
373387
cause: ValidationCause,
374-
value?: typeof this._tdata,
388+
value?: TData,
375389
): ValidationError[] | Promise<ValidationError[]> => {
376390
// If the field is pristine and validatePristine is false, do not validate
377391
if (!this.state.meta.isTouched) return []
@@ -389,7 +403,7 @@ export class FieldApi<TData, TFormData> {
389403
return this.validateAsync(value, cause)
390404
}
391405

392-
handleChange = (updater: Updater<typeof this._tdata>) => {
406+
handleChange = (updater: Updater<TData>) => {
393407
this.setValue(updater, { touch: true })
394408
}
395409

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
import { assertType } from 'vitest'
2+
import * as React from 'react'
3+
import { useForm } from '../useForm'
4+
5+
it('should type state.value properly', () => {
6+
function Comp() {
7+
const form = useForm({
8+
defaultValues: {
9+
firstName: 'test',
10+
age: 84,
11+
},
12+
} as const)
13+
14+
return (
15+
<form.Provider>
16+
<form.Field
17+
name="firstName"
18+
children={(field) => {
19+
assertType<'test'>(field.state.value)
20+
}}
21+
/>
22+
<form.Field
23+
name="age"
24+
children={(field) => {
25+
assertType<84>(field.state.value)
26+
}}
27+
/>
28+
</form.Provider>
29+
)
30+
}
31+
})
32+
33+
it('should type onChange properly', () => {
34+
function Comp() {
35+
const form = useForm({
36+
defaultValues: {
37+
firstName: 'test',
38+
age: 84,
39+
},
40+
} as const)
41+
42+
return (
43+
<form.Provider>
44+
<form.Field
45+
name="firstName"
46+
onChange={(val) => {
47+
assertType<'test'>(val)
48+
return null
49+
}}
50+
children={(field) => null}
51+
/>
52+
<form.Field
53+
name="age"
54+
onChange={(val) => {
55+
assertType<84>(val)
56+
return null
57+
}}
58+
children={(field) => null}
59+
/>
60+
</form.Provider>
61+
)
62+
}
63+
})

0 commit comments

Comments
 (0)