typescript

【TypeScript】型定義をする際にもDRY原則を守る

DRY原則は非常に有名な原則ですし、普段から特に考えずとも自然と重複をさけるようなコードを書いている方も多いことでしょう。 とはいえ、TypeScriptにおいて`interface`や`type`などを用いて型定義を行う際に重複した型定義を行ってしまうことはないでしょうか? TypeScriptには型定義をする際に重複を抑える手段は確かに存在します。そのようないくつかの方法を紹介します。

DRY 原則とは「Don't repeat yourself」の略でコードの重複をさけるというな原則の 1 つです。DRY 原則は非常に有名な原則ですし、普段から特に考えずとも自然と重複をさけるようなコードを書いているほうも多いことでしょう。

とはいえ、TypeScript において interfacetype などを用いて型定義を行う際に重複した型定義を行ってしまうことはないでしょうか?

確かに、型定義を行う際には普段コードを書くときのように関数で同じ表現のロジックをまとめたり、抽象化を行って重複を避けるようなことはできません。

ですが、TypeScript には型定義をする際に重複を抑える手段は確かに存在します。そのようないくつかの方法を紹介します。

Utility Types

Utility Types とは、TypeScript に標準で用意されている便利な型表現で、いわば便利な関数群のようなものです。Utility Types を利用するとベースとなる型を変換して新しい型を定義できます。

いくつかよく使う例を見ていきましょう。

Partial<Type>

例えば、一般的な CRUD 操作を考えてみてください。あるオブジェクトを作成する際にはすべてのプロパティを必須項目とするけれど、更新処理を行う際には更新を行うプロパティだけ項目として渡したといことでしょう。

単純に考えれば、元の型をすべてオプショナルなプロパティにすればこの要件は満たすことができます。

type Todo = {
    id: number
    title: string
    done: boolean
    createdAt: string
    updatedAt: string
}
 
type UpdateTodo = {
    id?: number
    title?: string
    done?: boolean
    createdAt?: string
    updateAt?: string
}
 
const todos: Todo[] = [{
    id: 1,
    title: 'some todo',
    done: false,
    createdAt: '2021-01-01',
    updatedAt: '2021-01-01'
}]
 
const updateTodo = (id: number, payload: UpdateTodo) => {
    const index = todos.findIndex(t => t.id === id)
    if (index === -1) throw new Error('not found')
    todos[index] = { ...todos[index], ...payload}
}
 
updateTodo(1, { done: true })

確かに上手くいきますが、Todo 型の持つプロパティを再度列挙しているので重複が生じています。

再度同じプロパティを書くのも面倒ですし、何よりこの状態ですと元の Todo 型のプロパティに変更が生じた際に UpdateTodo 側を更新し忘れて不具合が生じる可能性があります。

(それから、updateAt とタイポしていることには気が付きましたか?)

type Todo = {
    id: number
    title: string
    done: boolean
    createdAt: Date
    updatedAt: Date
}
 
type UpdateTodo = {
    id?: number
    title?: string
    done?: boolean
    createdAt?: string // Date型に変更し忘れている!!
    updatedAt?: string
}

このような場合には、Partial が使えます。Partial は型を 1 つ受け取りすべてをオプショナルにしたものを返します。

type Todo = {
    id: number
    title: string
    done: boolean
    createdAt: string
    updatedAt: string
}
 
type UpdateTodo = Partial<Todo>
 
// type UpdateTodo = {
//    id?: number | undefined;
//    title?: string | undefined;
//    done?: boolean | undefined;
//    createdAt?: string | undefined;
//    updatedAt?: string | undefined;
// }

Required<Type>

RequiredPartial とは反対に、すべて必須の型にしたものを返します。

type UpdateTodo = {
    id?: number
    title?: string
    done?: boolean
    createdAt?: string
    updatedAt?: string
}
 
type Todo = Required<UpdateTodo>
// type Todo = {
//     id: number;
//     title: string;
//     done: boolean;
//     createdAt: string;
//     updatedAt: string;
}

Pick<Type, Keys>

もう 1 つ別の例を考えてみましょう。

Todo 型は idcreatedAtupdatedAt というプロパティを持っていますが、これらはおそらくデータベースで自動生成されるものでしょう。であれば、ユーザーの入力させる型としてはこれらのプロパティを除いたものを定義したいでしょう。

type Todo = {
    id: number
    title: string
    done: boolean
    createdAt: string
    updatedAt: string
}
 
type TodoPayload = {
    title: string
    done: boolean
}

この例でも同じく重複が生じてしまっています。

これは、Pick を使うことで解決します。 Pick はある型から必要な部分だけを抽出します。

type Todo = {
    id: number
    title: string
    done: boolean
    createdAt: string
    updatedAt: string
}
 
type TodoPayload = Pick<Todo, 'title' | 'done'>
 
// type TodoPayload = {
//     title: string;
//     done: boolean;
// }

こうしておけば、元の型が変更されたときに追従できます。また Pick を定義する際にインテリセンスが効くのでプロパティ名のタイポを防げる点もメリットの 1 つでしょう。

Omit<Type, Keys>

OmitPick とは逆に除外するべきプロパティを選択します。

ype Todo = {
    id: number
    title: string
    done: boolean
    createdAt: string
    updatedAt: string
}
 
type TodoPayload = Omit<Todo, 'id' | 'createdAt' | 'updatedAt'>
 
// type TodoPayload = {
//     title: string;
//     id: number;
// }

OmitPick とは異なり Keys に存在しないプロパティ名を指定できてしまうことに注意してください。つまり、インテリセンスは効きませんしタイポを警告してくれません。

この他にも、たくさんの Utility Types が存在しますがこの記事の趣旨からは少しそれるので残りは省略します。

気になるほうは、以下のリンクをご参照ください。

TypeScript: Documentation - Utility Types

自分で型定義を行う際には、Utility Types を思い出してまずは適用させられないか考えて見る癖をつけるのが良いでしょう。

ライブラリの提供する型定義を利用する

もしコードの中でライブラリを使用している箇所があるなら、あなたは決してそのライブラリに関する型情報を自分で書いてはいけません。

あなたが利用しているライブラリの作者(またはコミュニティ)は大抵の場合そのライブラリに関する型情報をエクスポートしてくれているはずです。

それをわざわざ自分で再定義するというのは面倒な作業ですし、すでに用意されているものを使ったほうが安全です。

例として、Vuetifyのv-data-tableを見てみましょう。

ヘッダーを定義するために、次のようなオブジェクトの配列を header プロパティに渡す必要があることが書かれています。

{
  text: string,
  value: string,
  align?: 'start' | 'center' | 'end',
  sortable?: boolean,
  filterable?: boolean,
  groupable?: boolean,
  divider?: boolean,
  class?: string | string[],
  cellClass?: string | string[],
  width?: string | number,
  filter?: (value: any, search: string, item: any) => boolean,
  sort?: (a: any, b: any) => number
}

このようなドキュメントに記載されている型定義は vuetify モジュールからインポートできるので、ありがたく使わせていただきましょう。

import { DataTableHeader } from 'vuetify'
 
const myHeaders = (): DataTableHeader[] => {
  return [
    {
      text: 'Dessert (100g serving)',
      align: 'start',
      sortable: false,
      value: 'name'
    },
    { text: 'Calories', value: 'calories' }
  ]
}

型定義を自動生成する

やはり、一番ベストな方法は自分で型定義を作成せずにすべて任せる方法です。

OpenAPI や GraphQL を利用してバックエンドの型情報を自動生成する方法を記載します。

OpenAPI Generator

OpenAPI とは Rest API を記述するためのフォーマットのことで、以下のように API 全体を記述できます。

  • 使用可能なエンドポイント(/users)と各エンドポイントでの操作(GET /users、POST /users)
  • 操作パラメータ各操作の入力と出力
  • 認証方法
  • 連絡先情報、ライセンス、利用規約およびその他の情報。

OpenAPI は JSONYAML で記述されるので、人間と機械どちらでも読むことができます。

例として以下のような構造を持ちます。

openapi: 3.0.0
info:
  title: Sample API
  description: Optional multiline or single-line description in [CommonMark](http://commonmark.org/help/) or HTML.
  version: 0.1.9
servers:
  - url: http://api.example.com/v1
    description: Optional server description, e.g. Main (production) server
  - url: http://staging-api.example.com
    description: Optional server description, e.g. Internal staging server for testing
paths:
  /users:
    get:
      summary: Returns a list of users.
      description: Optional extended description in CommonMark or HTML.
      responses:
        '200':    # status code
          description: A JSON array of user names
          content:
            application/json:
              schema: 
                type: array
                items: 
                  type: string

そして、この OpenAPI の使用にそって記述された YAML ファイルを用いて型定義を自動生成できます。その中でも、openapi-genaratorはデファクトスタンダードといえるでしょう。

実際に openapi-generator を元に TypeScript の型定義を生成してみましょう。

パッケージのインストール

今回はサンプルとして、以下のリンクのファイルを使用して型定義を生成します。

型定義を生成するために必要なパッケージをインストールします。

npm install @openapitools/openapi-generator-cli -D

インストールが完了したら、package.json に以下スクリプトを追加します。

  "scripts": {
    "generate": "openapi-generator-cli generate -i openapi.yaml -g typescript-axios -o src/model"
  },

コマンドのオプションはそれぞれ以下のとおりです。

オプション Header
-i 生成元のOpenAPIのファイル
-g 利用するジェネレーター。open-api-generatorは複数の言語の出力に対応しているのでTypeScript用のジェネレーターを指定する。今回は axios を選択しているが他にもTypeScirpt用のジェネレーターにはAnguar, AnguarJS, fetch, jqueryなどがある
-o 生成した型定義の出力先

コマンドを実行します。

npm run generate

自動生成されたファイルを確認する

成功すると、アウトプット先に指定して src/model 配下に以下のようなファイルが生成されます。

src/model/
├── api.ts
├── base.ts
├── common.ts
├── configuration.ts
├── git_push.sh
└── index.ts

api.ts ファイルを見てみるとリクエストとレスポンスの interface が生成されていることがわかります。

/**
 * Describes the result of uploading an image resource
 * @export
 * @interface ApiResponse
 */
export interface ApiResponse {
    /**
     * 
     * @type {number}
     * @memberof ApiResponse
     */
    code?: number;
    /**
     * 
     * @type {string}
     * @memberof ApiResponse
     */
    type?: string;
    /**
     * 
     * @type {string}
     * @memberof ApiResponse
     */
    message?: string;
}
/**
 * A category for a pet
 * @export
 * @interface Category
 */
export interface Category {
    /**
     * 
     * @type {number}
     * @memberof Category
     */
    id?: number;
    /**
     * 
     * @type {string}
     * @memberof Category
     */
    name?: string;
}
 
// 省略

生成されたクライアントを使用する

openapi-generator によって生成されるのは型定義だけではなく、API クライアントも生成されます。

API クライアントのクラスはグルーピングに使用される tags ごとに生成されます。

後は、インスタンスのエンドポイントに対応したメソッドを呼び出せば API リクエストを送信できます。

import { PetApi } from "./model/api";
 
const petApi = new PetApi()
petApi.addPet({ name: 'pet', photoUrls: ['aaa.jpg'] })`

GraphQL

つづいて GraphQL から TypeScript の型定義を生成します。

GraphQL では OpenAPI のようにスキーマから型定義を生成するほか他にサーバーのエンドポイントからも型定義を生成できます。

GraphQL から型定義を生成するライブラリはいくつかありますが、以下が有名どころです。

  • graphql-codegen
  • Apollo

今回は、graphql-codegenを使用してやってみます。

パッケージのインストール

例として、GraphQL Content API から型定義を生成します。 これは Headless CMS の 1 つである「Contentful」が提供する GraphQL のエンドポイントです。

ひとまず必要なパッケージをインストールしましょう。とりあえず graphql は必要です。

npm install --save graphql gql

型定義を生成するパッケージもインストールします。

npm install --save-dev @graphql-codegen/cli @graphql-codegen/typescript

セットアップ

インストールが完了したら以下コマンドでセットアップします。

npx graphql-codegen init

codegen.yaml ファイルが生成されます。例として以下のように設定しました。

overwrite: true
schema:
  - "https://graphql.contentful.com/content/v1/spaces/${SPACE}/environments/${ENVIRONMENTS}":
      headers:
        Authorization: Bearer ${API_KEY}
documents: "src/**/*.ts"
generates:
  src/generated/graphql.ts:
    plugins:
      - "typescript"
      - "typescript-operations"
  ./graphql.schema.json:
    plugins:
      - "introspection"

package.json にスクリプトを追加しましょう。

{
  "scripts": {
    "generate": "graphql-codegen --config codegen.yml -r dotenv/config"
  }
}

codegen.yaml 内で環境変数を使用している箇所があるので(${} で囲われているところ)-r dotenv/config オプションを付与します。

Query・Mutationsを定義

GraphQL は Query・Mutations を定義してリソースを操作するのですが、@graphql-codegen/typescript パッケージによりそのような操作に対しても型を付与できます。

例として src/queries 内に投稿(Post)一覧を取得する以下のようなクエリを定義します。

import { gql } from "@urql/core"
 
export const postsQuery = gql`
  query Posts(
    $order: BlogPostOrder = createdAt_DESC,
    $limit: Int = 12,
    $skip: Int = 0,
  ) {
    blogPostCollection(
      limit: $limit,
      skip: $skip,
      order: [$order]
    ) {
      total
      skip
      limit
      items {
        title
        slug
        about
        createdAt
        thumbnail {
          title
          url
        }
        tagsCollection(limit: 5) {
          items {
            name
            slug
          }
        }
      }
    }
  }
`
 
(GraphQLに馴染みがないと難解に思われると思いますが・・・)
 
クエリから型定義を生成するもう一つメリットとして、タイポなどで誤った記述をした際にエラーを報告してくれるところでしょう。
 
例えば存在しないフィールド指定してコマンドを実行すると以下のように怒られます。
 
```sg
$ npm run generate
 Found 2 errors
 
  ✖ ./graphql.schema.json
    AggregateError: 
        GraphQLDocumentError: Cannot query field "tota" on type "BlogPostCollecti
on". Did you mean "total"?

コマンドを実行する

ここまで準備ができたらコマンドを実行してみましょう。

npm run generate

実行に成功すると、src/generated/graphql.ts に型定義ファイルが生成されています。

export type BlogPostCollection = {
  __typename?: 'BlogPostCollection';
  total: Scalars['Int'];
  skip: Scalars['Int'];
  limit: Scalars['Int'];
  items: Array<Maybe<BlogPost>>;
};
 
/** blog post [See type definition](https://app.contentful.com/spaces/in6v9lxmm5c8/content_types/blogPost) */
export type BlogPost = Entry & {
  __typename?: 'BlogPost';
  sys: Sys;
  linkedFrom?: Maybe<BlogPostLinkingCollections>;
  about?: Maybe<Scalars['String']>;
  createdAt?: Maybe<Scalars['DateTime']>;
  updatedAt?: Maybe<Scalars['DateTime']>;
  thumbnail?: Maybe<Asset>;
  title?: Maybe<Scalars['String']>;
  slug?: Maybe<Scalars['String']>;
  article?: Maybe<Scalars['String']>;
  tagsCollection?: Maybe<BlogPostTagsCollection>;
  relatedArticleCollection?: Maybe<BlogPostRelatedArticleCollection>;
};
 
export type PostsQueryVariables = Exact<{
  order?: Maybe<BlogPostOrder>;
  limit?: Maybe<Scalars['Int']>;
  skip?: Maybe<Scalars['Int']>;
}>;
 
export type PostsQuery = (
  { __typename?: 'Query' }
  & { blogPostCollection?: Maybe<(
    { __typename?: 'BlogPostCollection' }
    & Pick<BlogPostCollection, 'total' | 'skip' | 'limit'>
    & { items: Array<Maybe<(
      { __typename?: 'BlogPost' }
      & Pick<BlogPost, 'title' | 'slug' | 'about' | 'createdAt'>
      & { thumbnail?: Maybe<(
        { __typename?: 'Asset' }
        & Pick<Asset, 'title' | 'url'>
      )>, tagsCollection?: Maybe<(
        { __typename?: 'BlogPostTagsCollection' }
        & { items: Array<Maybe<(
          { __typename?: 'Tag' }
          & Pick<Tag, 'name' | 'slug'>
        )>> }
      )> }
    )>> }
  )> }
);

以上のような型定義が生成さました。(Query の方は難解すぎてよくわからんですが、しっかりと使えます)


Contributors

> GitHub で修正を提案する
この記事をシェアする
はてなブックマークに追加

関連記事