🪄

tRPC より良いかもしれない "ts-rest" の紹介(ユースケースによる)

2023/05/10に公開

ts-rest とは

https://ts-rest.com/

ts-rest は、TypeScript 製 REST Server と TypeScript 製 REST Client をタイプセーフに通信できるようにするアダプタ?です。
OpenAPI Generator のようなソースコード生成をする事なくタイプセーフさを実現しています。
似たようなものに tRPCHono RPC があります。
tRPC や Hono RPC はサーバの実装コードの型を利用しているのに対し、ts-rest は contract(インターフェース) を定義し、サーバは contract を元にコードを実装、クライアントは contract を利用したリクエストを実行するような仕組みになっています。
ts-rest は、contract を npm パッケージ等を通じて配布すことができると思います。
tRPC や Hono RPC は、フロントエンドと BFF を繋ぐのに向いていて ts-rest は純粋に REST Server と REST Client を繋ぐのに向いているのではないかと考えています。

公式トップページのコーディング動画を観て頂くとイメージが付くと思います。

ts-rest の Quickstart

https://ts-rest.com/docs/quickstart

そんな ts-rest ですが、2023年5月9日時点の Quickstart をクイックに動かすことは難しかったです。
Quickstart には不完全な整合性がとれていないコードが記載されています。
どこかで Quickstart のコードが管理されていないかと確認すると、本体のレポジトリ内に発見します。
https://github.com/ts-rest/ts-rest/tree/main/apps/example-nest
そして、PostgreSQL の DB サーバが必要である事が分かりましたが、クイックにできないため SQLite に書き換えました(Express の Example は DB サーバの起動が不要でした)。

成果物

https://github.com/chibat/ts-rest-example
実際に動かす事ができた成果物です。

  • repository root
    • packages/contract: contract(インターフェース)を公開するためのプロジェクトです。
    • packages/server: REST Server を起動するためのプロジェクトです。Express と NestJS が選択できるようですが、ここでは NestJS を使っています。
    • packages/test-client: REST Client を実行するためのテスト用プロジェクトです。実際のクライアントのコードは、別レポジトリ管理される事を想定しています。

コード抜粋

contract

server と client を繋ぐインターフェースを定義します。

// packages/contract/contract.ts
import { initContract } from '@ts-rest/core';
import { z } from 'zod';

export interface Post {
  id: string;
  title: string;
  description: string | null;
  content: string | null;
  published: boolean;
}

const PostSchema = z.object({
  id: z.string(),
  title: z.string(),
  description: z.string().nullable(),
  content: z.string().nullable(),
  published: z.boolean(),
});

const c = initContract();

export const contract = c.router({
  createPost: {
    method: 'POST',
    path: '/posts',
    responses: {
      201: PostSchema,
    },
    body: z.object({
      title: z.string().transform((v) => v.trim()),
      content: z.string(),
      published: z.boolean().optional(),
      description: z.string().optional(),
    }),
    summary: 'Create a post',
  },
  updatePost: {
    method: 'PATCH',
    path: `/posts/:id`,
    responses: { 200: PostSchema },
    body: z.object({
      title: z.string().optional(),
      content: z.string().optional(),
      published: z.boolean().optional(),
      description: z.string().optional(),
    }),
    summary: 'Update a post',
  },
  deletePost: {
    method: 'DELETE',
    path: `/posts/:id`,
    responses: {
      200: z.object({ message: z.string() }),
      404: z.object({ message: z.string() }),
    },
    body: null,
    summary: 'Delete a post',
  },
  getPost: {
    method: 'GET',
    path: `/posts/:id`,
    responses: {
      200: PostSchema,
      404: z.null(),
    },
    query: null,
    summary: 'Get a post by id',
  },
  getPosts: {
    method: 'GET',
    path: '/posts',
    responses: {
      200: z.object({
        posts: PostSchema.array(),
        count: z.number(),
        skip: z.number(),
        take: z.number(),
      }),
    },
    query: z.object({
      take: z.string().transform(Number),
      skip: z.string().transform(Number),
      search: z.string().optional(),
    }),
    summary: 'Get all posts',
  },
  testPathParams: {
    method: 'GET',
    path: '/test/:id/:name',
    pathParams: z.object({
      id: z.string().transform(Number),
    }),
    query: z.object({
      field: z.string().optional(),
    }),
    responses: {
      200: z.object({
        id: z.number().lt(1000),
        name: z.string(),
        defaultValue: z.string().default('hello world'),
      }),
    },
  },
});

server

contract を実装するサーバのコードです。

// packages/server/src/app.controller.ts
import { Controller, Get, Query } from '@nestjs/common';
import { contract } from '@foo-api/contract';
import {
  nestControllerContract,
  NestControllerInterface,
  NestRequestShapes,
  TsRest,
  TsRestRequest,
} from '@ts-rest/nest';
import { AppService } from './app.service';

const c = nestControllerContract(contract);
type RequestShapes = NestRequestShapes<typeof c>;

// You can implement the NestControllerInterface interface to ensure type safety
@Controller()
export class AppController implements NestControllerInterface<typeof c> {
  constructor(private readonly postService: AppService) {}

  @Get('/test')
  test(@Query() queryParams: any) {
    return { queryParams };
  }

  @TsRest(c.getPosts)
  async getPosts(
    @TsRestRequest()
    { query: { take, skip, search } }: RequestShapes['getPosts']
  ) {
    const { posts, totalPosts } = await this.postService.getPosts({
      take,
      skip,
      search,
    });

    return {
      status: 200 as const,
      body: { posts, count: totalPosts, skip, take },
    };
  }

  @TsRest(c.getPost)
  async getPost(@TsRestRequest() { params: { id } }: RequestShapes['getPost']) {
    const post = await this.postService.getPost(id);

    if (!post) {
      return { status: 404 as const, body: null };
    }

    return { status: 200 as const, body: post };
  }

  @TsRest(c.createPost)
  async createPost(@TsRestRequest() { body }: RequestShapes['createPost']) {
    const post = await this.postService.createPost({
      title: body.title,
      content: body.content,
      published: body.published,
      description: body.description,
    });

    return { status: 201 as const, body: post };
  }

  @TsRest(c.updatePost)
  async updatePost(
    @TsRestRequest() { params: { id }, body }: RequestShapes['updatePost']
  ) {
    const post = await this.postService.updatePost(id, {
      title: body.title,
      content: body.content,
      published: body.published,
      description: body.description,
    });

    return { status: 200 as const, body: post };
  }

  @TsRest(c.deletePost)
  async deletePost(
    @TsRestRequest() { params: { id } }: RequestShapes['deletePost']
  ) {
    await this.postService.deletePost(id);

    return { status: 200 as const, body: { message: 'Post Deleted' } };
  }

  @TsRest(c.testPathParams)
  async testPathParams(
    @TsRestRequest() { params }: RequestShapes['testPathParams']
  ) {
    return { status: 200 as const, body: params };
  }
}

test-client

contract を利用するクライアントのコードです。

// packages/test-client/client.ts
import { initClient } from "@ts-rest/core";
import { contract } from "@foo-api/contract";

const client = initClient(contract, {
  baseUrl: "http://localhost:3000",
  baseHeaders: {},
});

client.createPost({
  body: {
    title: "Post Title",
    content: "Post Content",
  },
}).then(({ body, status }) => {
  if (status === 201) {
    // body is Post
    console.log(body);
  } else {
    // body is unknown
    console.log(body);
  }
});

動作手順

以下、動作手順について整理します。

0. 前提

  • Node.js v18 以上

1. Example レポジトリの clone

$ git clone https://github.com/chibat/ts-rest-example
$ cd ts-rest-example
$ npm install

2. contract のビルド

$ cd packages/contract
$ npm run build
$ cd -

4. REST Server の起動

$ cd packages/server
$ npm start dev

5. REST Client の実行

cd packages/test-client
$ npm run client

> @foo-api/test-client@1.0.0 client
> ts-node client.ts

{
  id: 'clhl7i11u0000ud38s7nadq31',
  createdAt: '2023-05-12T23:45:33.906Z',
  updatedAt: '2023-05-12T23:45:33.906Z',
  title: 'Post Title',
  content: 'Post Content',
  description: null,
  published: false,
  image: null
}

OpenAPI の Spec レスポンス

OpenAPI にも対応しました。

Endpint: http://localhost:3000/api-json

$ curl http://localhost:3000/api-json
{"openapi":"3.0.2","paths":{"/posts":{"post":{"summary":"Create a post","tags":[],"parameters":[],"operationId":"createPost","requestBody":{"description":"Body","content":{"application/json":{"schema":{"type":"object","properties":{"title":{"type":"string"},"content":{"type":"string"},"published":{"type":"boolean"},"description":{"type":"string"}},"required":["title","content"]}}}},"responses":{"201":{"description":"201","content":{"application/json":{"schema":{"type":"object","properties":{"id":{"type":"string"},"title":{"type":"string"},"description":{"type":"string","nullable":true},"content":{"type":"string","nullable":true},"published":{"type":"boolean"}},"required":["id","title","description","content","published"]}}}}}},"get":{"summary":"Get all posts","tags":[],"parameters":[{"name":"take","in":"query","required":true,"schema":{"type":"string"}},{"name":"skip","in":"query","required":true,"schema":{"type":"string"}},{"name":"search","in":"query","schema":{"type":"string"}}],"operationId":"getPosts","responses":{"200":{"description":"200","content":{"application/json":{"schema":{"type":"object","properties":{"posts":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string"},"title":{"type":"string"},"description":{"type":"string","nullable":true},"content":{"type":"string","nullable":true},"published":{"type":"boolean"}},"required":["id","title","description","content","published"]}},"count":{"type":"number"},"skip":{"type":"number"},"take":{"type":"number"}},"required":["posts","count","skip","take"]}}}}}}},"/posts/{id}":{"patch":{"summary":"Update a post","tags":[],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"operationId":"updatePost","requestBody":{"description":"Body","content":{"application/json":{"schema":{"type":"object","properties":{"title":{"type":"string"},"content":{"type":"string"},"published":{"type":"boolean"},"description":{"type":"string"}}}}}},"responses":{"200":{"description":"200","content":{"application/json":{"schema":{"type":"object","properties":{"id":{"type":"string"},"title":{"type":"string"},"description":{"type":"string","nullable":true},"content":{"type":"string","nullable":true},"published":{"type":"boolean"}},"required":["id","title","description","content","published"]}}}}}},"delete":{"summary":"Delete a post","tags":[],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"operationId":"deletePost","responses":{"200":{"description":"200","content":{"application/json":{"schema":{"type":"object","properties":{"message":{"type":"string"}},"required":["message"]}}}},"404":{"description":"404","content":{"application/json":{"schema":{"type":"object","properties":{"message":{"type":"string"}},"required":["message"]}}}}}},"get":{"summary":"Get a post by id","tags":[],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"operationId":"getPost","responses":{"200":{"description":"200","content":{"application/json":{"schema":{"type":"object","properties":{"id":{"type":"string"},"title":{"type":"string"},"description":{"type":"string","nullable":true},"content":{"type":"string","nullable":true},"published":{"type":"boolean"}},"required":["id","title","description","content","published"]}}}},"404":{"description":"404","content":{"application/json":{"schema":{"type":"string","format":"null","nullable":true}}}}}}},"/test/{id}/{name}":{"get":{"tags":[],"parameters":[{"name":"name","in":"path","required":true,"schema":{"type":"string"}},{"name":"id","in":"path","required":true,"schema":{"type":"string"}},{"name":"field","in":"query","schema":{"type":"string"}}],"operationId":"testPathParams","responses":{"200":{"description":"200","content":{"application/json":{"schema":{"type":"object","properties":{"id":{"type":"number","maximum":1000,"exclusiveMaximum":true},"name":{"type":"string"},"defaultValue":{"default":"hello world","type":"string"}},"required":["id","name"]}}}}}}}},"info":{"title":"Posts API","version":"1.0.0"}}

Swagger-UI

Swagger-UI にも対応しました。

URL: http://localhost:3000/api

補足

実際は、contract のプロジェクトをモジュールとして社内 npm レジストリに公開し、client で引き込むのが良いと思います。
「社内 npm レジストリなんてないよ」という場合は、contract の TypeScript コードのコピーで済ませる事もできるかと思います。。

おわりに

コードの詳細は、git レポジトリの方をご確認頂ければと思います。。
REST Client がフロンエンドになる場合は、tRPC でモノレポ構成にするの良いと思いますが、純粋に REST Server と REST Client を繋ぐ場合は、ts-rest が良いのかと思っています。

以上です。

Discussion