Template Literal Types の基礎と .join・.split メソッドの型定義

11 min read読了の目安(約6700字

はじめに

本記事では、TypeScript 4.1 で追加予定の Template Literal Types の基本的な機能を説明し、文字列結合・分割の Array.prototype.joinString.prototype.split メソッドを型推論可能な関数として型定義したコードを紹介します。
Template Literal Types は結合・分割を理解すれば8割は使いこなせると思うので、型レベルで文字列操作をしたい方にはぜひお読みいただきたいです。

★ TypeScript での実行結果・型推論結果はインラインコメント内に // ts: xxx として表記します。

Template Literal Types とは

Template Literal Types についての簡単な紹介や開発環境の構築については下記の記事をご覧ください。

TypeScript 4.1 の Template Literal Types を試してみよう | Zenn

また、詳しい仕様については Pull Request も併せてご覧ください。

Template literal types and mapped type 'as' clauses by ahejlsberg · Pull Request #40336 · microsoft/TypeScript

Template Literal Types でできること

Template Literal Types は主に文字列の結合と抽出を行う機能です。

文字列の結合

基本的な機能

文字列の結合では JavaScript の Template Literal とほぼ同様の機能が型レベルでサポートされました。

type Year = '2020';
type Month ='05';
type Date ='15';

type UTCFullDate = `${Year}-${Month}-${Date} UTC`;
// ts: '2020-05-15 UTC'

このうち、別の型を埋め込んでいる ${Type} の部分を Placeholder と呼びます。

Placeholderに指定できる型は stringnumberbooleanbigint のみとなります。
それ以外の型や boolean 以外の型を直接指定するとエラーとなります。

type StrHello = `${'hello'}` // ts: 'hello'
type Str = `${string}` // ts: Template literal type argument 'string' is not literal type or a generic type.
type True = `${true}`; // ts: 'true'
type Bool = `${boolean}`; // ts: 'true' | 'false'
type Num100 = `${100}`; // ts: '100'
type Num = `${number}` // ts: Template literal type argument 'number' is not literal type or a generic type.
type BigInt100n = `${100n}`; // ts: '100'
type BigInt = `${bigint}`; // ts: Template literal type argument 'bigint' is not literal type or a generic type.
type Obj = `${Record<string, unknown>}` // ts: Type 'Record<string, unknown>' is not assignable to type 'string | number | bigint | boolean'.

また、 Placeholder 内で共用体型を使用した場合、推論される型は考えられるすべてのパターンとなります。

type UnionString = `${0|1}-${0|1}`;
// ts: '0-0' | '0-1' | '1-0' | '1-1'

なお、関数の返り値などで Template Literal Types を使用する場合、返却値の型だけでなく返却値そのもののアサーションも必要になります。
これは、Template Literal の型が string と推論されてしまうためです。

// NG
type EventName<T extends string> = `on${T}`;

function getEventName<T extends string>(name: T): EventName<T> {
  return `on${name}`;
  // ts: Type 'string' is not assignable to type '`on${T}`'.
}
// OK
type EventName<T extends string> = `on${T}`;

function getEventName<T extends string>(name: T): EventName<T> {
  return `on${name}` as EventName<T>;
}

大文字・小文字の変換

Template Literal Types では、キャピタライズ(大文字・小文字の変換)もサポートしています。

キャピタライズは Placeholder で修飾子 uppercaselowercasecapitalizeuncapitalize を使用することで表現できます。

// すべての文字を大文字にする
type Uppercase = `${uppercase 'HelloWorld'}`; // ts: 'HELLOWORLD'
// すべての文字を小文字にする
type Lowercase = `${lowercase 'HelloWorld'}`; // ts: 'helloworld'
// 最初の文字だけ大文字にする
type Capitalize = `${capitalize 'helloWorld'}`; // ts: 'HelloWorld'
// 最初の文字だけ小文字にする
type Uncapitalize = `${uncapitalize 'HelloWorld'}`; // ts: 'helloWorld'

文字列の抽出

文字列の抽出では Conditional Types による型推論との組み合わせで、文字列の一部を新たな型として抽出する機能がサポートされました。

次の例では、YYYY-MM-DD 形式の文字列から YYYYMMDD をそれぞれ YMD の型として抽出しています。

type FullDate = '2020-05-15';

type ParsedDate = FullDate extends `${infer Y}-${infer M}-${infer D}` ? [Y, M, D] : [];
// ts: ['2020', '05', '15']

Placeholder で infer T の形式で推論された型を使用する型名を指定しています。

文字列の抽出は最短マッチのため、上記の例で '0-2020-05-15' のような型を指定すると ['0', '2020', '05-15'] が返されます。
(最後の infer D が残りのすべてにマッチしてしまう)

また、Placeholder で型の判定はできないため、値が期待したものかどうかは別途判定する必要があります。

型推論可能な .join / .split メソッドの型定義

Template Literal Types を使った実用例として、 Array.prototype.joinString.prototype.split メソッドを型推論可能な関数として型定義したコードを説明します。

Array.prototype.join

JavaScript の Array.prototype.join メソッドを、実行結果の型推論が可能な関数として実装してみます。

type PlaceholderType = string | number | boolean | bigint;

type Join<T extends readonly PlaceholderType[], S extends string> =
  T extends readonly [infer P, ...infer R]
    ? P extends PlaceholderType
      ? [] extends R
        ? P
        : `${P}${S}${Join<R, S>}`
      : ''
    : [] extends T
      ? ''
      : string;

function join<T extends readonly PlaceholderType[], S extends string = ','>(
  array: T,
  separator?: S
) {
  return array.join(separator) as Join<T, S>;
}
join(['2020', '05', '15'] as const, '-'); // ts: '2020-05-15'
join(['Hello', 'world'] as const); // ts: 'Hello,World'

Playground

Join 型では、配列の要素ごとに再帰的に文字列の結合を行っています。

[infer P, ...infer R] で参照した配列の最初の要素 P と後続の要素 R から生成された文字列とをセパレータ S で結合しています。

`${P}${S}${Join<R, S>}`

String.prototype.split

JavaScript の String.prototype.split メソッドを、実行結果の型推論が可能な関数として実装してみます。

type Split<T extends string, S extends string> =
  T extends `${infer P}${S}${infer R}`
    ? string extends P
      ? [P]
      : [P, ...Split<R, S>]
    : [T];

function split<T extends string, S extends string>(
  value: T,
  separator: S
) {
  return value.split(separator) as Split<T, S>;
}
split('2020-05-15', '-'); // ts: ['2020', '05', '15']
split('Hello World', ''); // ts: ['H', 'e', 'l', 'l', 'o', ' ', 'W', 'o', 'r', 'l', 'd']

Playground

Split 型では、Placeholder によるマッチが最短マッチであることを利用して、セパレータ P で指定した文字列までのチャンクを抜き出し再帰的に配列を定義しています。

  // セパレータ P までの文字列 P と後続のすべての文字列 R
  T extends `${infer P}${S}${infer R}`

続く Conditional Types は抜き出した文字列 Pstring 型ではないかを判定しています。
string extends P は文字列 Pstring 型の場合 True に、文字列リテラルの場合 False になります。
この判定を含めている理由は、Pstring 型の場合に無限ループしてしまわないようにするためです。

// NG
type Split<T extends string, S extends string> =
  T extends `${infer P}${S}${infer R}`
    // P が string型 の場合、常に True になるため無限ループが発生する
    ? [P, ...Split<R, S>]
    // ts: Type instantiation is excessively deep and possibly infinite.
    : [T];