Skip to content

unleash-the-magic-of-type-script-with-ts-rest

前後端不再打架,讓 ts-rest 發揮 TypeScript 的魔法吧!(實務篇)

距離上次概念篇也快一年了,時間過得真快。(´・ω・`)

本文延續概念篇內容,紀錄一些實作筆記,大家若有更好的做法還請不吝指教。(*´∀`)~♥

技術堆疊以 Vue、NestJS 為例,相關套件版本如下:

  • @nestjs/core:11.1.5
  • @ts-rest/core:3.52.1
  • @ts-rest/nest:3.52.1
  • zod:3.25.76

TIP

ts-rest 預計 ^3.53.0 開始支援 Standard Schema 驗證,只要支援此規範的驗證器(例:Zod v4ValibotArkType)的驗證器都可以用來定義合約資料,不限於 Zod 了!✧⁑。٩(ˊᗜˋ*)و✧⁕。

本文分成 3 個主要部分:

  1. 通用概念

    不分前後端的注意事項與心得

  2. 前端

    前端 ts-rest 應用,包含初始化 Client、狀態與結果資料、表單資料驗證等

  3. 後端

    後端 ts-rest 應用,包含定義 API 合約、驗證資料、處理請求等

讓我們開始吧!♪( ◜ω◝و(و

不定期更新

本文為 ts-rest 應用筆記,會隨著時間推移而變化、更新,甚至過時,我會盡快更新,還請大家多多包涵。(´,,•ω•,,)

通用概念

jsonQuery

ts-rest 提供的 jsonQuery 參數可以讓你輕鬆處理 REST API 的 query string。

一般來說 REST API 的 query string 會被視為字串,其中數字、布林與矩陣可能會出現歧異。

啟用 jsonQuery 就可以讓 query string 中的資料使用 JSON 形式發送,這樣就可以避免以上問題。

舉個栗子。( ´ ▽ ` )ノ🌰

數字與布林

若 URL 為 /posts?take=10&draft=false,伺服器會取得 { take: "10", draft: "false" },可以看到內容都是字串。

你可能會說:Zod 可以用 z.coerce.number()z.coerce.boolean() 轉換啊?

的確可以轉換沒錯,但這裡有個隱藏的小地雷,就是字串的 "true""false",經過 z.coerce.boolean() 都會變成 true╭(°A ,°`)╮

Zod v4 之 z.stringbool()

感謝社群的一隻狐狸大大補充

Zod v4 新增了 stringbool,可以正確處理字串的布林值,v4 讚讚!(/≧▽≦)/

當然可以自定義轉換器解決,不過若啟用 jsonQuery(前端後端都要開),伺服器會取得 { take: 10, draft: false },就不用特別轉換了。(≖‿ゝ≖)✧

矩陣

若 URL 為 /posts?tags=js&tags=ts,伺服器會拿到 ['js', 'ts'],但若 tags 只有一個呢?

URL 變為 /posts?tags=cod

這樣伺服器取到的參數可能會變成 { tags: 'cod' },而不是 { tags: ['cod'] }Σ(ˊДˋ;)

若啟用 jsonQuery,則 URL 會變成 /posts?tags=["js","ts"],就沒有以上分歧的問題了。✧⁑。٩(ˊᗜˋ*)و✧⁕。


路人:「如果 client 端不是使用 ts-rest client,是不是可能有一樣的問題?」

鱈魚:「你說的沒錯 ...(›´ω`‹ ),如果使用端不是用 JSON Query Parameters 發送,還是要乖乖的把合約內的驗證器與轉換器寫好寫滿了 ( ˘・з・)

ClientInfer、ServerInfer 傻傻分不清楚?

ts-rest 本身提供了很方便的 Type Helper 工具,可以輕鬆提取合約內的型別。

不過有分 ClientInfer 與 ServerInfer,看的出來一個是 Client 視角,一個是 Server 視角,所以具體差在哪呢?(*´・д・)

簡單來說就是進去與出來的資料型別不同,假設合約這麼寫:

ts
export const collectionDataContract = contract.router({
  find: {
    method: 'GET',
    path: '/v1/collection-data',
    query: z.object({
      age: z.string().transform(Number),
    }),
    responses: {
      200: collectionDataSchema.array(),
    },
    summary: '取得 collection-data',
  },
})

可以看出 age 輸入型別是 string,但輸出型別是 number

現在讓我們實際看看 ClientInfer 與 ServerInfer 取出來的 age

ts
// type 為 string
type ClientInput = ClientInferRequest<typeof collectionDataContract>['find']['query']['age']
// type 為 number
type ServerInput = ServerInferRequest<typeof collectionDataContract>['find']['query']['age']

對 Request 來說,client 是提供端(輸入至合約),而 server 是接收端(取得合約輸出)。

所以得到 ClientInputstring,而 ServerInputnumber

不難發現只有在輸入與輸出型別不同才會有明顯差別,否則兩者型別會相同。

以上就是 ClientInfer 與 ServerInfer 具體差別,有沒有比較清楚了呢?ԅ(´∀` ԅ)

前端

初始化 Client

如同文件描述,使用 initClient 即可以將合約轉換成可直接呼叫的 Client。

可以考慮多一層封裝,提供基礎設定值:

ts
import type { AppRouter } from '@ts-rest/core'
import { initClient } from '@ts-rest/core'

const baseUrl = import.meta.env.VITE_API_BASE_URL ?? ''

export function useClient<T extends AppRouter>(
  router: T,
  options?: Parameters<typeof initClient>[1],
) {
  return initClient(router, {
    baseUrl,
    baseHeaders: {},
    jsonQuery: true,
    ...options,
  })
}

Client 會在網頁中到處出現,希望不要重複建立,避免不必要的記憶體浪費。

可以存在某個變數並 export 給其他檔案使用,不過這樣每次微調 options 都要建立共用變數有點麻煩(命名好難 (。-`ω´-)),使用 memoization 方式處理會更簡單。

甚麼是 memoization

先前的文章曾經聊過,有興趣的朋友可以來看看

這裡用 lodash-es 提供的 memoize 實作:

ts
import hash from 'object-hash'

export const useClient = memoize(
  <T extends AppRouter>(
    router: T,
    options?: Parameters<typeof initClient>[1],
  ) => initClient(router, {
    baseUrl,
    baseHeaders: {},
    jsonQuery: true,
    ...options,
  }),
  (router, options) => hash({ router, options }),
)

hash 是為了計算快取用的 key,這裡使用 object-hash 處理,大家可以換成自己喜歡的實作。ヾ(◍'౪`◍)ノ゙


需要授權的 API 需要在 Authorization Header 提供 Access Token 才能存取,讓我們加入自動加入 Authorization Header 的功能:

ts
import { initClient, tsRestFetchApi } from '@ts-rest/core'

/** 自動加入 Authorization Header */
export const useClient = memoize(
  <T extends AppRouter>(
    router: T,
    options?: Parameters<typeof initClient>[1],
  ) => initClient(router, {
    baseUrl,
    baseHeaders: {},
    jsonQuery: true,
    api: async (args) => {
      const { accessToken } = useAuthStore()

      return tsRestFetchApi({
        ...args,
        headers: {
          Authorization: `Bearer ${accessToken}`,
          ...args.headers,
        },
      })
    },
    ...options,
  }),
  (router, options) => hash({ router, options }),
)

api 參數可以讓我們自定義請求如何送出。(ゝ∀・)b


說到 Access Token,就一定有 Refresh Token,讓我們加入 401 時自動 Refresh Token 邏輯:

ts
import { authContract } from '@project-code/shared'

/** 自動加入 Authorization Header 且 401 時自動 refresh */
export const useClient = memoize(
  <T extends AppRouter>(
    router: T,
    options?: Parameters<typeof initClient>[1],
  ) => initClient(router, {
    baseUrl,
    jsonQuery: true,
    api: async (args) => {
      const authStore = useAuthStore()
      let accessToken = authStore.accessToken

      let res = await tsRestFetchApi({
        ...args,
        headers: {
          Authorization: `Bearer ${accessToken}`,
          ...args.headers,
        },
      })

      // 只有 401 需要處理 refreshToken
      if (res.status !== 401) {
        return res
      }

      const authApi = initClient(authContract, {
        baseUrl,
        ...options,
      })

      const result = await authApi.refresh()
      if (result.status === 200) {
        accessToken = result.body.accessToken
        authStore.setAccessToken(accessToken)
      }

      /** 再發送一次剛剛被 401 的請求 */
      res = await tsRestFetchApi({
        ...args,
        headers: {
          Authorization: `Bearer ${accessToken}`,
          ...args.headers,
        },
      })
      if (res.status === 401) {
        // 可加入要求使用者登入邏輯
      }

      return res
    },
    ...options,
  }),
  (router, options) => hash({ router, options }),
)

這時候有個小問題,若有請求同時發出,就會重複 refresh 很多次。

甚麼?你說你們後端不在意?那就跳...還是來加個基本處理好了。(・∀・)9

使用 Vue 的 ref 與 VueUse 的 until 實現基本版:

ts
/** 用於防止多次 refresh */
const isRefreshing = ref(false)

/** 自動加入 Authorization Header 且 401 時自動 refresh */
export const useClient = memoize(
  <T extends AppRouter>(
    router: T,
    options?: Parameters<typeof initClient>[1],
  ) => initClient(router, {
    baseUrl,
    jsonQuery: true,
    api: async (args) => {
      const authStore = useAuthStore()

      await until(isRefreshing).toBe(false)
      let res = await tsRestFetchApi({
        ...args,
        headers: {
          Authorization: `Bearer ${authStore.accessToken}`,
          ...args.headers,
        },
      })

      // 只有 401 需要處理 refreshToken
      if (res.status !== 401) {
        return res
      }

      // 防止多次 refresh
      if (!isRefreshing.value) {
        const refreshClient = initClient(authContract, {
          baseUrl,
          ...options,
        })

        isRefreshing.value = true
        const result = await refreshClient.refresh()
        isRefreshing.value = false

        if (result.status === 200) {
          authStore.setAccessToken(result.body.accessToken)
        }
      }

      await until(isRefreshing).toBe(false)
      /** 再發送一次剛剛被 401 的請求 */
      res = await tsRestFetchApi({
        ...args,
        headers: {
          Authorization: `Bearer ${authStore.accessToken}`,
          ...args.headers,
        },
      })
      if (res.status === 401) {
        // 可加入要求使用者登入邏輯
      }

      return res
    },
    ...options,
  }),
  (router, options) => hash({ router, options }),
)

這樣可以處理基本 9 成常見的情境了。◝( •ω• )◟

二次封裝後可以很輕鬆地加入各種自定義邏輯,有需求的話還可以加入 Queue 等等花式功能,這個就看實際需求而定了。੭ ˙ᗜ˙ )੭


眼尖的朋友可能還有注意文件有提到 Query Client,實際上就是 Vue Query(Tanstack Query)。

Vue Query 內建資料快取、背景更新、自動重試等等強大功能,推薦大家可以玩玩看。

與一般的 Client 差別在初始化與參數稍微不同而已,所以 Query Client 的封裝同理:

ts
import { initQueryClient } from '@ts-rest/vue-query'

/** 自動加入 Authorization Header 且 401 時自動 refresh */
export const useQueryClient = memoize(
  <T extends AppRouter>(
    router: T,
    options?: Parameters<typeof initQueryClient>[1],
  ) => initQueryClient(router, {
    baseUrl,
    baseHeaders: {},
    jsonQuery: true,
    api: async (args) => {
      // ...
    },
    ...options,
  }),
  (router, options) => hash({ router, options }),
)

可以注意到 ts-rest 支援同時使用兩種 Client,這讓實務開發上更有彈性

資料邏輯很單純的網頁使用一般的 Client 即可,有複雜需求的部分再使用 Query Client。

正所謂小朋友才做選擇,我兩種都要!ヽ(●`∀´●)ノ

如此可以讓專案保持簡單、乾淨,更不容易出現協作斷層。( ‧ω‧)ノ╰(‧ω‧ )


最後讓我們來點測試,確認一下 memoize 有正常工作:

/apps/admin-web/src/common/api.test.ts

ts
import { initContract } from '@ts-rest/core'
import { describe, expect, it } from 'vitest'
import { useClient } from './api'

const baseContract = initContract().router({
  create: {
    method: 'GET',
    path: '/v1/collection-data',
    responses: {},
    summary: '建立 collection-data',
  },
}, {
  pathPrefix: '/api',
})

describe('api', () => {
  describe('useClient', () => {
    it(`相同設定應取得同一個實例`, () => {
      const client1 = useClient(baseContract)
      const client2 = useClient(baseContract)

      expect(client1).toBe(client2)
    })

    it(`不同設定應取得不同實例`, () => {
      const client1 = useClient(baseContract)
      const client2 = useClient(baseContract, {
        baseUrl: '/custom',
        baseHeaders: { a: 'a', b: 'b' },
      })

      expect(client1).not.toBe(client2)
    })

    it(`同樣設定但順序不同,也應取得同一個實例`, () => {
      const client1 = useClient(baseContract, {
        baseUrl: '/custom',
        baseHeaders: { a: 'a', b: 'b' },
      })
      const client2 = useClient(baseContract, {
        baseHeaders: { b: 'b', a: 'a' },
        baseUrl: '/custom',
      })

      expect(client1).toBe(client2)
    })
  })
})

以上程式碼可以在此取得

狀態與結果資料

ts-rest 回應的結果會將 HTTP Code 包含在資料內,其結構範例如下:

txt
{
  status: 200,
  body: {
    name: 'cod'
  },
  header: {}
}

假設 API 合約為:

ts
const c = initContract()

export const accountContract = c.router({
  create: {
    method: 'POST',
    path: '/v1/accounts',
    body: z.object({
      username: z.string(),
      password: z.string(),
      name: z.string(),
      description: z.string().optional(),
    }),
    responses: {
      200: z.object({
        id: z.string(),
      }),
      400: z.object({
        reason: z.enum([
          'username-duplicate',
        ]).optional(),
        message: z.string(),
      }),
      401: c.noBody(),
      403: c.noBody(),
    },
    summary: '建立 account',
  },
})

可以根據 status 取得具體內容,例如:

ts
const accountApi = useClient(accountContract)

const result = await accountApi.create({})

if (result.status === 200) {
  const newId = result.body.id
  // 提示成功建立使用者
}

if (result.status === 400) {
  if (result.body.reason === 'username-duplicate') {
    // 處理重複的使用者名稱
  }
  else {
    // 處理其他錯誤
  }
}

這樣前後端只要約定好 status 或列舉資料(像是 reason 欄位),就可以精準處理各種業務邏輯了。( ´ ▽ ` )ノ

當然如果不需要關心細節,直接比較數字即可:

ts
const accountApi = useClient(accountContract)

const result = await accountApi.create({})

if (result.status >= 400) {
  // 提示建立使用者失敗
}

表單資料驗證

若 UI 套件的 Form 可以使用驗證器,可以直接與取得合約內的驗證器進行表單驗證,以 Nuxt UI Form 為例:

vue
<script setup lang="ts">
import type { FormSubmitEvent } from '@nuxt/ui'
import { accountContract } from '@project-code/shared'
import * as z from 'zod'

const schema = accountContract.create.body
type Schema = z.output<typeof schema>

const state = reactive<Partial<Schema>>({
  email: undefined,
  password: undefined
})

const toast = useToast()
async function onSubmit(event: FormSubmitEvent<Schema>) {
  toast.add({ title: 'Success', description: 'The form has been submitted.', color: 'success' })
  console.log(event.data)
}
</script>

<template>
  <UForm
    :schema="schema"
    :state="state"
    class="space-y-4"
    @submit="onSubmit"
  >
    <UFormField label="Email" name="email">
      <UInput v-model="state.email" />
    </UFormField>

    <UFormField label="Password" name="password">
      <UInput v-model="state.password" type="password" />
    </UFormField>

    <UButton type="submit">
      Submit
    </UButton>
  </UForm>
</template>

若 Form 不能直接使用驗證器(Zod schema、ArkType type 等等),也不是大問題,來個轉換器就行。

以我常用的 Quasar 為例,可以參考此實作

感謝優秀的同事們

此轉換器是公司的夥伴們開發,我幫忙寫測試與文件,感謝他們的努力!(´,,•ω•,,)

後端

合約 + 測試 = 更可靠的 API

後端實作合約基本上就和概念篇相同,實作上最重要的部分是可以拿合約寫 e2e 測試,這樣只要測試有過,API 基本上不會有錯惹。ԅ(´∀` ԅ)

所以要怎麼拿合約寫 e2e 測試呢?

概念如下:

  1. 將資料輸入合約,取得 API 請求資料(例如 url、query string、body data)
  2. 利用第 1 步取得的資料,使用測試工具(例如 Supertest)發送請求

接下來以 collection-data 合約為例,一步一步來吧。

建立一個將 ts-rest 合約轉換成 API 請求資料的工具:

apps\api-server\src\common\utils\utils-ts-rest.ts

ts
type ExtractQuery<T> = T extends { query: infer U } ? U : never
type ExtractBody<T> = T extends { body: infer U } ? U : never

interface UseContractReturn<Query, Body> {
  url: string;
  method: Lowercase<AppRoute['method']>;
  query: Query;
  body: Body;
}

/** 協助取出合約 API 指定 API 內容
 *
 * 主要用於 e2e 測試
 */
export function useContract<
  Route extends AppRouteQuery,
  Data extends ClientInferRequest<Route>,
>(
  route: Route,
  data?: Data
): UseContractReturn<ExtractQuery<Data>, ExtractBody<Data>>
export function useContract<
  Route extends AppRouteMutation,
  Data extends ClientInferRequest<Route>,
>(
  route: Route,
  data?: ClientInferRequest<Route>
): UseContractReturn<ExtractQuery<Data>, undefined>
export function useContract<
  Route extends AppRouteQuery | AppRouteMutation,
>(
  route: Route,
  data?: ClientInferRequest<Route>,
): any {
  const {
    method,
    path,
  } = route

  const url = path.replace(
    /:(\w+)/g,
    (match, key) => data?.params?.[key],
  )

  if (data && 'body' in data) {
    return {
      url,
      method: method.toLowerCase() as Lowercase<AppRoute['method']>,
      query: data?.query,
      body: data,
    }
  }

  return {
    url,
    method: method.toLowerCase() as Lowercase<AppRoute['method']>,
    query: data?.query,
  }
}

測試工具使用 Supertest,建立一個將合約資料透過 Supertest 發送請求的工具:

apps/api-server/src/resource/collection-data/collection-data.e2e.ts

ts
import type { ClientInferRequest, ClientInferResponseBody } from '@ts-rest/core'
import type { IncomingHttpHeaders } from 'node:http'
import {
  collectionDataContract,
} from '@ts-rest-practice/shared'
import request from 'supertest'
import { useContract } from '../../common/utils/utils-ts-rest'

// 這裡不可能出現其他 Contract,所以可以簡短命名
type Contract = typeof collectionDataContract
type ContractRequest = ClientInferRequest<Contract>

interface ApiOptions {
  headers?: IncomingHttpHeaders;
}

/** e2e 測試之合約轉換層
 *
 * 將合約轉換成 API 呼叫,這樣就可以直接拿合約內容進行 e2e 測試
 *
 * 也就是說只要合約有變動,這裡也要跟著變動,這樣就可以確保合約的正確性
 *
 * 同時只要 e2e 測試通過,即表示合約正確,可以交付
 *
 * 另一個附加好處是如果其他資源的 e2e 測試有依賴此資源,也可以直接拿去重複使用
 *
 * @param server 測試環境中的 HTTP server 主體
 * @param globalOptions 額外的選項。同 method 內的 options,method 內的 options 會覆蓋這裡的 options
 */
export function createCollectionDataApi(
  server: any,
  globalOptions?: ApiOptions,
) {
  return {
    async create<
      Code extends keyof Contract['create']['responses'] = 200,
    >(
      data: ContractRequest['create']['body'],
      code = 200 as Code,
      options?: ApiOptions,
    ) {
      const { url, method } = useContract(collectionDataContract.create, {
        body: data,
      })

      const { body, statusCode, headers } = await request(server)[method](url)
        .set({
          ...globalOptions?.headers,
          ...options?.headers,
        })
        .send(data)

      if (code !== statusCode) {
        throw new Error(`${statusCode} : ${JSON.stringify(body, null, 2)}`)
      }

      return {
        headers,
        body: body as ClientInferResponseBody<
          typeof collectionDataContract.create,
          Code
        >,
      }
    },

    // 其他方法同理
  }
}

最後就針對此資源建立 e2e 測試(以下為參考,實作可以替換成自己喜歡的方式):

ts
type CollectionDataContract = ClientInferRequest<typeof collectionDataContract>

describe('collectionData e2e', () => {
  /** 使用記憶體版本 MongoDB 運行測試 */
  let mongodb: MongoMemoryReplSet
  let mongoConnection: Connection
  let app: INestApplication
  let server: ReturnType<INestApplication['getHttpServer']>

  let collectionDataApi: ReturnType<typeof createCollectionDataApi>

  beforeAll(async () => {
    // ...啟動 Server 與 DB

    collectionDataApi = createCollectionDataApi(server)
  }, 60000)

  /** 測試結束,關閉 DB */
  afterAll(async () => {
    // ...
  })

  async function clearAll() {
    // ...
  }
  /** 每次測試開始前與結束時,都清空 collection 資料,以免互相影響 */
  beforeEach(async () => await clearAll())
  afterEach(async () => await clearAll())

  function createCollectionData(
    params?: Partial<CollectionDataContract['create']['body']>,
  ) {
    const expectData: CollectionDataContract['create']['body'] = {
      name: 'test',
      description: '安安',
      ...params,
    }

    return collectionDataApi.create(expectData)
  }

  describe('建立 collection-data', () => {
    it('全部參數', async () => {
      const expectData: Required<CollectionDataContract['create']['body']> = {
        name: 'name',
        description: 'description',
        remark: 'remark',
      }

      const { body: result } = await collectionDataApi.create(expectData)

      expect(result).toMatchObject(pick(
        expectData,
        ['name', 'description', 'remark'],
      ))
    })
  })

  describe('取得 collection-data', () => {
    it('取得指定筆數資料', async () => {
      await Promise.allSettled([
        createCollectionData(),
        createCollectionData(),
      ])

      const { body: result } = await collectionDataApi.find({ limit: 1 })
      expect(result.total).toBe(2)
      expect(result.data).toHaveLength(1)
    })
  })

  describe('取得指定 collection-data', () => {
    it('取得指定資料', async () => {
      const { body: data } = await createCollectionData()
      const { body: newData } = await collectionDataApi.findOne(data.id)

      expect(newData).toEqual(data)
    })
  })

  describe('更新指定 collection-data', () => {
    it('修改 name 為 cod', async () => {
      const { body: data } = await createCollectionData()
      const { body: newData } = await collectionDataApi.update(data.id, {
        name: 'cod',
      })

      expect(newData.name).toBe('cod')
    })
  })

  describe('刪除指定 collection-data', () => {
    it('刪除資料後,find 無法取得', async () => {
      const { body: data } = await createCollectionData()
      await collectionDataApi.remove(data.id)
      const { body: result } = await collectionDataApi.find()

      expect(result.data).toHaveLength(0)
    })
  })
})

為了防止篇幅過長,這裡只展示部分測試內容,完整測試程式碼可以在此查看

理論上測試內容只要覆蓋合約內的所有 API 與業務邏輯之測試案例,就可以確保 API 正常交付惹。

至於測試案例怎麼列?這個就是一門藝術了。(◜௰◝)

總結 🐟

感謝大家看到這裡,以上為使用 ts-rest 實際開發的點點滴滴,內容與觀念會隨著時間推移而變化,所以本文會不定期更新。

特別感謝公司優秀的同事們,導入 ts-rest、專案結構、使用設計與實作,很多靈感都來自同事們的提問與建議,非常感謝大家。( ‧ω‧)ノ╰(‧ω‧ )

也感謝前端社群的 Danny 大大推薦 ts-rest 這個好東西,有任何問題還請大家不吝指教。(*´∀`)~♥

本文完整程式碼可以在此取得