Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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

Expand All @@ -55,3 +55,6 @@ jobs:

- name: Test
run: npm run test

- name: Typecheck
run: npm run test:types
8 changes: 7 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<UseQueryOptions, 'queryKey' | 'queryFn'>
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<number | undefined>
)

// Imperative fetch (TanStack) — for prefetch or event handlers
Expand Down
32 changes: 32 additions & 0 deletions playground/app.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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' },
Expand Down
72 changes: 40 additions & 32 deletions src/generator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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'`)
Expand All @@ -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
Expand Down Expand Up @@ -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>' : ''

Expand All @@ -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
Expand All @@ -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<ReturnType<typeof _get>>`)
lines.push(`type _UQO<TData> = Omit<Exclude<UseQueryOptions<_QD, DefaultError, TData, _QD, QueryKey>, Ref<unknown>>, 'queryKey' | 'queryFn'>`)
lines.push(`type _FQO = Omit<FetchQueryOptions<_QD, DefaultError, _QD, QueryKey>, 'queryKey' | 'queryFn'>`)
lines.push(`type _EQO = Omit<EnsureQueryDataOptions<_QD, DefaultError, _QD, QueryKey>, 'queryKey' | 'queryFn'>`)
lines.push(``)

// useQuery (reactive options via MaybeRefOrGetter)
const uqArgs = [
reactiveDecl ?? null,
`queryOptions?: Omit<UseQueryOptions, 'queryKey' | 'queryFn'>`,
`queryOptions?: _UQO<TData>`,
].filter(Boolean).join(', ')

// When the endpoint has options (params/query), support null to disable the query.
Expand All @@ -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: <TData = _QD>(${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`
+ ` }),`,
Expand All @@ -411,33 +420,35 @@ export function generateEndpointFile(ep: EndpointInfo, hasTanstack: boolean): st
const uqKeyCall = buildKeyCall(null, null)

methods.push(
` useQuery: (${uqArgs}) => useQuery({\n`
` useQuery: <TData = _QD>(${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<UseQueryOptions, 'queryKey' | 'queryFn'>`,
`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`
+ ` }),`,
)
Expand All @@ -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
Expand Down
Loading