テンプレートリテラル型とは、バッククォートと ${} を型の位置で使い、「この形の文字列だけ」を表す型です。 たとえば `${number}px` と書くと、"16px" は通り、"16em" はコンパイル時にエラーになります。

この記事では、よく書く文字列パターンを一覧表にまとめ、通る値と通らない値を tsc のエラー全文つきで並べます。あわせて、大文字・小文字を変える4つの型、infer での分解、キーの付け替え、そして組み合わせが多すぎるとき・再帰が深すぎるときに出るエラーの規模を実測しました。

確認環境:Ubuntu 24.04 / Node.js 22.22.2 / TypeScript 7.0.2(npx tsc --version の表示は Version 7.0.2)。上限の比較には TypeScript 5.9.3 と 6.0.3 も使いました。エラー文はすべて npx tsc --noEmit --strict の出力です。

よく書く文字列パターンの早見表

表したいもの 型の書き方 通る値 通らない値
px つきの数値 `${number}px` "16px" "1.5px" "-4px" "16em" "px"
パーセント `${number}%` "50%" "half%"
# で始まる文字列 `#${string}` "#ff8800" "ff8800"
/api/ で始まるパス `/api/${string}` "/api/tasks/1" "/tasks/1"
イベントハンドラ名 `on${Capitalize<E>}` "onClick" "onclick" "onBlur"
日付(ハイフン区切り) `${number}-${number}-${number}` "2026-10-05" "2026/10/05" "2026-10"
バージョン番号 `v${number}.${number}.${number}` "v1.2.3" "v1.2"
CSS 変数名 `--${string}` "--main-color" "-main-color"
data 属性名 `data-${string}` "data-id" "id"
メールアドレス風 `${string}@${string}.${string}` "taro@example.com" "taro.example.com"
接頭辞つきの ID `user_${number}` "user_42" "user_abc"
真偽値の文字列 `${boolean}` "true" "false" "yes"
決まった語の組み合わせ `btn-${Size}-${Color}` "btn-sm-red" 組み合わせにない語

${} の中に書ける型は、string・number・bigint・boolean・null・undefined と、それらのリテラル型やユニオンです。symbol を入れると Type 'symbol' is not assignable to type 'string | number | bigint | boolean | null | undefined'.(TS2322)になります。

本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。

(記載例)出典:株式会社RJC「TypeScript テンプレートリテラル型の使い方|文字列パターンを型で縛る」

基本の書き方:${} に型を入れる

type Px = `${number}px`;          // 数値 + "px"
type HexColor = `#${string}`;     // "#" + 任意の文字列
type Greeting = `hello, ${"taro" | "hanako"}`;   // "hello, taro" | "hello, hanako"

${} に入れる型によって、結果が2種類に分かれます。

${} に入れる型 結果 例
string・number などの広い型 パターン(形に合う文字列すべて) `${number}px`
リテラル型やそのユニオン 具体的な文字列リテラル型のユニオン "hello, taro" | "hello, hanako"

前者は「形を縛る」、後者は「候補を並べる」用途です。

パターンに通る値・通らない値を tsc で確かめる

早見表のパターンを1つのファイルにまとめ、通る値と通らない値を代入しました。

// pat.ts
type Px = `${number}px`;
type HexColor = `#${string}`;
type ApiPath = `/api/${string}`;
type EventName = "click" | "focus";
type Handler = `on${Capitalize<EventName>}`;
type DateStr = `${number}-${number}-${number}`;
type Percent = `${number}%`;
type Version = `v${number}.${number}.${number}`;
type CssVar = `--${string}`;
type DataAttr = `data-${string}`;
type Mail = `${string}@${string}.${string}`;
type UserId = `user_${number}`;
type Flag = `${boolean}`;
type BigId = `${bigint}n`;

// 通る値
const p1: Px = "16px";
const p2: Px = "1.5px";
const p3: Px = "-4px";
const c1: HexColor = "#ff8800";
const a1: ApiPath = "/api/tasks/1";
const h1: Handler = "onClick";
const d1: DateStr = "2026-10-05";
const pc: Percent = "50%";
const v1: Version = "v1.2.3";
const cv: CssVar = "--main-color";
const da: DataAttr = "data-id";
const m1: Mail = "taro@example.com";
const u1: UserId = "user_42";
const f1: Flag = "true";
const b1: BigId = "10n";

// 通らない値
const p9: Px = "16em";
const p8: Px = "px";
const p7: Px = "16 px";
const c9: HexColor = "ff8800";
const a9: ApiPath = "/tasks/1";
const h9: Handler = "onclick";
const h8: Handler = "onBlur";
const d9: DateStr = "2026/10/05";
const pc9: Percent = "half%";
const v9: Version = "v1.2";
const cv9: CssVar = "-main-color";
const m9: Mail = "taro.example.com";
const u9: UserId = "user_abc";
const f9: Flag = "yes";
$ npx tsc --noEmit --strict pat.ts
pat.ts(34,7): error TS2322: Type '"16em"' is not assignable to type '`${number}px`'.
pat.ts(35,7): error TS2322: Type '"px"' is not assignable to type '`${number}px`'.
pat.ts(37,7): error TS2322: Type '"ff8800"' is not assignable to type '`#${string}`'.
pat.ts(38,7): error TS2322: Type '"/tasks/1"' is not assignable to type '`/api/${string}`'.
pat.ts(39,7): error TS2820: Type '"onclick"' is not assignable to type '"onClick" | "onFocus"'. Did you mean '"onClick"'?
pat.ts(40,7): error TS2322: Type '"onBlur"' is not assignable to type '"onClick" | "onFocus"'.
pat.ts(41,7): error TS2322: Type '"2026/10/05"' is not assignable to type '`${number}-${number}-${number}`'.
pat.ts(42,7): error TS2322: Type '"half%"' is not assignable to type '`${number}%`'.
pat.ts(43,7): error TS2322: Type '"v1.2"' is not assignable to type '`v${number}.${number}.${number}`'.
pat.ts(44,7): error TS2322: Type '"-main-color"' is not assignable to type '`--${string}`'.
pat.ts(45,7): error TS2322: Type '"taro.example.com"' is not assignable to type '`${string}@${string}.${string}`'.
pat.ts(46,7): error TS2322: Type '"user_abc"' is not assignable to type '`user_${number}`'.
pat.ts(47,7): error TS2322: Type '"yes"' is not assignable to type '"false" | "true"'.

読み取れることが3つあります。

  • 通る値の行(17〜31行目)にはエラーが出ていません。
  • 36行目の "16 px"(数値と px の間に空白)にもエラーが出ていません。 「通らない値」のつもりで書きましたが、型チェックは通りました。${number} が受け付ける範囲は次の節で詳しく見ます。
  • 39行目は TS2820 で、「"onClick" のことですか?」という提案つきです。 リテラルのユニオンに展開される型は、つづりの近い候補を教えてくれます。

${number} と ${string} が受け付ける範囲

パターン型は便利ですが、${number} は「JavaScript が数値として読める文字列」を広く受け付けます。 どこまで通るかを実際に確かめました。

// num.ts
type N = `${number}`;
const n01: N = "42";
const n02: N = "3.14";
const n03: N = "-1";
const n04: N = "1e3";
const n05: N = "0x1f";
const n06: N = "007";
const n07: N = ".5";
const n08: N = "Infinity";
const n09: N = "NaN";
const n10: N = "";
const n11: N = " 42";
const n12: N = "1,000";
const n13: N = "12";
const n14: N = "1_000";
const n15: N = "+5";
const n16: N = "5.";
type D = `${number}-${number}-${number}`;
const d1: D = "2026-13-99";
const d2: D = "1-2-3";
const d3: D = "2026-10-05";
const d4: D = "2026-10";
type S = `#${string}`;
const s1: S = "#";
const s2: S = "#zzz";
let w = "abc";
const s3: S = `#${w}`;
const s4: S = "#" + w;
$ npx tsc --noEmit --strict num.ts
num.ts(9,7): error TS2322: Type '"Infinity"' is not assignable to type '`${number}`'.
num.ts(10,7): error TS2322: Type '"NaN"' is not assignable to type '`${number}`'.
num.ts(11,7): error TS2322: Type '""' is not assignable to type '`${number}`'.
num.ts(13,7): error TS2322: Type '"1,000"' is not assignable to type '`${number}`'.
num.ts(14,7): error TS2322: Type '"12"' is not assignable to type '`${number}`'.
num.ts(15,7): error TS2322: Type '"1_000"' is not assignable to type '`${number}`'.
num.ts(22,7): error TS2322: Type '"2026-10"' is not assignable to type '`${number}-${number}-${number}`'.
num.ts(28,7): error TS2322: Type 'string' is not assignable to type '`#${string}`'.
値 ${number} に通るか
"42" "3.14" "-1" 通る
"1e3"(指数表記)、"0x1f"(16進数) 通る
"007"(先頭のゼロ)、".5"、"5."、"+5" 通る
" 42"(先頭に空白) 通る
""(空文字)、"Infinity"、"NaN" 通らない
"1,000"、"1_000"、全角の "12" 通らない

ここからわかる使い方の目安は次のとおりです。

わかったこと 設計への生かし方
日付の型は "2026-13-99" や "1-2-3" も通る 型は「区切りの形」を確かめる役。月日の範囲や桁数は実行時の検証(正規表現など)と組み合わせる
`#${string}` は "#" だけでも "#zzz" でも通る 型は「# で始まる」ことだけを確かめる役と割り切る
テンプレート文字列で組み立てた値(s3)は通る 変数を埋め込むときはバッククォートで組み立てる
+ でつないだ値(s4)は string になり通らない 文字列連結は + よりテンプレート文字列を選ぶ

テンプレートリテラル型は「打ち間違いや区切り記号の取り違えをコンパイル時に知らせる」道具 で、値の中身まで確かめたいときは実行時の検証と組み合わせる、という分担が合っています。

大文字・小文字を変える4つの型の早見表

型 働き 入力 結果
Uppercase すべて大文字に "userName" "USERNAME"
Lowercase すべて小文字に "userName" "username"
Capitalize 先頭1文字を大文字に "userName" "UserName"
Uncapitalize 先頭1文字を小文字に "UserName" "userName"

細かい入力での結果も確かめました。

式 結果 補足
Capitalize<"user name"> "User name" 変わるのは先頭の1文字だけ
Uppercase<"user_id-1"> "USER_ID-1" 記号と数字はそのまま
Capitalize<""> "" 空文字はそのまま
Capitalize<"1st"> "1st" 先頭が数字なら変化なし
Uppercase<"a" | "b"> "A" | "B" ユニオンはメンバーごとに変換
Capitalize<Lowercase<"HELLO">> "Hello" 重ねて使える
Uppercase<"straße"> "STRASSE" 文字数が変わる変換もある

公式ハンドブックには、これらの型は JavaScript の文字列メソッドをそのまま使っており、ロケール(言語ごとの大文字・小文字の規則)を考慮しない、と書かれています。

4つの型が受け取れるのは string 系の型だけです。キーの変換で使うときの書き方は「as でキーを付け替える」の節で扱います。

ユニオンを掛け合わせて候補を作る

${} にユニオンを入れると、すべての組み合わせが展開されます。

type Size = "sm" | "md" | "lg";
type Color = "red" | "blue";
type ClassName = `btn-${Size}-${Color}`;
// "btn-sm-red" | "btn-sm-blue" | "btn-md-red" | "btn-md-blue" | "btn-lg-red" | "btn-lg-blue"

3 × 2 = 6 通りの文字列リテラル型になります。Size に1つ足せば、ClassName も自動で増えます。

イベント名からハンドラ名を作る型も同じ仕組みです。

type EventName = "click" | "focus";
type Handler = `on${Capitalize<EventName>}`;   // "onClick" | "onFocus"

掛け合わせの数には上限があります。規模は後半の「TS2590」の節で実測します。

infer で文字列を分解する

条件型の中でテンプレートリテラル型に infer を置くと、文字列の一部を取り出せます。次のファイルは、そのまま保存して tsc にかけられる完成形です。

// ok.ts(分解の部分)
type Equal<X, Y> =
  (<T>() => T extends X ? 1 : 2) extends (<T>() => T extends Y ? 1 : 2) ? true : false;

type RemovePrefix<S extends string, P extends string> = S extends `${P}${infer Rest}` ? Rest : S;
type RemoveSuffix<S extends string, X extends string> = S extends `${infer Head}${X}` ? Head : S;
type Split<S extends string, D extends string> =
  S extends `${infer Head}${D}${infer Tail}` ? [Head, ...Split<Tail, D>] : [S];
type SnakeToCamel<S extends string> =
  S extends `${infer Head}_${infer Tail}` ? `${Head}${Capitalize<SnakeToCamel<Tail>>}` : S;
type CamelToSnake<S extends string> =
  S extends `${infer C}${infer Rest}`
    ? C extends Lowercase<C> ? `${C}${CamelToSnake<Rest>}` : `_${Lowercase<C>}${CamelToSnake<Rest>}`
    : S;
type PathParams<S extends string> =
  S extends `${string}:${infer Param}/${infer Rest}` ? Param | PathParams<`/${Rest}`> :
  S extends `${string}:${infer Param}` ? Param :
  never;
type Trim<S extends string> =
  S extends ` ${infer R}` ? Trim<R> : S extends `${infer L} ` ? Trim<L> : S;
type Replace<S extends string, From extends string, To extends string> =
  S extends `${infer A}${From}${infer B}` ? `${A}${To}${B}` : S;

// 型が違えば TS2322 が出る
const i01: Equal<RemovePrefix<"get_user", "get_">, "user"> = true;
const i02: Equal<RemovePrefix<"user", "get_">, "user"> = true;
const i03: Equal<RemoveSuffix<"userId", "Id">, "user"> = true;
const i04: Equal<Split<"2026-10-05", "-">, ["2026", "10", "05"]> = true;
const i05: Equal<SnakeToCamel<"created_at">, "createdAt"> = true;
const i06: Equal<SnakeToCamel<"user_first_name">, "userFirstName"> = true;
const i07: Equal<SnakeToCamel<"id">, "id"> = true;
const i08: Equal<CamelToSnake<"userFirstName">, "user_first_name"> = true;
const i09: Equal<PathParams<"/users/:userId/posts/:postId">, "userId" | "postId"> = true;
const i10: Equal<PathParams<"/users/:id">, "id"> = true;
const i11: Equal<PathParams<"/users">, never> = true;
const i12: Equal<Trim<"  hello ">, "hello"> = true;
const i13: Equal<Replace<"a-b-c", "-", "_">, "a_b-c"> = true;

npx tsc --noEmit --strict ok.ts はエラーなしで終わりました。Equal 型は「2つの型が一致すれば true 型」になるもので、一致しなければ Type 'true' is not assignable to type 'false'(TS2322)で止まります。

型 入力 結果
RemovePrefix "get_user" から "get_" "user"
RemoveSuffix "userId" から "Id" "user"
Split "2026-10-05" を "-" で ["2026", "10", "05"]
SnakeToCamel "user_first_name" "userFirstName"
CamelToSnake "userFirstName" "user_first_name"
PathParams "/users/:userId/posts/:postId" "userId" | "postId"
Trim " hello " "hello"
Replace "a-b-c" の "-" を "_" に "a_b-c"(最初の1か所)

接頭辞を取り除く

type RemovePrefix<S extends string, P extends string> = S extends `${P}${infer Rest}` ? Rest : S;

「P で始まるなら、残りを Rest として取り出す。始まらなければ元のまま」 と読みます。

snake_case を camelCase に変える

type SnakeToCamel<S extends string> =
  S extends `${infer Head}_${infer Tail}` ? `${Head}${Capitalize<SnakeToCamel<Tail>>}` : S;

最初の _ で前後に分け、後ろ側を再帰で変換してから先頭を大文字にしてつなぎます。infer が2つ並ぶと、前の infer はできるだけ短く切られる ので、Head には最初の _ の手前までが入ります。

パスパラメータを取り出す

type PathParams<S extends string> =
  S extends `${string}:${infer Param}/${infer Rest}` ? Param | PathParams<`/${Rest}`> :
  S extends `${string}:${infer Param}` ? Param :
  never;

1行目は「: の後ろにまだ / が続く」場合、2行目は「: の後ろが末尾まで」の場合です。取り出した名前をユニオンにまとめるので、パラメータがなければ never になります。

as でキーを付け替える

Mapped Types(キーを1つずつ回して新しいオブジェクト型を作る書き方)の as に、テンプレートリテラル型を書くと、キーの名前を変えた型を作れます。

// ok.ts(キーの付け替えの部分。Equal と SnakeToCamel は上と同じ)
type Task = { id: number; title: string; done: boolean };

type Getters<T> = { [K in keyof T & string as `get${Capitalize<K>}`]: () => T[K] };
type Setters<T> = { [K in keyof T & string as `set${Capitalize<K>}`]: (value: T[K]) => void };
type Prefixed<T, P extends string> = { [K in keyof T & string as `${P}${K}`]: T[K] };
type ChangeEvents<T> = { [K in keyof T & string as `${K}Changed`]: (next: T[K], prev: T[K]) => void };
type Camelize<T> = { [K in keyof T & string as SnakeToCamel<K>]: T[K] };
type OnlyDataAttrs<T> = { [K in keyof T as K extends `data-${string}` ? K : never]: T[K] };

const m01: Equal<Getters<Task>,
  { getId: () => number; getTitle: () => string; getDone: () => boolean }> = true;
const m02: Equal<Setters<Task>,
  { setId: (value: number) => void; setTitle: (value: string) => void; setDone: (value: boolean) => void }> = true;
const m03: Equal<Prefixed<Task, "task_">, { task_id: number; task_title: string; task_done: boolean }> = true;
const m04: Equal<keyof ChangeEvents<Task>, "idChanged" | "titleChanged" | "doneChanged"> = true;
const m05: Equal<Camelize<{ user_id: number; created_at: string }>, { userId: number; createdAt: string }> = true;
const m06: Equal<OnlyDataAttrs<{ "data-id": string; "data-role": string; title: string }>,
  { "data-id": string; "data-role": string }> = true;
型 元のキー 変換後のキー
Getters id, title, done getId, getTitle, getDone
Setters id, title, done setId, setTitle, setDone
Prefixed("task_") id, title, done task_id, task_title, task_done
ChangeEvents id, title, done idChanged, titleChanged, doneChanged
Camelize user_id, created_at userId, createdAt
OnlyDataAttrs data-id, data-role, title data-id, data-role(title は除かれる)

as の結果を never にすると、そのキーは取り除かれます。 OnlyDataAttrs は、パターンに合うキーだけを残す例です。

keyof T & string と書く理由(TS2344)

& string を付けずに書くと、次のエラーになります。

// strkey.ts
type Task = { id: number; title: string };
type Getters1<T> = { [K in keyof T as `get${Capitalize<K>}`]: () => T[K] };
strkey.ts(2,56): error TS2344: Type 'K' does not satisfy the constraint 'string'.
  Type 'keyof T' is not assignable to type 'string'.
    Type 'string | number | symbol' is not assignable to type 'string'.
      Type 'number' is not assignable to type 'string'.

keyof T には number や symbol のキーも含まれうるため、string だけを受け取る Capitalize へそのまま渡せません。keyof T & string で string のキーに絞る と通ります。

コピペ用:パスとイベント名に型を付けて動かす

ここまでの型を、実行できるコードにまとめました。

// route.mts
// パスの文字列から、必要なパラメータの型を作る
type PathParams<S extends string> =
  S extends `${string}:${infer Param}/${infer Rest}` ? Param | PathParams<`/${Rest}`> :
  S extends `${string}:${infer Param}` ? Param :
  never;

function buildPath<P extends string>(pattern: P, params: Record<PathParams<P>, string | number>): string {
  const table = params as Record<string, string | number>;
  return pattern.replace(/:([A-Za-z0-9_]+)/g, (_, key: string) => String(table[key]));
}

console.log(buildPath("/users/:userId/posts/:postId", { userId: 7, postId: 120 }));
console.log(buildPath("/tags/:name", { name: "typescript" }));

// イベント名から、ハンドラ名の型を作る
type EventName = "click" | "focus" | "change";
type HandlerName = `on${Capitalize<EventName>}`;

function toHandlerName<E extends EventName>(event: E): `on${Capitalize<E>}` {
  return `on${event[0]!.toUpperCase()}${event.slice(1)}` as `on${Capitalize<E>}`;
}

const names: HandlerName[] = [toHandlerName("click"), toHandlerName("change")];
console.log(names);
$ npx tsc --noEmit --strict --target es2022 --module nodenext route.mts
$ node route.mts
/users/7/posts/120
/tags/typescript
[ 'onClick', 'onChange' ]

Node.js 22.22.2 は、型注釈だけの TypeScript ファイルをそのまま実行できます。

パラメータが足りない呼び出しと、余分な呼び出しを tsc にかけると、パスの文字列から作った型が引数を確かめている ことがわかります。

buildPath("/users/:userId/posts/:postId", { userId: 7 });
buildPath("/users/:userId", { userId: 7, postId: 1 });
route-ng.mts(7,43): error TS2741: Property 'postId' is missing in type '{ userId: number; }' but required in type 'Record<"postId" | "userId", string | number>'.
route-ng.mts(8,42): error TS2353: Object literal may only specify known properties, and 'postId' does not exist in type 'Record<"userId", string | number>'.

toHandlerName の戻り値は、実行時の文字列操作の結果を型に合わせるために as を使っています。型と実装が同じ変換をしていることは、出力(onClick、onChange)で確かめられます。

組み合わせが多すぎるとき:TS2590 が出る規模

ユニオンを掛け合わせた結果が大きくなりすぎると、TS2590 が出ます。

// digits.ts
type D = "0" | "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9";
type D4 = `${D}${D}${D}${D}`;          // 10,000 通り
type D5 = `${D}${D}${D}${D}${D}`;      // 100,000 通り
type D6 = `${D}${D}${D}${D}${D}${D}`;  // 1,000,000 通り
const a: D4 = "2026";
const b: D5 = "20261";
const c: D6 = "202610";
$ npx tsc --noEmit --strict digits.ts
digits.ts(3,11): error TS2590: Expression produces a union type that is too complex to represent.
digits.ts(4,11): error TS2590: Expression produces a union type that is too complex to represent.

4桁(1万通り)は通り、5桁(10万通り)からエラーになりました。境目を詳しく知るために、a 個のユニオンと b 個のユニオンを掛け合わせる型を生成して、3つの版で比べました。

掛け合わせ 組み合わせ数 5.9.3 6.0.3 7.0.2
10 の 4 乗 10,000 通る 通る 通る
300 × 300 90,000 通る 通る 通る
316 × 316 99,856 通る 通る 通る
369 × 271 99,999 通る 通る 通る
250 × 400 100,000 TS2590 TS2590 TS2590
100 × 1,000 100,000 TS2590 TS2590 TS2590
317 × 317 100,489 TS2590 TS2590 TS2590

3つの版とも、99,999 通りまでは通り、100,000 通りから TS2590 になりました。 掛け合わせる個数の内訳(250×400 か 100×1,000 か)による違いはなく、結果の総数で決まっています。

直し方:掛け合わせずに表す

状況 書き換え先
「数字の並び」などを表したい `${number}` のようなパターン型にする(展開されない)
1文字ずつ確かめたい 再帰の条件型で判定する(下のコード)
候補の一覧そのものが必要 掛け合わせる数を減らすか、候補をあらかじめ生成して書いておく

3つ目について、公式ハンドブックは「大きな文字列ユニオンには事前生成を勧める。掛け合わせは小さな規模で便利」という趣旨を述べています。

1文字ずつ判定する書き方は次のとおりです。ユニオンを作らないので、桁数が増えても TS2590 になりません。

// fix.ts(後半)
type D = "0" | "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9";
type IsDigits<S extends string> =
  S extends "" ? true : S extends `${D}${infer Rest}` ? IsDigits<Rest> : false;
function digits<S extends string>(s: IsDigits<S> extends true ? S : never): S { return s; }
const c1 = digits("202610");
const c2 = digits("2026-1");
fix.ts(18,19): error TS2345: Argument of type '"2026-1"' is not assignable to parameter of type 'never'.

数字だけの "202610" は通り、ハイフンの入った "2026-1" だけがエラーになりました。

再帰が深すぎるとき:TS2589 が出る規模

文字列を1文字ずつ処理する再帰型は、長い文字列を渡すと TS2589 になります。

r.ts(2,10): error TS2589: Type instantiation is excessively deep and possibly infinite.

上限は、再帰の書き方によって大きく変わります。 次の2つの Reverse(文字列を逆順にする型)と Split で、何文字(何要素)まで通るかを3つの版で測りました。

// 書き方 A:再帰の結果を、さらにテンプレートの中で使う
type Reverse<S extends string> =
  S extends `${infer C}${infer Rest}` ? `${Reverse<Rest>}${C}` : "";

// 書き方 B:途中の結果を Acc に積み、再帰の呼び出しをそのまま返す
type Reverse<S extends string, Acc extends string = ""> =
  S extends `${infer C}${infer Rest}` ? Reverse<Rest, `${C}${Acc}`> : Acc;
型 通った最大 エラーになった最小 5.9.3 6.0.3 7.0.2
Reverse(書き方 A) 48 文字 49 文字 同じ 同じ 同じ
Split(結果をタプルに展開する書き方) 49 要素 50 要素 同じ 同じ 同じ
Reverse(書き方 B) 999 文字 1,000 文字 同じ 同じ 同じ

書き方 A は 50 回前後、書き方 B は 1,000 回手前までで、3つの版に差はありませんでした。

この違いは、TypeScript 4.5 のリリースノートに説明があります。条件型の分岐が「別の条件型をそのまま返すだけ」で、結果に手を加えない形(末尾再帰)のとき、TypeScript は深さの制限にかかりにくい形で処理します。結果をユニオンやテンプレートの中で使う書き方は対象外で、リリースノートは Acc のような引数を足して書き換える方法を示しています。

直し方:Acc に積む形へ書き換える

前の節の Split を書き換えると、200 要素でも通るようになります。

// fix.ts(前半)
// 結果を Acc に積みながら進む書き方(再帰の呼び出しが式の一番外側にある)
type Split<S extends string, D extends string, Acc extends string[] = []> =
  S extends `${infer H}${D}${infer T}` ? Split<T, D, [...Acc, H]> : [...Acc, S];

type Csv = "x,x,x,…(x を 200 個)…,x";
const n: Split<Csv, ",">["length"] = 200;

Csv に x を 200 個カンマで並べた文字列を入れて確かめ、エラーなしで通りました。Split<"a,b,c", ","> の結果が ["a", "b", "c"] になることも Equal 型で確かめています。

書き方 見分け方 上限の目安(実測)
結果を加工する 再帰の呼び出しが `${...}` や [...] の中にある 50 回前後
そのまま返す 再帰の呼び出しが分岐の結果そのもの 1,000 回手前

エラー番号から直し方を引く

番号 メッセージ(要約) 原因 直し方
TS2322 Type '"16em"' is not assignable to type ... 値がパターンに合わない 値を直す。string 型の変数ならテンプレート文字列で組み立てる
TS2820 ... Did you mean '"onClick"'? ユニオンの候補とつづりが近い 提案された候補に直す
TS2344 Type 'K' does not satisfy the constraint 'string' keyof T を Capitalize などへ直接渡した keyof T & string に絞る
TS2590 union type that is too complex to represent 掛け合わせが 100,000 通り以上 パターン型か、1文字ずつの判定に変える
TS2589 excessively deep and possibly infinite 再帰が深すぎる Acc に積む形へ書き換える
TS2741 / TS2353 Property ... is missing / may only specify known properties 文字列から作ったキーと引数が合わない 足りないキーを足す、余分なキーを外す

パターン型とユニオン展開の選び方

観点 パターン型(string・number を入れる) ユニオン展開(リテラルを入れる)
例 `${number}px` `btn-${Size}-${Color}`
候補の数 数えられない 掛け合わせた数(99,999 まで)
つづりの提案(TS2820) 出ない 出る
値の中身の確認 形だけ 候補と完全一致
向いている用途 単位つき数値、接頭辞、区切り記号の確認 クラス名、イベント名、キー名

よくある質問

Q. テンプレートリテラル型は実行時にも検証してくれますか? A. 型は実行時には残らないため、コンパイル時だけの確認です。外部から届く文字列は、正規表現などで実行時に確かめ、その結果を型に反映する関数(型ガード)と組み合わせます。次の書き方で、if の中では input を Px として扱えることを確かめています。

type Px = `${number}px`;
function isPx(s: string): s is Px { return /^-?\d+(\.\d+)?px$/.test(s); }

declare const input: string;
if (isPx(input)) { const p: Px = input; }   // 通る
const q: Px = input;                         // TS2322: Type 'string' is not assignable to type '`${number}px`'.

Q. string 型の変数を、パターン型の変数に入れるには? A. string のままでは TS2322 になります。#${w} のようにテンプレート文字列で組み立てるか、実行時に形を確かめた後で型を絞り込みます。+ での連結は結果が string になるため通りません。

Q. ${number} で桁数を指定できますか? A. ${number} 自体には桁数の指定がありません。「数字だけ」を確かめたいときは、この記事の IsDigits のように1文字ずつ判定する型を使います。

Q. Capitalize は日本語や記号に使うとどうなりますか? A. 先頭の文字に大文字・小文字の区別がなければ、そのままです。この記事では先頭が数字の "1st" と空文字で、変化しないことを確かめています。

まとめ

  • テンプレートリテラル型は、バッククォートと ${} で「文字列の形」を型にする。 string や number を入れるとパターン、リテラルのユニオンを入れると候補の一覧になる
  • ${number} は指数表記・16進数・先頭の空白も受け付ける。 型は区切りや接頭辞の確認に使い、値の中身は実行時の検証と組み合わせる
  • Uppercase・Lowercase・Capitalize・Uncapitalize で大文字と小文字を変えられる。 キーに使うときは keyof T & string に絞る
  • infer を置くと、接頭辞の除去・snake_case から camelCase・パスパラメータの抽出ができる
  • as にテンプレートリテラル型を書くと、キーを付け替えた型が作れる。 never を返せばキーを除ける
  • 掛け合わせは 99,999 通りまで。100,000 通りから TS2590(5.9.3・6.0.3・7.0.2 で同じ)
  • 再帰は、結果を加工する書き方で 50 回前後、Acc に積む書き方で 1,000 回手前まで。 TS2589 が出たら Acc に積む形へ書き換える

参考

  • TypeScript Handbook「Template Literal Types」: https://www.typescriptlang.org/docs/handbook/2/template-literal-types.html
  • TypeScript 4.5 リリースノート(Tail-Recursion Elimination on Conditional Types): https://www.typescriptlang.org/docs/handbook/release-notes/typescript-4-5.html

本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。

(記載例)出典:株式会社RJC「TypeScript テンプレートリテラル型の使い方|文字列パターンを型で縛る」

株式会社RJC ― SI事業・SES事業・AI駆動開発。RJCは一緒に成長を楽しめる会社です。

WE ARE HIRING

RJCで一緒に開発しながら、
成長を楽しみませんか?

RJCは、Web・モバイル・AIを活用した開発プロジェクトで、テックリードやPM・PMOも活躍するシステム開発会社です。会社を知る、待遇を確かめる、話を聞いてみる。気になるところ見てみてください!

  • 127日年間休日
  • 12時間平均残業時間
  • 毎日ガチャ遊びココロも大切にする福利厚生。アマギフなどの賞品ラインナップ!

ほかにも、チケットレストラン、書籍読み放題、2年ごとの慰労報奨(休暇 or 金一封)、11期連続の黒字決算。

ABOUT RJC RJCがどんな会社か知る 考え方、研修、働き方、福利厚生、社員の前職まで。RJCのことが丸わかり! RJC丸わかりページへ JOB DESCRIPTION 仕事内容・待遇を見てみる 仕事内容、給与・待遇、選考の流れ。経験者も未経験も!応募前に知りたいこと、まとめました! 募集要項を見る ENTRY エントリーする エントリーは1〜2分・履歴書不要です。まずは話を聞いてみたい、という方でも歓迎です! エントリーフォームへ

RJCで一緒に開発しながら、 成長を楽しみませんか?