TECH BOX

Technology blog from Web Engineer

この記事は最終更新日から5年以上経過しているため正確ではないかもしれません

TypeScriptのGenericsをどこで使うか

TypeScriptにはGenericsという型付けの仕方があります。
簡単に説明すると引数や返り値などの型を任意に設定できる型指定方法です。

例えば、一つの関数で戻り値がある時はstring、ある時はnumberといった時にGenericsを使う事ができます。

function Foo<T> (abc: T): T {
  return abc
}

const res1 = Foo<string>('foo')
const res2 = Foo<number>(123)

このTというのは慣習で書いているだけなので実はAでもBでもいいです。
TとかKとかがよく使われるそうです。

一見便利そうですが、実務レベルでどういった時に使うのか疑問に思うはず。
TypeScriptのリファレンスのようなサンプルでは実務ででてくることはありません。

どういう時に使うか、いくつか例を紹介します。

処理の手順が同じで戻り値が違う場合に使える

メソッドに渡す引数が一定で中身の処理が変わらず、戻り値の型が違う時という場合にはGenericsは有用です。
むしろこれぐらいのときしか使わないくらいの制約の方が運用しやすいかも。

例えば、APIの結果や直接JSONデータを取得するときなどがそうですね。

YAMLなどのファイルを読み込む例

// read-yaml.ts
import fs from 'fs'
import yaml from 'js-yaml'

export function readYaml<T> (filepath: string): T {
  return yaml.safeLoad(fs.readFileSync(filepath, 'utf8'))
}

// foo.ts
import { readYaml } from './read-yaml'

const foo1 = readYaml<{id: string; name: string}>('path/to/user.yml')
const foo2 = readYaml<number[]>('path/to/ids.yml')

サンプルはNode.jsでYAMLをJSONにして読み込むものになります。
Genericsを使って戻り値のみ適切な型を指定することで、ファイルパスを渡すだけで共通処理を作ることができます。

DynamoDBの結果を取得

AWSのDynamoDBを利用していて、データを取得する場合なんかでも使えます。
下記はDynamoDBのテーブルとキーを元にデータを取得する簡単なサンプルです。

import { AWSError, DynamoDB } from 'aws-sdk'

export default class DbCtrl {
  /** DynamoDBから情報取得 */
  public async read(
    params: { TableName: string; Key: object }[]
  ): Promise<{ data: any; error?: string }> {
    const db = new DynamoDB.DocumentClient()

    const database:
      | DynamoDB.DocumentClient.TransactGetItemsOutput
      | AWSError = await db.transactGet({ TransactItems: params }).promise()

    const error = this._errorCheck(database)
    if (error) {
      return {
        data: {},
        error
      }
    }

    const res = database.Responses

    return { data: res }
  }

  /** n番目のデータを取得 */
  public get<T>(res: any, index: number): T | undefined {
    if (res === undefined) return

    const d = res[index]
    if (d === undefined) return

    return (d['Item'] as T) || undefined
  }

  /** DBエラーかどうかのチェック */
  private _errorCheck(res: any): string | undefined {
    if (res.hasOwnProperty('code')) {
      return (res as AWSError).code
    }

    return
  }
}

Genericsを使っているのはgetメソッドになります。
余談ですが、Promiseの戻り値型指定もGenericsです。

これを下記のように使います。

;(async () => {
  const db = new DbCtrl()
  const res = await db.read([
    { TableName: 'User', Key: { id: 1 } },
    { TableName: 'Result', Key: { id: 1 } }
  ])

  if (res.error) {
    // エラーの場合の処理
  }

  const user = db.get<{ id: number; name: string }>(res, 0)
  const result = db.get<{ total: number }>(res, 1)

  // 以降何らかの処理
})()

DynamoDBからデータを取り出した後はデータのn番目を取得する or 存在しなければundefinedという処理が必要なのですが、TypeScriptの場合は型があるため、any型にしてしまうとそれ以降の型チェックができないし、型が存在する分だけメソッドを作るというのは無駄です。

そういう時にGenericsを使って呼び出し側で型を指定してあげることで処理ロジックが散逸しなくなります。

そもそもGenericsを使う必要があるのかを考える

実装例を2つほど挙げましたが、まずは本当にGenericsが必要なのかということを考えましょう。

Genericsには他にも<T extends K>のように制約を追加したり、<T extends Foo keyof T>のようにキーの制約をしたりできます。
こういうのが必要になる場面があるとすれば、ライブラリやフレームワークを作る時くらいなのではと思わなくもないです。
※どう使うかはQiitaのこの記事とかが参考になります

Genericsはうまく使えば便利ですが、行き過ぎた抽象化と同じであまりにも多用しすぎると可読性や単一責任の原則を損なうことにもなります。

本当にGenericsを使う必要があるのかを一度考えましょう。
もしかしたら、Genericsを使う必要はないかもしれないですよ。

おまけ: RxJSもObservableなどがGenericsです

RxJSを最近やっていますが、これもGenericsが使われています。
下記の例はGlobパターンでYAMLファイルリストを取得して一つずつJSONファイルにするロジックになります。

import fs from 'fs'
import glob from 'glob'
import yaml from 'js-yaml'
import mkdir from 'make-dir'
import { from, Observable } from 'rxjs'
import { flatMap, map } from 'rxjs/operators'

function foo (globpath: string): Observable<string> {
  return from(glob.sync(globpath)).pipe(
    map(
      (filename: string): {name: string; body: string} => {
        // @ts-ignore
        const body = yaml.safeLoad(fs.readFileSync(filename), 'utf8')

        // 拡張子をjsonに変える
        const name = filename.replace('.yml', '.json')

        return {
          name,
          body: JSON.stringify(body || {}, null, 2)
        }
      }
    ),

    // ディレクトリが存在しなければ作成
    flatMap(
      async (obj: {name: string; body: string}): Promise<{name: string; body: string}> => {
        await mkdir(path.dirname(obj.name))
        return obj
      }
    ),

    // JSONファイルを作成
    map(
      (obj: {name: string; body: string}): string => {
        try {
          fs.writeFileSync(obj.name, obj.body)
          return obj.name
        } catch (e) {
          throw new Error(e)
        }
      }
    )
  )
}

ちょっと処理の中身は雑に書いているところもありますが、RxJSを使っている場合pipeで処理をつなげることができるのですが、この例でいうと最後のmapメソッドに指定した戻り値がObservable<string>のGenerics部分と同一になります。

これを実際に呼び出してみます。

const convert = foo('path/to/**/[!_]*.yml')
convert.subscribe(
  (r: string)=>console.log(r),
  (err: Error)=>console.log(err),
  ()=>{/* complete */}
)

Observable<string>は上記の(r: string)=>console.log(r)のことを指しています。

RxJSはまたいずれ記事にしたいと思います。
今回はGenericsの紹介までに。