TypeScript 4.1で密かに追加されたintrinsicキーワードとstring mapped types

7 min読了の目安(約7100字TECH技術記事

TypeScript 4.1では、Mapped typesにおけるkey remappingやtemplate literal typesに付随する新機能として、標準ライブラリにUppercaseなどの型が追加されました。

// type Str = "FOOBAR"
type Str = Uppercase<"foobar">;

上の例から分かるように、Uppercase型は一つの文字列を受け取る型関数で、文字列のリテラル型を渡すとその文字列中の小文字を全て大文字にした文字列のリテラル型が返ります。他にも、LowercaseCapitalize, Uncapitalizeがあります。

これらの型は標準ライブラリ(lib/es5.d.ts)にその定義があります。そこで使われているのがintrinsicキーワードなのです。以下はTypeScript 4.1時点の標準ライブラリからの引用です。

/**
 * Convert string literal type to uppercase
 */
type Uppercase<S extends string> = intrinsic;

/**
 * Convert string literal type to lowercase
 */
type Lowercase<S extends string> = intrinsic;

/**
 * Convert first character of string literal type to uppercase
 */
type Capitalize<S extends string> = intrinsic;

/**
 * Convert first character of string literal type to lowercase
 */
type Uncapitalize<S extends string> = intrinsic;

intrinsicキーワードとは

これらの型定義を見ると、なんだか不思議な定義となっていますね。=の右が全てintrinsicとなっています。これは実装がコンパイラの内部実装として隠蔽されていることを意味しています。言い換えれば、TypeScriptの通常の型定義では表現できないような特殊な機能を表現するものです。JavaScriptをお使いの方は、組み込み関数の中身を表示しようとするとfunction log() { [native code] }のように表示されて関数の中身が隠されていたという経験がおありでしょう。それと似たようなものだと考えましょう。

intrinsicキーワードの必要性

TypeScript 4.1では文字列の大文字・小文字変換の機能を実装することになりましたが、実は当初の実装ではintrinsicを使っていませんでした。代わりに、次のようにuppercaselowercasecapitalizeuncapitalizeという4つのキーワードによる新たな構文を定義され、それらを用いる方式となっていました(#40336)。

// 古い方式
// type Str = "FOOBAR"
type Str = uppercase "foobar";

この古い方法の欠点は、新しい組み込み型関数を実装するたびに構文が増えてしまうことにありました。古い方法では、4種類の文字列操作を実現するためだけに4種類の新しい構文が追加されています。TypeScript(あるいはTypeScriptの新機能の実装を主に担うAnders Hejlsberg氏)はミニマルな機能で様々なユースケースに対応できるような機能設計を好む傾向にあり、文字列処理のためだけに存在する4種類の新しい構文というのはそれに逆行してしまうものでした。

代替となるintrinsicキーワードでは、新しい構文はintrinsicキーワードという1つだけでミニマルです。また、将来的な機能拡張もこのintrinsicキーワードで対応できます。

intrinsicキーワードの挙動

TypeScript 4.1からはintrinsicが新たにキーワードとなりました。よって、TypeScript 4.1では次のようなコードはコンパイルエラーとなります(これはTypeScript 4.0では動作しました)。ちなみに、type intrinsic = ...はエラーにならずに単に無視されるようです。

type intrinsic = string;
// エラー: The 'intrinsic' keyword can only be used to declare compiler provided intrinsic types.
type A = intrinsic;

intrinsicはコンパイラ内部で定義される特殊な型ですが、その実態はintrinsicに与えられた型名型引数の数によって決められます。例えば、次のようにすると自分でintrinsicを使うことができます。

function foo() {
    type Uppercase<T> = intrinsic;
    // type T = "FOO"
    type T = Uppercase<"foo">
}

わざわざ関数fooの中に入れているのはグローバルに定義されているUppercaseと重複しないように別のスコープとするためです。このように、Uppercaseという名前で型引数を1つ持つ型エイリアス(type宣言)をintrinsicと宣言することにより、コンパイルエラーを起こさずにintrinsicを使うことができます。この処理はTypeScriptコンパイラの中で次のように書かれています(TypeScriptのコミットeca8957430bf52cfdfa2bfe03c9431682da6a558src/compiler/checker.tsから引用。以降の引用も同じ)。

const type = getDeclaredTypeOfSymbol(symbol);
if (type === intrinsicMarkerType && intrinsicTypeKinds.has(symbol.escapedName as string) && typeArguments && typeArguments.length === 1) {
    return getStringMappingType(symbol, typeArguments[0]);
}

ここに登場したintrinsicMarkerTypeというのがintrinsicキーワードに与えられるコンパイラ内部の特殊な型で、intrinsicTypeKindsというのがintrinsicがサポートする名前の一覧です。現在はUppercaseや、そのほかにLowercaseCapitalizeUncapitalizeがこれに含まれています。さらにtypeArgumentsの数のチェックも行われており、型変数がちょうど1個でなければならないことが分かります。これはつまり、type Uppercase<T> = intrinsic;はOKだがtype Uppercase<U, T> = intrinsic;のようなものは認識されないことを意味しています。これは現在intrinsicで定義される型関数が全て1引数だからこうなっているのであり、将来2引数などのintrinsic型関数が登場するような場合には適宜変更されると考えられます。

Uppercase型のコンパイラ内部での実体は、型定義に書かれている通り常にintrinsicMarkerTypeであり、Uppercase<"foo">のようにUppercaseが使用された際に上に引用したロジックが動作し、intrinsicMarkerTypeが解決されます。

このコードにgetStringMappedTypeとありますが、この関数の役割は主に2つあります。一つは、文字列リテラル型に即座に変換を適用することです。先ほどのコードでT"FOO"型となるのはこちらの機能によるものです。

// type T = "FOO"
type T = Uppercase<"foo">

もう一つは、string mapped typeという新たな型を生成するものです。こちらは、Uppercase<T>Tが別の型変数だったりした場合に発生します。String mapped typeというのは、conditional typeやindex access type、あるいはmapped typeなどと同レベルの概念だと思ってもらって構いません。ただし、ここで挙げたものはそれぞれ専用の構文を持つのに対し、string mapped typeはintrinsicを介してのみ発生します。

String Mapped Type

ここで出てきたstring mapped typeについてさらに見ていきましょう。この型が存在する理由の一つは、Uppercaseなどの機能を型変数に対して使用できるようにすることです。

function toUpperCase<T extends string>(str: T) {
    type Mapped = Uppercase<T>;
    return str.toUpperCase() as Mapped;
}

// const str: "PIKACHU"
const str = toUpperCase("pikachu");

このコードでは関数toUpperCaseの返り値はUppercase<T>です。実際にtoUpperCase("pikachu")としてこの関数を呼び出すと型引数T"pikachu"型に推論され、その結果返り値のUppercase<T>Uppercase<"pikachu">になった結果として"PIKACHU"型に解決されます。このように、型引数を含む関数インターフェースの中でもUppercaseなどの機能を使えるようにするために、string mapped typeが内部的に使われています。

String mapped typeと型推論

TypeScriptの型の多くは型推論、特に型引数の推論をサポートしています。その典型例はtemplate literal typesです。

// type Rest = "def"
type Rest = "abcdef" extends `abc${infer S}` ? S : never;

// type Rest2 = never
type Rest2 = "aaaaaa" extends `abc${infer S}` ? S : never;

このように、TypeScriptは"abcdef" extends `abc${infer S}`のような条件を判定し、必要に応じてSを推論できる必要があります。ここではinferを用いましたが、関数に与えられた引数から型引数を推論する場合も同様です。

ここで、"abcdef""aaaaaa"に対して`abc${infer S}`をマッチングして条件判定やSの推論を行うのはtemplate literal typeの機能の一部です。

では、string mapped typeの場合はどうでしょうか。実は、この場合型推論は全然行われません。

// type A = string
type A = "ABC" extends Uppercase<infer S> ? S : never;
// type B = never
type B = 123 extends Uppercase<infer S> ? S : never;

この結果は次のように理解できます。まず、"ABC"Uppercase<infer S>をマッチングさせようとしても何の推論も起こりません。そのため、Sについては何の情報も得られないことになります。ただし、Uppercaseが標準ライブラリ内でtype Uppercase<S extends string> = intrinsic;と定義されていることにより、Sextends stringという制約があることが分かります。Sにはこれ以上の情報がないためSstringに置き換えられます。Aの場合、"ABC" extends Uppercase<string>という条件判定が行われることになり[1]Uppercase<string>stringなのでこれは"ABC" extends stringと同じであるため真となり、結果としてAにはSから置き換えられたstringが入ります。一方のBは、123 extends Uppercase<string>が偽なのでneverとなります。

このことを裏付けるためには次のような実験をしてみましょう。適当な関数スコープの中でUppercaseを再定義しました。

function f() {
    type Uppercase<S> = intrinsic;

    // type A = unknown
    type A = "ABC" extends Uppercase<infer S> ? S : never;
    // type B = unknown
    type B = 123 extends Uppercase<infer S> ? S : never;
}

ここでのUppercaseの定義は標準ライブラリと異なり、S extends stringという制約がありません。その結果、Uppercase<infer S>においてもSに対する制約が一切なくなり、その結果Sunknown型と推論されています。

このように、型がintrinsicにより定義されていても、標準ライブラリに露出している部分もUppercaseなどの挙動に影響しています。なかなか面白いですね[2]

まとめ

この記事では、TypeScript 4.1の新機能であるUppercaseなどの型を表現するのに使われているintrinsicキーワードの機構を解説しました。

Uppercase<T>の結果は内部的にstring mapped typesになりますが、それらにいちいち(uppercase "foo"のような)専用構文を与えるのは将来性の面で望ましくないため、intrinsicキーワードの機構を用いることでstring mapped typesの存在をコンパイラ内部に隠蔽したと見ることができます。その結果として、string mapped typesはそれを直接表現する構文を持たない(Uppercase<T>のように間接的に表現するしかない)特殊な型となりました。このような型は今度も増えていくのかもしれません。

脚注
  1. 厳密に言えば、extendsの右は「stringに対するstring mapped type」であり、「Uppercaseという型エイリアスにstring型引数を適用したもの」ではないのですが、前者を表す構文的な記法が無いのでここでは分かりやすさのために両者を同一視しています。 ↩︎

  2. この理由はUppercase<infer S>におけるSのconstraintの決定が構文的に行われている(infer SUppercaseの型引数であることを構文的に見てUppercaseの型引数のconstraintを見に行っている)からです。昔はこれがUppercase<(infer S)>のようにカッコで囲むとうまくいかなくなるバグがあったのですが、筆者が修正しました↩︎