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

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

本文基於 TypeScript,如果沒有採用的小夥伴可以狠心離開惹。~(>_<。)

或者一起湊個熱鬧。(´▽`ʃ♡ƪ)

甚麼是 ts-rest?

ts-rest 是基於 TypeScript 的 RESTful API 工具,主要目的在於實現從介面定義開始,涵蓋至服務器、客戶端的全型別安全,而且無需額外、繁瑣的標註或生成過程。

風格類似 RPC,可以讓 API 呼叫更加直覺,配合 TSDoc 有更完整、詳盡的說明,可以大幅降低使用 API 的心智負擔。

以下範例以 Vue、NestJS 為例,相關套件版本如下:

  • @nestjs/core:10.4.1
  • @ts-rest/core:3.51.0
  • @ts-rest/nest:3.51.0
  • zod:3.23.8

從前有個後端和前端

他們想要合力串接一個資料,叫做 CollectionData。

通用資料

前後端手牽手,先討論想要的格式。(≧︶≦))( ̄▽ ̄ )ゞ

第一步定義傳輸層的 schema,方式與 zod 相同。

定義 Zod Object

import { z } from 'zod';

export const collectionDataSchema = z.object({
  id: z.string(),
  /** 名稱 */
  name: z.string(),
  description: z.string(),
  remark: z.string(),
});
export interface CollectionData extends z.infer<
  typeof collectionDataSchema
> { }

// 用 type 也可以,取決於貴團隊的規範
// export type CollectionData = z.infer<typeof collectionDataSchema>

接著根據剛剛制定的 schema 和 API 功能定義合約:

ts-rest:Define Contract


import { AppRoute, ClientInferRequest, initContract } from '@ts-rest/core';
import { z } from 'zod';
import { collectionDataSchema } from './schema';

// 建立 collection-data
export const createCollectionDataDtoSchema = collectionDataSchema.omit({
  id: true,
  timestamp: true,
}).partial({
  remark: true,
  description: true,
});

/**
 * 使用 satisfies 是為了 AppRoute 的欄位提示又可以保留具體內容定義。
 * 當然 as const 也行,只是輸入的過程不會有 AppRoute 欄位提示。
 * const create: AppRoute 會遺失詳細的內容定義,所以最後採用 satisfies。
 */
const create = {
  method: 'POST',
  path: '/v1/collection-data',
  body: createCollectionDataDtoSchema,
  responses: {
    201: collectionDataSchema,
  },
  summary: '建立 collection-data',
} satisfies AppRoute

export const collectionDataContract = initContract().router({
  create,
}, {
  pathPrefix: '/api',
  commonResponses: {
    500: z.object({
      message: z.string(),
    }),
  },
});

// 提前取出 client 視角的合約型別,方便前端使用
export interface CollectionDataContract extends ClientInferRequest<
  typeof collectionDataContract
> { }

路人:「怎麼只有一個 create?ಠ_ಠ」

鱈魚:「我懶。(ツ)」

路人:(抽刀)

鱈魚:「刀下留魚啊 (#°Д°),是因為內容太多怕大家會累啦,用心良苦欸。」

後端實作(NestJS)

依照官網步驟實作。

ts-rest:Nest

以下是一個簡單的範例:

...

@Controller()
export class CollectionDataController {
  constructor(
    private readonly loggerService: LoggerService,
    private readonly collectionDataService: CollectionDataService,
  ) { }

  @TsRestHandler(collectionDataContract.create, {
    validateResponses: true
  })
  async create() {
    return tsRestHandler(collectionDataContract.create, async ({
      body: dto,
    }) => {
      const [error, data] = await to(this.collectionDataService.create(dto));
      if (error) {
        this.loggerService.error(`建立 CollectionData 錯誤 :`);
        this.loggerService.error(error);

        return {
          status: 500,
          body: {
            message: '建立 CollectionData 錯誤',
          },
        };
      }

      return {
        status: 201,
        body: data,
      };
    });
  }
}
那個奇怪的 await to 是因為我用了這個,不是寫錯喔。(。・∀・)ノ゙

開啟 validateResponses 的話,ts-rest 會嚴格驗證 API 響應資料格式是否正確,錯誤的話會直接噴 500,這就表示後端最好在 e2e 測試時,就該把可能情境測出來,只要測試有過,資料格式就不可能錯。

前後端就不用再為了資料有坑吵架了。ヽ(✿゚▽゚)ノ

前端實作(Vue)

前端用法有兩種,可以依照需求挑適合的用:

可以漸進式導入,兩個同時用也沒問題。

個人推薦 Vue Query,雖然有點門檻,可是功能很強很好用。(๑•̀ㅂ•́)و✧

如果都不喜歡,也可以自定義

驗證

前端如果呼叫 API 前想先驗證一次參數正確性。例如:驗證 collectionData API 的 body 資料是否正確。

只要取得 contract 中對應路徑的 zod schema 即可。

const createCollectionDataDto = collectionDataContract.create.body;

createCollectionDataDto.parse({...})

剩下就是 zod 的工作了。(o゚v゚)ノ

Zod:parse

型別

請求用的型別則是取用預先準備好的 CollectionDataContract,如下圖。

CollectionDataContract

回應型別則是可以使用 ServerInferResponseBody 推導:

type Data = ServerInferResponseBody<
  typeof collectionDataContract.create, 201
>

ServerInferResponseBody

詳情可以看看 ts-rest 提供的 Inferring Types 工具可以推導更多型別內容。( •̀ ω •́ )y

ts-rest 還有很多內容,可以來官網逛逛。♪(^∇^*)

所以前後端要怎麼共享合約?

鱈魚:「真是個好問題,當然是複製貼...ᕕ( ゚ ∀。)ᕗ 」

路人:(拿起球棒)

鱈魚:「開玩笑的啦。∠( ᐛ 」∠)_」

目前我自己已知不錯的方式有:

  • 私有 npm
  • monorepo

個人比較推薦使用 monorepo,我們公司也是如此,至於怎麼用 Google 有很多教學,這裡就不贅述囉。ԅ( ˘ω˘ԅ)

總結 🐟

  • ts-rest 可以讓前後端串接更加嚴謹、自動化
  • 介紹後端、前端實作
  • 合約可以藉由私有 npm、monorepo 共享

以上恭喜你完全發揮 TypeScript 的魔法惹!ˋ( ° ▽、° )

從此前後端再也不用為資料不一致問題打架了。✧⁑。٩(ˊᗜˋ*)و✧⁕。

甚麼?你說還有很多事情可以吵?╭(°A ,°`)╮

那就超過本文的探討範圍惹,要打去練舞室打。ᕕ( ゚ ∀。)ᕗ