jvinhit//lab

Search posts

Type to search across journal entries.

navigate open esc close

TanStack Query · Phần 17 — Type-safety đỉnh cao

Typing toàn trình từ queryFn tới component: queryOptions & DataTag suy luận type, generic query factory tái dùng, skipToken thay cho enabled, kết hợp zod infer, và xoá sạch as khỏi lớp data.

React Query có type rất tốt sẵn — nhưng để type thực sự chảy mượt từ tầng API tới component (không một any, không một as), bạn cần vài kỹ thuật. Phần này gom toàn bộ “đồ nghề” type-level cho một codebase React Query mà compiler bắt lỗi giúp bạn thay vì runtime. Đây cũng là nơi tôn trọng đúng prohibition của dự án: không any, không as trừ sau khi validate runtime bằng zod.


1. Luồng suy luận type: từ queryFn tới data

Nguồn sự thật của type là giá trị trả về của queryFn. Định đúng đó, data ở component tự có type — không generic, không as:

// fetchCustomers: () => Promise<Customer[]>
useQuery({ queryKey: ['customers'], queryFn: fetchCustomers });
// data: Customer[] | undefined — tự suy ra

TanStack Query đọc ReturnType của queryFn (đã gỡ Promise) ra một type nội bộ gọi là TQueryFnData, rồi suy ra TData (kiểu của data) từ đó. Toàn bộ chuỗi như sau:

queryFn: () => Promise<Customer[]>

   │  unwrap Promise → ReturnType

TQueryFnData = Customer[]                  ← "raw" type từ network

   ├── KHÔNG có select ──────► TData = Customer[]

   └── CÓ select: (d) => d.length ─► TData = ReturnType<select> = number


                              useQuery(...).data : TData | undefined

Bảng các generic của useQuery và nguồn suy luận:

GenericÝ nghĩaSuy ra từ đâu
TQueryFnDataType thô queryFn trả vềReturnType của queryFn
TErrorType của errorRegister['defaultError'] (mặc định Error)
TDataType của dataselect nếu có, ngược lại TQueryFnData
TQueryKeyType của queryKey (const tuple)Chính queryKey bạn truyền

Đừng viết useQuery<Customer[]>(...) thủ công. Truyền generic tay chỉ ép TQueryFnData, nhưng làm hỏng suy luận cho select, error, initialData, và không kiểm chứng queryFn thật sự trả Customer[]. Hãy để queryFn “kể” type. Nếu cần ép kiểu, ép ở queryFn (qua zod parse) — chứ không ở hook.


2. queryOptions mang type đi xuyên suốt — DataTag

queryOptions (Phần 3) không chỉ để tái dùng — nó gắn một DataTag (một phantom type ẩn) vào queryKey, giúp mọi API nhận key đó biết kiểu data từ chính key:

import { queryOptions } from '@tanstack/react-query';

export const customerQuery = (id: string) =>
  queryOptions({
    queryKey: ['customers', 'detail', id] as const,
    queryFn: () => fetchCustomer(id),
  });

// Nhờ DataTag gắn trong queryKey, dòng này biết kết quả là Customer | undefined:
const cached = queryClient.getQueryData(customerQuery(id).queryKey);
//    ^? Customer | undefined  — KHÔNG cần generic, KHÔNG cần `as`

Điều mạnh nhất: một queryOptions chảy type ra mọi điểm dùng key, end-to-end:

            customerQuery(id)  →  { queryKey: DataTag<Key, Customer>, queryFn }

   ┌───────────────┼────────────────────┬──────────────────────┐
   ▼               ▼                    ▼                       ▼
useQuery(...)   prefetchQuery(...)   getQueryData(key)     setQueryData(key, x)
 data:           (void, nhưng        → Customer|undefined   x phải là
 Customer|undef   queryFn type-check)                       Customer (hoặc updater)

Bảng đối chiếu — cùng một queryOptions, type được giữ ở đâu:

Điểm dùngAPIType được giữ
ComponentuseQuery(customerQuery(id))data: Customer | undefined
SuspenseuseSuspenseQuery(customerQuery(id))data: Customer (non-undefined)
PrefetchqueryClient.prefetchQuery(customerQuery(id))queryFn được type-check
Đọc cachegetQueryData(customerQuery(id).queryKey)trả Customer | undefined
Ghi cachesetQueryData(customerQuery(id).queryKey, x)x phải là Customer

So với cách cũ getQueryData<Customer>(['customers','detail',id]) (phải tự nhắc type tự gõ key đúng), queryOptions cho cả type lẫn key từ một nguồn — lệch key là compiler la ngay.


3. Typed query keys: const tuple & key factory có type

getQueryData/setQueryData chỉ suy được type khi queryKeyconst tuple (literal), không phải string[] rộng. Khác biệt nằm ở as const:

const k1 = ['customers', 'detail', id];            // type: string[]  ❌ quá rộng
const k2 = ['customers', 'detail', id] as const;   // type: readonly ['customers', 'detail', string]  ✅

Key factory nên trả const tuple ở mọi nhánh để filter (queryKey: [...]) khớp type:

export const customerKeys = {
  all: ['customers'] as const,
  lists: () => [...customerKeys.all, 'list'] as const,
  list: (filters: CustomerFilters) => [...customerKeys.lists(), filters] as const,
  details: () => [...customerKeys.all, 'detail'] as const,
  detail: (id: string) => [...customerKeys.details(), id] as const,
};

// Lấy type của một key để dùng lại nơi khác:
type DetailKey = ReturnType<typeof customerKeys.detail>;
//   = readonly ['customers', 'detail', string]
Cách viết keyType suy raHệ quả
['customers', id]string[]getQueryData trả unknown, filter lỏng
['customers', id] as constreadonly ['customers', string]Type chuẩn, khớp DataTag
customerKeys.detail(id) (factory)const tupleMột nguồn key, không gõ tay sai

Quy tắc: factory + as const + queryOptions là bộ ba bất ly thân. Thiếu as const, DataTag không bám được vào key và bạn rơi về unknown/as.


4. Generic query factory — tái dùng có type

Khi có nhiều resource cùng pattern (list/detail), một factory generic giảm lặp mà vẫn giữ type:

import { queryOptions } from '@tanstack/react-query';

function createResourceQueries<T>(resource: string, fetchList: () => Promise<T[]>, fetchOne: (id: string) => Promise<T>) {
  const keys = {
    all: [resource] as const,
    lists: () => [resource, 'list'] as const,
    detail: (id: string) => [resource, 'detail', id] as const,
  };
  return {
    keys,
    list: () => queryOptions({ queryKey: keys.lists(), queryFn: fetchList }),
    detail: (id: string) => queryOptions({ queryKey: keys.detail(id), queryFn: () => fetchOne(id) }),
  };
}

// Dùng — type T chảy xuyên suốt:
export const customerQueries = createResourceQueries<Customer>('customers', fetchCustomers, fetchCustomer);
// customerQueries.detail(id) → queryOptions với data: Customer

Mỗi resource mới chỉ cần một dòng, và type của data được giữ nguyên end-to-end nhờ generic <T>.


5. zod ở biên: parse cho runtime, z.infer cho static

Prohibition của dự án cho phép as chỉ sau khi validate runtime. zod chính là công cụ đó: nó nhận unknown và trả ra một type đã được chứng minh tại runtime — vừa khử any, vừa khử as:

import { z } from 'zod';

export const customerSchema = z.object({
  id: z.string(),
  name: z.string(),
  orderCount: z.number(),
  tier: z.enum(['free', 'pro', 'enterprise']),
});
export type Customer = z.infer<typeof customerSchema>; // type SUY RA từ schema

export async function fetchCustomer(id: string): Promise<Customer> {
  const res = await fetch(`/api/customers/${id}`);
  if (!res.ok) throw new ApiError(`HTTP ${res.status}`, res.status);
  const json: unknown = await res.json(); // unknown, KHÔNG any
  return customerSchema.parse(json);      // parse → Customer đã kiểm chứng
}

type Customer = z.infer<...> giữ type và validator đồng bộ tuyệt đối: đổi schema, type tự đổi theo. Không bao giờ định nghĩa interface Customer song song với schema — chúng sẽ trôi xa nhau.

Cách lấy data thôTypeAn toàn?
await res.json()anyany lây lan khắp nơi
(await res.json()) as CustomerCustomeras mù — runtime có thể sai hình
schema.parse(await res.json())Customer✅ runtime + static khớp nhau
schema.safeParse(json){ success; data | error }✅ xử lý lỗi không throw

Mẹo: z.infer cho type output (sau transform). Nếu schema có .transform() hay .default(), dùng z.input<typeof schema> cho dữ liệu vào và z.infer/z.output cho dữ liệu ra để không lẫn lộn hai đầu.


6. Register augmentation: typed error, queryMeta, mutationMeta

Mặc định error trong React Query là Error — đủ rộng để bạn không đọc được error.status. Nếu queryFn luôn throw ApiError, khai báo qua module augmentation Register để mọi error toàn app có đúng type:

// src/types/react-query.d.ts
import '@tanstack/react-query';

declare module '@tanstack/react-query' {
  interface Register {
    defaultError: ApiError;                       // error: ApiError, không phải Error
    queryMeta: { silent?: boolean; invalidates?: readonly unknown[][] };
    mutationMeta: { successMessage?: string; invalidates?: readonly unknown[][] };
  }
}
function Detail() {
  const { error } = useQuery(customerQuery(id));
  // error: ApiError | null — đọc thẳng error.status, không cần instanceof/as
  if (error) return <p>Lỗi {error.status}</p>;
}
Khoá trong RegisterType hoá cái gìTrước khi khai báo
defaultErrorerror của mọi query & mutationError
queryMetaoptions.meta của queryRecord<string, unknown> | undefined
mutationMetaoptions.meta của mutationRecord<string, unknown> | undefined

Vì sao toàn cục? Register là một interface “mở” mà chính thư viện đọc khi suy TError/meta. Augment nó một lần ở file .d.ts là cả codebase được lợi — không phải truyền <…, ApiError> ở từng hook. Đây là cách duy nhất biến error từ Error rộng thành type miền của bạn mà không dùng as.


7. skipToken: disabled query type-safe (vs enabled narrowing)

Pattern enabled: !!id (Phần 3) tắt query khi chưa có tham số, nhưng nó không làm hẹp type: queryFn vẫn thấy id: string | undefined, buộc bạn id! (non-null assertion — gần như as). skipToken giải quyết triệt để:

import { skipToken, useQuery } from '@tanstack/react-query';

function useCustomer(id: string | undefined) {
  return useQuery({
    queryKey: ['customers', 'detail', id] as const,
    // id undefined → skipToken: query bị tắt VÀ type của queryFn được giữ.
    queryFn: id === undefined ? skipToken : () => fetchCustomer(id),
    // Trong nhánh else, `id` đã hẹp về `string` — không cần `id!`.
  });
}
Tiêu chíenabled: !!idskipToken
Tắt query khi thiếu tham số
Narrow idstring trong queryFn❌ (vẫn string | undefined)
Cần id! / as✅ phải dùng❌ không cần
Dùng được với useSuspenseQuery❌ (không có enabled)✅ (skipToken treo Suspense)

Quy tắc: dùng skipToken cho dependent query (query phụ thuộc tham số có thể chưa có). Để dành enabled cho điều kiện boolean thuần (vd enabled: isLoggedIn) không liên quan tới narrow type.


8. Narrowing data: discriminated status & initialData

data của useQueryTData | undefined vì lúc đầu chưa có. Có hai cách hợp lệ để TypeScript hiểu khi nào data chắc chắn tồn tại.

Cách 1 — discriminated union qua status. React Query thiết kế kết quả thành union phân biệt theo status, nên kiểm tra status/isSuccess sẽ narrow data:

const query = useQuery(customerQuery(id));
if (query.isPending) return <Spinner />;
if (query.isError)   return <Err e={query.error} />;   // error: ApiError
// Tới đây status === 'success' → data đã narrow:
return <Profile customer={query.data} />;               // data: Customer (KHÔNG undefined)
status: 'pending'  → data: undefined,  error: null
status: 'error'    → data: undefined,  error: TError
status: 'success'  → data: TData,      error: null   ← chỉ ở đây data chắc chắn có

Cách 2 — initialData biến data thành non-undefined. Khi truyền initialData (không phải hàm trả undefined), TanStack chọn overload DefinedInitialDataOptionsdata mất nhánh undefined ngay từ render đầu:

useQuery({
  ...customerQuery(id),
  initialData: cachedCustomer,   // Customer (không undefined)
});
// data: Customer — đã non-undefined, không cần kiểm tra isPending
Tình huốngOverload chọndata
Không initialDataUndefinedInitialDataOptionsTData | undefined
initialData: valueDefinedInitialDataOptionsTData
initialData: () => maybeUndefUndefinedInitialDataOptionsTData | undefined
useSuspenseQueryTData (Suspense đảm bảo đã có)

Gotcha: initialData: () => readFromCache() mà hàm có thể trả undefined thì overload “defined” không kích hoạt — data lại là TData | undefined. Muốn non-undefined, truyền giá trị trực tiếp hoặc đảm bảo hàm luôn trả TData.


9. Typing select, custom hook & UseQueryResult

select đổi type của data: TData thành kiểu trả về của select, còn data thô (TQueryFnData) giữ nguyên cho cache:

const count = useQuery({
  ...customerQuery(id),
  select: (c) => c.orderCount,   // (c: Customer) => number
});
// count.data: number | undefined  — TData giờ là number

Khi viết custom hook, đừng “đóng băng” return type — hãy để nó suy ra hoặc dùng đúng type tiện ích của thư viện:

import type { UseQueryResult, UseMutationResult } from '@tanstack/react-query';

// Cách A — để return type tự suy ra (gọn nhất, luôn đúng):
export function useCustomer(id: string) {
  return useQuery(customerQuery(id));
}

// Cách B — annotate tường minh khi cần export ổn định cho lib:
export function useCustomerExplicit(id: string): UseQueryResult<Customer, ApiError> {
  return useQuery(customerQuery(id));
}

// Mutation tương tự:
export function useUpdateCustomer(): UseMutationResult<Customer, ApiError, UpdateInput> {
  return useMutation(updateCustomerMutation());
}
Type tiện íchGenericDùng khi
UseQueryResult<TData, TError>data + errorAnnotate return của custom query hook
UseMutationResult<TData, TError, TVars, TCtx>4 genericAnnotate return của mutation hook
UseSuspenseQueryResult<TData, TError>data non-undefinedHook bọc useSuspenseQuery
QueryObserverOptions / DefinedInitialDataOptionsoptionsNhận options từ bên ngoài có type

Vì sao thường chọn Cách A? Để TypeScript suy ra return type giữ mọi field (isPending, dataUpdatedAt, fetchStatus…) luôn khớp phiên bản thư viện. Chỉ annotate tường minh (Cách B) khi bạn publish một thư viện và muốn API ổn định, không phụ thuộc nội bộ React Query.


10. Type cho mutation: mutationOptions, biến & ngữ cảnh

v5 có mutationOptions tương tự queryOptions để gói mutation có type:

import { mutationOptions } from '@tanstack/react-query';

export const updateCustomerMutation = () =>
  mutationOptions({
    mutationFn: updateCustomer,                 // (vars: UpdateInput) => Promise<Customer>
    meta: { successMessage: 'Đã lưu' },
  });

// 4 generic của useMutation: <TData, TError, TVariables, TContext>
useMutation({
  mutationFn: updateCustomer,
  onMutate: async (vars): Promise<{ previous: Customer | undefined }> => {
    const previous = queryClient.getQueryData(customerKeys.detail(vars.id));
    return { previous };                        // TContext = { previous: Customer | undefined }
  },
  onError: (_e, _vars, ctx) => {
    // ctx có type { previous: Customer | undefined } — không undefined-mù
    if (ctx?.previous) queryClient.setQueryData(customerKeys.detail(ctx.previous.id), ctx.previous);
  },
});
GenericSuy ra từKhi nào annotate tay
TVariablestham số của mutationFnHiếm — để suy ra
TDataReturnType của mutationFnHiếm — để suy ra
TErrorRegister['defaultError']Không (đặt qua Register)
TContextreturn của onMutateAnnotate return của onMutate

Mẹo quan trọng: annotate giá trị trả về của onMutate (Promise<{...}>) để TContext được suy đúng, nhờ đó ctx trong onError/onSettled có type chuẩn thay vì unknown. Đây là chỗ duy nhất mutation thật sự cần “giúp” compiler.


11. Gotchas thường gặp

GotchaTriệu chứngCách đúng
as Customer lên responseruntime sai hình mà compiler imschema.parse(json)queryFn
await res.json() để anyany lây sang data, mất typeconst json: unknown rồi parse
Generic tay useQuery<T>()select/error mất suy luậnĐể queryFn định type
Key không as constgetQueryData trả unknownFactory + as const + queryOptions
error để unknown/Errorkhông đọc được error.statusRegister.defaultError = ApiError
enabled: !!id rồi id!non-null assertion trá hình asskipToken để narrow
Đọc data khi isPendingdataundefinedNarrow qua status/isSuccess
initialData: () => undefined?tưởng non-undefined nhưng khôngtruyền giá trị trực tiếp
Tự định interface + schema riêngtype & validator trôi xa nhautype = z.infer<typeof schema>
Đóng băng return custom hook sailệch field theo versionđể suy ra hoặc dùng UseQueryResult

12. Recipe: module API feature đầy đủ type

Gom tất cả vào một module feature — any/as bằng 0, type chảy từ network tới component:

// features/customers/api.ts
import { z } from 'zod';
import { queryOptions, mutationOptions } from '@tanstack/react-query';
import { apiFetch } from '@/lib/api';   // (path) => Promise<unknown>

// 1) Schema = nguồn type duy nhất
export const customerSchema = z.object({
  id: z.string(),
  name: z.string(),
  tier: z.enum(['free', 'pro', 'enterprise']),
});
export type Customer = z.infer<typeof customerSchema>;
export const updateInputSchema = customerSchema.pick({ name: true, tier: true });
export type UpdateInput = z.infer<typeof updateInputSchema>;

// 2) Key factory — const tuple ở mọi nhánh
export const customerKeys = {
  all: ['customers'] as const,
  detail: (id: string) => [...customerKeys.all, 'detail', id] as const,
};

// 3) Fetchers — unknown vào, parse ra
export async function fetchCustomer(id: string): Promise<Customer> {
  return customerSchema.parse(await apiFetch(`/customers/${id}`));
}

// 4) queryOptions/mutationOptions — type đi xuyên suốt
export const customerQuery = (id: string) =>
  queryOptions({ queryKey: customerKeys.detail(id), queryFn: () => fetchCustomer(id) });

export const updateCustomerMutation = (id: string) =>
  mutationOptions({
    mutationFn: (vars: UpdateInput) =>
      apiFetch(`/customers/${id}`, { method: 'PATCH', body: vars }).then((d) => customerSchema.parse(d)),
  });
// features/customers/CustomerCard.tsx
import { skipToken, useQuery } from '@tanstack/react-query';
import { customerQuery } from './api';

export function CustomerCard({ id }: { id: string | undefined }) {
  const q = useQuery(
    id === undefined
      ? { ...customerQuery(''), queryFn: skipToken }
      : customerQuery(id),
  );
  if (q.isPending) return <Spinner />;
  if (q.isError)   return <p>Lỗi {q.error.status}</p>;   // error: ApiError nhờ Register
  return <h2>{q.data.name}</h2>;                          // data: Customer, không undefined/as
}

Toàn bộ chuỗi — schema → z.infer → fetcher parsequeryOptionsuseQuery → narrow status — không có một any hay as nào. Đổi schema một chỗ, compiler dẫn bạn tới mọi điểm cần sửa.


13. Bài tập

1. Vì sao không nên truyền generic tay vào useQuery<T>() mà nên để queryFn định type?

Lời giải

Truyền useQuery<T>() chỉ ép kiểu TQueryFnData/data nhưng phá suy luận của select, error, initialData, và không kiểm chứng queryFn thực sự trả T. Để queryFn “kể” type giữ một nguồn sự thật duy nhất và compiler kiểm tra toàn bộ chuỗi. Nếu cần ép, ép ở queryFn (qua zod parse) chứ không ở hook.

2. skipToken hơn enabled: !!id ở điểm nào về type?

Lời giải

enabled: !!id tắt query nhưng không narrow type — queryFn vẫn thấy id: string | undefined, buộc id!. skipToken vừa tắt query vừa cho TypeScript narrow id về string trong nhánh có hàm, xoá nhu cầu non-null assertion/as; ngoài ra skipToken còn dùng được với useSuspenseQuery (vốn không có enabled).

3. Vì sao nên type T = z.infer<typeof schema> thay vì khai báo interface T riêng?

Lời giải

z.infer ràng buộc type với validator: schema là nguồn sự thật duy nhất, đổi schema thì type tự cập nhật. Một interface riêng có thể lệch khỏi schema theo thời gian, dẫn tới compiler tin một đằng còn runtime validate một nẻo. Một nguồn → không lệch.

4. Cùng một queryOptions, hãy kể ba điểm dùng mà type được giữ tự động và type tương ứng ở mỗi điểm.

Lời giải

Nhờ DataTag gắn trong queryKey: (1) useQuery(customerQuery(id))data: Customer | undefined; (2) getQueryData(customerQuery(id).queryKey) → trả Customer | undefined; (3) setQueryData(customerQuery(id).queryKey, x) → ép x phải là Customer (hoặc updater (old) => Customer). Ngoài ra prefetchQuery/useSuspenseQuery cũng nhận type từ cùng một nguồn — useSuspenseQuery cho data: Customer non-undefined.

5. Khi nào data của useQuery mất nhánh | undefined? Nêu hai cách hợp lệ.

Lời giải

(1) Sau khi narrow qua status: trong nhánh isSuccess/status === 'success', dataTData. (2) Truyền initialData bằng giá trị (không phải hàm trả undefined) → overload DefinedInitialDataOptions kích hoạt, data: TData ngay từ render đầu. Ngoài ra useSuspenseQuery luôn cho data: TData vì Suspense đảm bảo đã resolve.

6. Vì sao chỉ cần annotate return của onMutate là đủ để toàn bộ optimistic flow có type?

Lời giải

TContext được suy ra từ giá trị trả về của onMutate. Annotate onMutate: async (vars): Promise<{ previous: Customer \| undefined }> => … cố định TContext, nên ctx trong onError/onSettled có đúng type { previous: Customer \| undefined } thay vì unknown — đọc ctx.previous an toàn để rollback.

Nâng cao: Refactor một resource thật sang module feature ở mục 12: schema + z.infer, key factory as const, queryOptions/mutationOptions, bật Register.defaultError = ApiError, thay enabled: !!id bằng skipToken, rồi xoá mọi as/! còn sót trong lớp data. Chạy astro check/tsc --noEmit xác nhận 0 lỗi và 0 assertion.


Tóm tắt

  • Luồng suy luận: queryFn định TQueryFnDataTData (select nếu có) → data: TData | undefined. Đừng truyền generic tay vào useQuery.
  • queryOptions gắn DataTag vào key, mang type end-to-end qua useQuery/prefetch/getQueryData/setQueryData — không cần as.
  • Typed key: factory + as const (const tuple) là điều kiện để DataTag bám và getQueryData có type; generic factory tái dùng pattern list/detail giữ type.
  • zod ở biên: unknownschema.parse → type đã kiểm chứng; type = z.infer<schema> giữ type & validator đồng bộ — đây là nơi as hợp lệ.
  • Register augmentation: defaultError type hoá error (đọc error.status), queryMeta/mutationMeta type hoá meta toàn cục.
  • skipToken thay enabled: !!id để vừa tắt query vừa narrow id về string (xoá !/as); còn dùng được với useSuspenseQuery.
  • Narrow data: discriminated status/isSuccess hoặc initialData bằng giá trị (DefinedInitialDataOptions) làm data mất nhánh undefined.
  • Typing hook: để return tự suy ra hoặc dùng UseQueryResult/UseMutationResult; select đổi TData; annotate return của onMutate để TContext chuẩn.

Phần tiếp theo

Phần 18 — Kiến trúc production & Migration (Capstone): gói toàn bộ 17 phần thành một kiến trúc query layer theo feature, quy ước loading/error nhất quán, devtools & logging cho production, lộ trình migrate v4 → v5, so sánh nhanh với RTK Query/SWR, và một checklist “production-ready” để tự chấm điểm dự án.