ミツモア Tech blog

「ミツモア」を運営する株式会社ミツモアの技術ブログです

Template Literal Type っていつ使うの?

※ こちらはミツモアAdvent Calendar 2022の6日目の記事です。

こんにちは、ミツモア エンジニア の坂本(@ryusaka)です。今年もアドベントカレンダーの季節がやってきました。昨年の記事については ミツモアAdvent Calendar 2021 こちらを見てみてください。

今回は、TypeScript 4.1 から導入されている Template Literal Type について具体例を交えて紹介しようと思います。

Template Literal Type って?

そのままテンプレート文字列の型なのですが、 string 型の拡張です。

よく使うのは以下のような型だと思いますが、これは String Literal です。

type Animal = 'dog' | 'cat' | 'tiger'

他にも Literal Type は数値などにも利用できますね。

type LessThanFive = 1 | 2 | 3 | 4

今回お話しする Template Literal はもう少し発展した型で、stringを構成する文字を制限できます。

例えば以下の簡単な例であれば、文字列の先頭が animal_ から始まることをチェックできます。

type AnimalName = `animal_${string}`
const cat: AnimalName = 'animal_cat' // OK
const wrongDog: AnimalName = 'dog' // Error

あんまり使い所なさそうって思いましたか?

私も最初はそう思っていたのですが、Template Literal の本領は Conditional Type において存分に発揮されます。

例えばよく書かれる型であれば、caseを変換する型などがあります。

このような型が生きるシチュエーションとして、アプリケーション内ではPascalCaseをキーに使うけれど外部からやってくる値はsnake_caseをキーにしている場合などに型情報を生かしたまま利用できます

まず snake_case を PascalCase に変換してくれる型を作ります。 Capitalize は TypeScript が用意している型で、文字列の先頭を大文字にしてくれます。

type SnakeToPascal<T extends string> = T extends `${infer F}_${infer R}`
  ? `${Capitalize<F>}${SnakeToPascal<R>}`
  : Capitalize<T>

次に、オブジェクトの key を snake_key から PascalCase に変換してくれる型を作ります。

type ConvertKey<O> = {
  [K in Extract<keyof O, string> as SnakeToPascal<K>]: O[K]
}
  1. 型Oを受け取る
  2. オブジェクトのキーを K in Extract<keyof O, string> as SnakeToPascal<K> として定義
    1. keyofnumber | symbol | string を型として返すので、Extract<keyof O, string> で string のみに絞っています。
    2. K では snake_case なままなので、 as SnakeToPascal<K> でキャストしています。
  3. オブジェクトのバリューを O[K] で定義
    1. こちらは元のオブジェクトから値を取ってくるので K のままでOKです。

これを使って関数を作ります。

const snakeKeyToPascalKey = <O>(obj: O): ConvertKey<O> => {
  const converted = snakeToPascal(obj) // 実際にキーを変換する実装は省略
  return converted
}

実際に使ってみると、結果は以下のようになります。キーは this_is_a_stringThisIsAString のように変換され、バリューはそれぞれの型を継承できています。

const result = snakeKeyToPascalKey({
  this_is_a_string: 'pen',
  this_is_an_object: { a: 1, b: 2 },
})

result.ThisIsAString // string
result.ThisIsAnObject // { a: number, b: number }

result.this_is_a_string // エラー

全体のコードはこちら

Template Literal Type は可能性の塊なので、書こうと思えば以下のような複雑な型も書けたりします。

文字列化された数値の桁数をチェックする型 CheckDigit です。 基本的にTypeScriptは inferextends キーワードを組み合わせることでかなり自由に型を書くことができます。

/**
 * 0-9を定義
 */
type Digit = '1' | '2' | '3' | '4' | '5' | '6' | '7' | '8' | '9' | '0'

/**
 * 文字列のままだとlength比較できないので受け取った文字列を配列に変更
 */
type StringToArray<
  S extends string,
  L extends Digit[] = [],
> = S extends `${infer F}${Digit}` ? StringToArray<F, [...L, Digit]> : L

/**
 * テスト
 */
type A = StringToArray<'123'> // [Digit, Digit, Digit]

/*
 * 桁数が一致しているかを判定する
 */
type CheckDigit<
  N extends number,
  L extends Digit[] = [],
> = N extends L['length'] ? true : false

type B = CheckDigit<3, A> // true
type C = CheckDigit<2, A> // false

/**
 * 桁数が一致してたらそのまま受け取った文字列を返し、一致していなかったらneverを返す
 */
type RestrictDigits<S extends string, D extends number> = CheckDigit<
  D,
  StringToArray<S>
> extends true
  ? S
  : never

const numberStringWithExactDigits = <S extends string, D extends number>(
  digits: D,
  str: RestrictDigits<S, D>,
): string => str

numberStringWithExactDigits(3, '123') // '123'
numberStringWithExactDigits(3, 'あいう') // Error Argument of type 'string' is not assignable to parameter of type 'never'
numberStringWithExactDigits(3, '1234') // Error
numberStringWithExactDigits(3, '12') // Error

複雑な型は再帰処理を利用していることが多く、再帰処理の深さの制限がTypeScript自体に存在するためあまりにも複雑な型だと利用できないことはあり得ますが、それ以外はあらゆる型が書けるのではないでしょうか。

全体のコードはこちら

ミツモアでの実際の利用例

ミツモアの2つのプロダクトのうちの1つであるMeetsoneでの実際のユースケースを紹介します。

登録してあるデータをCSVで出力する機能があるのですが、処理の過程でDBの幾つものテーブルからデータを取得してフォーマットし、一つのオブジェクトにまとめる処理があります。

データの出どころをわかりやすくするため、 job テーブルのデータなら jobXXXX というキー名のルールにしてあります。

interface CsvRow {
  jobName: string
  jobAddress: string
  clientName: string
}

const obj: CsvRow = {
  jobName: '仕事名',
  jobAddress: '仕事住所',
  clientName: '顧客名',
}

上記の例であれば仕事名と仕事住所はJobテーブルから、顧客名はClientテーブルから取得しています。

一つの関数内で全てのテーブルの処理をするとごちゃごちゃしてしまうので、テーブルごとにフォーマットする関数を分けています。どの関数がどのキーの項目を処理するかを示すのに Template Literal を利用しています。

const formatJob = (row): Pick<CsvRow, Extract<keyof CsvRow, `job${string}`>> => {
  return {
    jobName: row.job.name,
    jobAddress: row.job.address,
  }
}

const formatClient = (row): Pick<CsvRow, Extract<keyof CsvRow, `client${string}`>> => {
  return {
    clientName: row.client.name,
  }
}

const obj: CsvRow = {
  ...formatJob(row),
  ...formatClient(row),
}

このように型を定義すると、新たに CsvRowclientPhone というフィールドが追加された時 objformatClient で型エラーが起きてくれて対応漏れを防ぐことができます。また、この機能にそこまで詳しくない人が実装してもどこに実装すればいいかもわかりやすくなります。(誤って formatJobclientPhone を足してしまうことがない)

ここで最初の例で出てきたCamelCaseの話と繋がってきたりしそうですね!?そんな感じで型安全な世界が広がっていくのでとても良いです。

まとめ

TypeScript で利用できる Template Literal Type とそのユースケースを紹介しました。 文字列のフォーマットを string よりもはるかに厳密に定義することができることがわかりました。

Template Literal Type はとても便利な型ですが、あまりにも複雑な型だと作者以外がいじることのできない負債になる可能性もあると言えるので、用法用量を守って使っていきましょう!

最後に

ミツモアでは様々な職種のエンジニアを積極的に採用しています! ご興味がある方はぜひ気軽に面談しましょう!