- TypeScript
TypeScript テンプレートリテラル型の使い方|文字列パターンを型で縛る
テンプレートリテラル型とは、バッククォートと ${} を型の位置で使い、「この形の文字列だけ」を表す型です。 たとえば `${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期連続の黒字決算。