diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 99f1754..838fddc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -17,7 +17,7 @@ jobs: - uses: actions/setup-node@v6 with: - node-version: 20 + node-version: 24 - run: npm i -g --force corepack@latest && corepack enable @@ -39,7 +39,7 @@ jobs: - uses: actions/setup-node@v6 with: - node-version: 20 + node-version: 24 - run: npm i -g --force corepack@latest && corepack enable @@ -55,3 +55,6 @@ jobs: - name: Test run: npm run test + + - name: Typecheck + run: npm run test:types diff --git a/README.md b/README.md index 94d30ef..e57ed6c 100644 --- a/README.md +++ b/README.md @@ -192,7 +192,13 @@ const { data } = api.structure.$id.invoices.$get.useFetch({ // Reactive query (TanStack) — re-fetches when params/query change const { data, isPending } = api.structure.$id.invoices.$get.useQuery( { params: { id: 'abc' }, query: { page: 1 } }, - queryOptions?, // Omit + queryOptions?, // UseQueryOptions without queryKey/queryFn, typed on the endpoint's data +) + +// `select` types `data` the way TanStack's own useQuery does +const { data: count } = api.structure.$id.invoices.$get.useQuery( + { params: { id: 'abc' } }, + { select: result => result.invoices.length }, // data: Ref ) // Imperative fetch (TanStack) — for prefetch or event handlers diff --git a/playground/app.vue b/playground/app.vue index e2f1975..1bac521 100644 --- a/playground/app.vue +++ b/playground/app.vue @@ -52,6 +52,38 @@ const { data, isPending } = api.structure.$id.invoices.$get.useQuery({ query: { query: 'ABC' }, }) +// --- useQuery with select: `data` takes the type `select` returns --- +// Type checks only, never called: `npm run test:types` fails if one breaks. +function _useQueryTypeChecks() { + // Without select, `data` is the success payload + const { data: payload } = api.structure.$id.invoices.$get.useQuery({ params: { id: 'testid' } }) + const invoices: { invoices: string[] } | undefined = payload.value + // @ts-expect-error `data` is the payload, not `any` + const payloadIsNotAString: string = payload.value + + // With select, `data` is what select returns, from the payload it receives + const { data: count } = api.structure.$id.invoices.$get.useQuery( + { params: { id: 'testid' } }, + { select: result => result.invoices.length }, + ) + const invoiceCount: number | undefined = count.value + // @ts-expect-error `data` is the selected number, not `any` + const countIsNotAString: string = count.value + + // A select that expects another input than the payload is refused + api.structure.$id.invoices.$get.useQuery( + { params: { id: 'testid' } }, + // @ts-expect-error the payload has no `total` + { select: (result: { total: number }) => result.total }, + ) + + // Endpoints without options keep the same inference + const { data: mode } = api.$get.useQuery(undefined, { select: () => true as const }) + const modeIsTrue: true | undefined = mode.value + + return { invoices, payloadIsNotAString, invoiceCount, countIsNotAString, modeIsTrue } +} + // --- useFetch (Nuxt composable) --- const { data: nuxtData, pending: nuxtPending } = api.structure.$id.invoices.$get.useFetch({ params: { id: 'testid' }, diff --git a/src/generator.ts b/src/generator.ts index a140b77..c5a400b 100644 --- a/src/generator.ts +++ b/src/generator.ts @@ -227,8 +227,11 @@ function buildKeyFn(pathSegments: string[], paramsType: string | null, queryType } } - const querySpread = queryType ? `, ...(query !== undefined ? [query] : [])` : '' - return `function _key(${args.join(', ')}): unknown[] { return [${keyParts.join(', ')}${querySpread}] }` + // The root route (/api/) has no segment. An empty string stands for it, so its + // key is never [], which a disabled query of any route uses as a placeholder. + if (keyParts.length === 0) keyParts.push(JSON.stringify('')) + if (queryType) keyParts.push(`...(query !== undefined ? [query] : [])`) + return `function _key(${args.join(', ')}): unknown[] { return [${keyParts.join(', ')}] }` } function buildUrlFn(pathSegments: string[]): string { @@ -283,7 +286,7 @@ export function generateEndpointFile(ep: EndpointInfo, hasTanstack: boolean): st if (hasTanstack) { if (isGet) { lines.push(`import { useQuery, type QueryClient } from '@tanstack/vue-query'`) - lines.push(`import type { UseQueryOptions } from '@tanstack/vue-query'`) + lines.push(`import type { DefaultError, EnsureQueryDataOptions, FetchQueryOptions, QueryKey, UseQueryOptions } from '@tanstack/vue-query'`) } else { lines.push(`import { useMutation } from '@tanstack/vue-query'`) @@ -294,7 +297,7 @@ export function generateEndpointFile(ep: EndpointInfo, hasTanstack: boolean): st lines.push(`import { _apiFetch } from '../schema-api-fetch'`) if (isGet && hasTanstack) { lines.push(`import { computed, toValue } from 'vue'`) - lines.push(`import type { MaybeRefOrGetter } from 'vue'`) + lines.push(`import type { MaybeRefOrGetter, Ref } from 'vue'`) } // Schema imports @@ -344,9 +347,6 @@ export function generateEndpointFile(ep: EndpointInfo, hasTanstack: boolean): st return reactive && hasTanstack ? `MaybeRefOrGetter<${inner} | null>` : inner } - // Helper: access options value (reactive vs plain) - const tv = (expr: string) => (hasTanstack ? `toValue(${expr})` : expr) - // Type generic for _apiFetch / useFetch — populated when output schema is available const fetchG = sv.output ? '<_DO>' : '' @@ -363,13 +363,7 @@ export function generateEndpointFile(ep: EndpointInfo, hasTanstack: boolean): st ? `options${hasDynamicParams ? '' : '?'}: ${plainOptionsType}` : null - // Helpers to access params/query from (possibly reactive) options - const optAccess = (field: string) => - hasDynamicParams - ? `${tv('options')}.${field}` - : `${tv('options')}?.${field}` - // urlCall uses toValue (reactive, for useQuery); fetchUrlCall is plain (fetchQuery/$fetch) - const urlCall = hasDynamicParams ? `_url(${optAccess('params')})` : `_url()` + // Plain options: useQuery unwraps its reactive options before calling _get const fetchUrlCall = hasDynamicParams ? `_url(options.params)` : `_url()` // Key parts for queryKey @@ -379,11 +373,28 @@ export function generateEndpointFile(ep: EndpointInfo, hasTanstack: boolean): st const buildKeyCall = (paramsExpr: string | null, queryExpr: string | null) => `_key(${[paramsExpr, queryExpr].filter(Boolean).join(', ')})` + // The one fetch every GET method goes through. Its resolved type is the + // query's data type (`_QD`), whether or not an output schema types it. + lines.push( + `const _get = (${plainDecl ?? ''}) => _apiFetch${fetchG}(${fetchUrlCall}${queryType ? `, { query: options${hasDynamicParams ? '' : '?'}.query }` : ''})`, + ) + lines.push(``) + if (hasTanstack) { + // Option types bound to the endpoint's data type, so that TanStack infers + // `TData` from `select` the way its own `useQuery` does. `UseQueryOptions` + // is a `MaybeRef<…>`: `Omit` over that union would keep no key at all, so + // the plain object variant is taken out of it first. + lines.push(`type _QD = Awaited>`) + lines.push(`type _UQO = Omit, Ref>, 'queryKey' | 'queryFn'>`) + lines.push(`type _FQO = Omit, 'queryKey' | 'queryFn'>`) + lines.push(`type _EQO = Omit, 'queryKey' | 'queryFn'>`) + lines.push(``) + // useQuery (reactive options via MaybeRefOrGetter) const uqArgs = [ reactiveDecl ?? null, - `queryOptions?: Omit`, + `queryOptions?: _UQO`, ].filter(Boolean).join(', ') // When the endpoint has options (params/query), support null to disable the query. @@ -395,13 +406,11 @@ export function generateEndpointFile(ep: EndpointInfo, hasTanstack: boolean): st needsParams ? `_o.params` : null, queryType ? `_o?.query` : null, ) - const urlCallWithVar = hasDynamicParams ? `_url(_o.params)` : `_url()` - const queryFnBody = `{ const _o = toValue(options)!; return _apiFetch${fetchG}(${urlCallWithVar}${queryType ? `, { query: _o?.query }` : ''}) }` methods.push( - ` useQuery: (${uqArgs}) => useQuery({\n` + ` useQuery: (${uqArgs}) => useQuery<_QD, DefaultError, TData, QueryKey>({\n` + ` queryKey: computed(() => { const _o = toValue(options); return _o !== null ? ${uqKeyCallWithVar} : [] }),\n` - + ` queryFn: () => ${queryFnBody},\n` + + ` queryFn: () => _get(toValue(options)!),\n` + ` ...queryOptions,\n` + ` enabled: computed(() => toValue(options) !== null),\n` + ` }),`, @@ -411,33 +420,35 @@ export function generateEndpointFile(ep: EndpointInfo, hasTanstack: boolean): st const uqKeyCall = buildKeyCall(null, null) methods.push( - ` useQuery: (${uqArgs}) => useQuery({\n` + ` useQuery: (${uqArgs}) => useQuery<_QD, DefaultError, TData, QueryKey>({\n` + ` queryKey: computed(() => ${uqKeyCall}),\n` - + ` queryFn: () => _apiFetch${fetchG}(${urlCall}),\n` + + ` queryFn: () => _get(),\n` + ` ...queryOptions,\n` + ` }),`, ) } - // fetchQuery (plain options, uses queryClient) - const fqArgs = [ + // fetchQuery / ensureQueryData (plain options, use queryClient). Typed on + // the endpoint's data, without `select`: neither of them applies one. + const fqArgs = (optionsType: string) => [ `queryClient: QueryClient`, plainDecl ?? null, - `queryOptions?: Omit`, + `queryOptions?: ${optionsType}`, ].filter(Boolean).join(', ') + const getCall = plainDecl ? `_get(options)` : `_get()` methods.push( - ` fetchQuery: (${fqArgs}) => queryClient.fetchQuery({\n` + ` fetchQuery: (${fqArgs('_FQO')}) => queryClient.fetchQuery<_QD, DefaultError, _QD, QueryKey>({\n` + ` queryKey: ${buildKeyCall(keyParamsPart, keyQueryPart)},\n` - + ` queryFn: () => _apiFetch${fetchG}(${fetchUrlCall}${queryType ? `, { query: options${hasDynamicParams ? '' : '?'}.query }` : ''}),\n` + + ` queryFn: () => ${getCall},\n` + ` ...queryOptions,\n` + ` }),`, ) methods.push( - ` ensureQueryData: (${fqArgs}) => queryClient.ensureQueryData({\n` + ` ensureQueryData: (${fqArgs('_EQO')}) => queryClient.ensureQueryData<_QD, DefaultError, _QD, QueryKey>({\n` + ` queryKey: ${buildKeyCall(keyParamsPart, keyQueryPart)},\n` - + ` queryFn: () => _apiFetch${fetchG}(${fetchUrlCall}${queryType ? `, { query: options${hasDynamicParams ? '' : '?'}.query }` : ''}),\n` + + ` queryFn: () => ${getCall},\n` + ` ...queryOptions,\n` + ` }),`, ) @@ -456,10 +467,7 @@ export function generateEndpointFile(ep: EndpointInfo, hasTanstack: boolean): st ) // $fetch (always generated) - const fetchArgs = plainDecl ?? null - methods.push( - ` $fetch: (${fetchArgs ?? ''}) => _apiFetch${fetchG}(${fetchUrlCall}${queryType ? `, { query: options${hasDynamicParams ? '' : '?'}.query }` : ''}),`, - ) + methods.push(` $fetch: _get,`) // key — same required/optional rule as useQuery but plain (not reactive) const keyArgs = plainDecl ?? null