FORCIA CUBEフォルシアの情報を多面的に発信するブログ

型パズルで学ぶTypeScriptの型

2021.12.16

アドベントカレンダー2021 エンジニア テクノロジー

これは、FORCIA Advent Calendar 2021の16日目の記事です。

はじめに

新卒1年目の井上と申します。本格的な業務を開始して以来、TypeScriptという言語を触ってきました。TypeScriptというのはその名の通り、JavaScriptに型を付けたような言語です。学生のころよく書いていた言語といえばC++やJavaなのですが1、どうやらそれらの言語よりも型でいろんなことができるようで、せっかくなのでTypeScriptの豊かな機能を学んでみたいとなんとなく思っていました。

そんなことを考えていたら、type-challangesというサイトの存在を教えてもらいました。このサイトでは、与えられた型から新たな型を生み出すという問題を解きながら、TypeScriptの型について学ぶことができるようです。何かしらの問題を解くのが好きな筆者にとってはうってつけの場所です。本記事では、type-challangesに掲載されている、主に文字列に関する問題を解きながら2、TypeScriptの型でできることを紹介していきたいと思います。実務に何か生かすというよりは、こういう遊びもあるんだなという気持ちで読んでいただければ幸いです。

TypeScriptの型機能の紹介

問題を解くには知識が必要です。筆者が持つ(または参照できる)すべての知識 3 の解説をするのはここでは控えますが4、本記事で扱う問題を解く上でキーとなる機能について少し紹介しましょう。

リテラル型

TypeScriptの基本的な型(プリミティブ型)としては、number型、string型、boolean型などが挙げられますが、それらをさらに細分化した型がリテラル型です。

const age = 25; //ageは25型
const name = "inoue"; //nameは"inoue"型
const yes = true; //yesはtrue型
let age = 25; //ageはnumber型

ここでconstで宣言した変数の型はそれぞれnumberstringbooleanではないことに注意してください。例えば"inoue"型というのは、"inoue"という文字列しか代入できない型となります。constで宣言された場合は再代入を許さないので、型としてもそれで十分だということですね。letで宣言した場合は、別の値が入ることがあるのでプリミティブ型となります。

リテラル型が登場するシーンは他にもあります。

const printHello = (str : "World") => {
 console.log(`Hello ${str}!`);
};

printHello("World");
printHello("Japan"); // compile error

printHelloという関数は、引数として"World"型しか許さない関数として定義されます。これだと制約がきつすぎますが、Union型5というものを考えることで、柔軟な型付けが可能となります。

type Country = "Japan" | "America";
const printHello = (country : Country) => {
 console.log(`Hello ${country}!`);
};

printHello("Japan");
printHello("America");
printHello("Tokyo"); // compile error

ABに対して型C = A | Bは、A型かB型であるような型を表します。上記の場合、printHelloという関数は、引数として"Japan"型か"America"型しか許さない関数として定義されます。こうしてみると結構使いどころがありそうですね。

代入可能

文字列のリテラル型"World"型ですが、これをstring型として扱いたいというときもあるかもしれないし、実際そのようにみなすこともできます。このことを、ここでは"World"型はstring型に代入可能であるということにしましょう。このような関係はstring型だけではなくnumber型、boolean型にも存在しますし、"Japan"型や"America"型は"Japan" | "America"型に代入可能です。型Aが型Bに代入可能なことを、A extends Bと書いたりします。

型から新しい型を作る

TypeScriptでは、ジェネリクスを用いることで、型から新しい型を作る関数のようなものを作ることができます。

type getUnion<T, U> = T | U;
type ex0 = getUnion<string, number>; //ex0はstring | number 型
type ex1 = getUnion<"a", "b">; //ex1は"a" | "b" 型

getUnionは、型Tと型Uを受け取り、型T | Uを返す関数です。このように、型から新しい型を作る関数のことをここでは型関数と呼ぶことにしましょう。 これは単純な例ですが、後に見るようにさまざまな型を生成できます。

ここからは少し高度な型の機能について見ていきます。

Conditional Types

Conditional Typesの構文は以下のようになります。

T extends U? A : B

この構文は、「TUに代入可能であれば、A型、そうでなければB型を返す」という意味です。三項演算子みたいなものですね。

type ex0 = "a" extends string? "x" : "y"; // "x"型
type ex1 = 0 extends string? "x" : "y"; // "y"型
type ex2 = "a" extends "a" | "b"? "x" : "y"; // "x"型
type ex3 = "c" extends "a" | "b"? "x" : "y"; // "y"型

例えば、Conditional Typesを使うとif文が実現できます。

問題1

真偽値のリテラル型C、任意の型TFが与えられる。Ctrueであれば型T、そうでなければ型Fを返すような型関数IF<C, T, F>を作成せよ。type-challanges 問題リンク

type ex0 = If<true, "a", "b">; // "a"型
type ex1 = If<false, "a", "b">; // "b"型

回答は次のようになります。

解答1
type If<C extends boolean, T, F> = C extends true? T : F;

true型に代入可能な型はtrue型のみなので、Ctrue型ならT型を返し、そうでなければF型を返すということになります。

さて、Conditional Typesには条件文がありますが、なんとこの条件文で、新たな型変数を導入できます。

問題2

Promise<Type>型が与えられるので、型Typeを得る型関数Awaitedを作成せよ。type-challanges 問題リンク

解答2
type Awaited<T> = T extends Promise<infer R> ? R : never;

infer Rという構文が、Rという新たな型変数をこの後使いますよ、という意味です。Promiseの中身の型はわからなくても、TypeScriptが型推論をし、その型を返すような型関数を作成できるわけです。

Template Literal Types

Template Literal自体はJavaScriptのES6から導入された機能で、文字列を自分で定義した変数を用いて作成できる機能です。これはお世話になっている人も多いと思います。

const fruit = apple;
const applePie = `${fruit}Pie` // applePie = "applePie"となる

これが型でもできるいうのがTemplate Literal Typesです。

type Fruit = "apple" | "peach";
type FruitPie = `${Fruit}Pie`; // FruitPie型は "applePie" | "peachPie" 型

こんなこともできます。

type ID = `Number : ${number}`;
const ex0:ID = "Number : 1";
const ex1:ID = "Number : 12345";
const ex2:ID = "Number : abc"; //compile error

ID型の定義のTemplate Literal Typesの部分にnumberが使われています。このように書くと、${number}の部分を数値型に置き換えることが可能な文字列を受け入れる型を定義できます。

このTemplate Literal Typesと、先ほど紹介したConditional Typesを用いると、型における文字列の操作が可能になります。

問題3

文字列のリテラル型SFromToが与えられる。Sを左からみたときに一番最初に部分文字列として現れるFromを、Toに置換した型を返すような型関数Replaceを作成せよ。ただし、Fromが空文字列の場合は置換しなくてよい。type-challanges 問題リンク

type ex0 = Replace<"types are fun!", "fun", "awesome"> //"types are awesome!"型
type ex1 = Replace<"aaa", "a", "b"> //"baa"型

一見大変そうですが、なんとこれが次のように書けてしまうことがTemplate Literal Typesの面白いところです。

解答3
type Replace<S extends string, From extends string, To extends string> = 
From extends '' ? S:
S extends `${infer L}${From}${infer R}`? `${L}${To}${R}`:S;

3行目に注目してください。この条件文では、「Sという型が${L}${From}${infer R}という形で書けるか」ということを判定しています。しかもこの一致判定は前方一致6なので、最も左に現れる文字列From${From}に対応します。この条件が満たされれば、${L}${To}${R}という形で表される文字列のリテラル型を返すので、FromToに置換することが達成されます。

問題4

文字列のリテラル型SFromToが与えられる。Sを左から順に、部分文字列として現れるFromを、Toすべて置換した型を返すような型関数ReplaceAllを作成せよ。ただし、Fromが空文字列の場合は置換しなくてよい。

type ex0 = ReplaceAll<"types are fun!", "fun", "awesome"> //"types are awesome!"型
type ex1 = ReplaceAll<"aaa", "a", "b"> //"bbb"型
type ex2 = ReplaceAll<"aaa", "aa", "b"> //"ba"型
type ex3 = ReplaceAll<"ababa", "cc", "acccc"> //"ababa"型

先ほどは左から見て最初のFromを置換するだけでしたが、今回はすべて置換する必要があります。ここで大事なことは、Conditional Typesによって再帰が実現できるということです。

解答4
type ReplaceAll<S extends string, From extends string, To extends string> = 
From extends '' ? S:
S extends `${infer L}${From}${infer R}`? `${L}${To}${ReplaceAll<R, From, To>}`:S;

先ほどのReplace関数と違い、ReplaceAll関数ではConditional Typesの分岐後にR型の部分に対してReplaceAllを適用しています。このように再帰関数として型関数を定義することで、さまざまなことが型で実現できるようになります。

さらに文字列操作に関する問題を解く

これらの機能を用いて、いろいろな問題を解いてみましょう。

問題5

文字列のリテラル型Sに対して、その長さを表す数値のリテラル型を返す型関数LengthOfStringを作成せよ。type-challanges問題リンク

type ex0 = LengthOfString<"abcda">; // 5型
type ex1 = LengthOfString<"a">; // 1型

普通の文字列であればその長さを取得できるメソッドがありますが、型には残念ながらありません。自分で書きましょう。

まず、以下のようにすれば、「文字列を先頭から一文字ずつ切り出して何らかの操作をする」ということができます。つまり、for文が書けます

S extends `${infer L}${infer R}`? Lに対する何らかの操作+Rに対する再帰 : 停止操作

${infer L}${infer R}Lの部分が前方一致となっており、Sの最初の1文字を切り出すということができます。Sの残りの文字列がRになります。 それならば、Cntという数値型を用意して、一文字ずつ切り出すごとにCntに1加える、ということを繰り返せばよさそうに思えますが、なんと「数値型に対して1加える」という操作が容易にできません。そこで、型で数値演算を扱う唯一の方法であるタプル型が登場します。タプルといっても実際は配列なので、ここでは詳細な説明は省略し7、その機能に注目しながら先に進みます。

実はこのタプル型には、その長さを取得する方法が用意されています。

type T = ["a", "b", "c"]; // タプル型
type length = T["length"]; // 3型

なんとタプル型Tに対して、T["length"]でその長さを表す数値型を取得できます。そんなわけで、このタプル型をカウンターとして用いることにしましょう8

元の問題に戻ります。「文字列Sを左から一文字ずつ切り出し、タプル型の要素に追加する」ことを繰り返せばよいです。全て追加し終えたら、タプルの型の長さを取得すればよいです。

解答5
type LengthOfString<S extends string, Cnt extends any[] = []> = 
S extends `${infer L}${infer R}`? LengthOfString<R, [1, ...Cnt]> : Cnt["length"];

型関数も普通の関数と同様、デフォルト引数を設定できます(Cnt extends any[] = []の部分)。Cntのデフォルト引数を空のタプルとして、カウンターを初期化します。 `${infer L}${infer R}` で一文字切り出し、LengthOfString<R, [1, ...Cnt]>としてカウンターに何かしらの要素を追加します(1である必要性はなく、""でも十分です)。...Cntという記法は配列やオブジェクトと同じで、タプル型の要素を展開するものです。

問題6

文字列のリテラル型Sに対して、Sが回文であればtrue型、そうでなければfalse型を返す型関数IsPalindromeを作成せよ。type-challanges問題リンク

リンク先ではnumber型も考慮する必要がありますが、ここでは文字列型に限定しておきましょう。

type ex0 = IsPalindrome<"abcba">; //true型
type ex1 = IsPalindrome<"abcdef">; //false型

この問題はTypeScriptの型ではなく普段慣れ親しんでいるプログラミング言語で判定を書いてくださいと言われても、苦戦する人がいるかもしれません。文字列Sが回文であるということは、Sを反転させた文字列をrevSとすると、SrevSが一致することと同値です。したがって、Sを反転した文字列が得られればなんとかなりそうです。このためには、空文字列Tを用意し、Sを1文字ずつ切り出しながら、Tの先頭に追加していけばよさそうです。

type Reverse<S extends string, T extends string = ""> =
T extends `${infer L}${infer R}`? Reverse<R, `${L}${T}`> : T;

Conditional Typesの条件分岐後のReverse<R, `${L}${U}`>という部分で、Sの先頭の文字をTの先頭に追加する、という操作が実現されています。ここまでできれば、リテラル型に関する型の一致判定はextendsで十分なので、次のようにすればよいでしょう。

解答6
type Reverse<S extends string, T extends string = ""> =
S extends `${infer L}${infer R}`? Reverse<R, `${L}${T}`> : T;

type IsPalindrome<S extends string> = S extends Reverse<S>? true : false

いかがでしょうか。型レベルでも結構いろんなことができそうな気持ちになってきたのではないでしょうか。これらを体得したいという型は、ぜひtype-challangesの問題に挑戦してみてください。また、本記事ではオブジェクト型に関する問題は扱いませんでしたが、TypeScriptを使いこなすうえでは大事なテーマかと思いますので、こちらも問題を見てみるとよいと思います。

最後に、もう一問問題を出題して本記事の締めくくりとしたと思います。本記事で紹介した知識のみで解くことができるので、ぜひ考えてみてください。 最後まで読んでいただきありがとうございました。

演習問題

文字列S平方であるとは、ある文字列Tが存在してS=TTと書けることをいう。つまり、Sがある文字列を2つ並べて得られる文字列であるとき、Sは平方であるという。

文字列のリテラル型Sが与えられる。Sが平方であればtrue型、そうでなければfalse型を返す型関数IsSquareを作成せよ。9

この記事を書いた人

井上 輝義

学生時代は競技プログラミングをしていました。(highest:2079 ほぼ引退)
現在は勤務時間以外は麻雀を打つか観戦をしています。

1. 筆者は学生時代競技プログラミングにそれなりに取り組んでいたため、C++をよく書いていました。また、Javaはアルバイトで書いていました。


2. なぜ文字列を選んだかということですが、Template Literal Typesという新しめの機能を試すのにちょうどよいのと、比較的に何が起きているかがわかりやすいと思ったためです。


3. TypeScriptを完全に理解している人から見れば微々たる程度の知識しかありません。


4. 基礎知識の勉強にはuyhoさんの入門記事(https://qiita.com/uhyo/items/e2fdef2d3236b9bfe74a)を読むとよいでしょう。


5. 対してIntersection型というものも存在します。これは A & Bと書き、AでもBでもあるような型を指します。number & stringなどはほとんど意味をなしませんが、オブジェクト型を考えると結構有用です。


6. できるだけ前から区切って条件を満たせばの区切り方を採用する、というイメージです。


7. タプル型についてはこの記事(https://zenn.dev/luvmini511/articles/d89b3ad241e544)を読むとよいでしょう。


8. ちなみに、数値型の演算(2型+3型とか)というのは定義されておらず、その機能を実現するためには現状タプル型に頼るしかありません。型での数値演算は実装量が多く、typechallangesでは難度の高い問題として紹介されています。型で数値を扱う方法の例についてはこの記事(https://techracho.bpsinc.jp/yoshi/2020_09_04/97108)を見るとよいでしょう。


9. ヒント : 文字列を半分で区切って...と考えてしまうと、数値演算が必要になり、これは前述の通り面倒です。問題で示した平方の定義を満たしているかを確認するためには、文字列を前から一文字ずつ区切りながらどうすればよいでしょうか?

フォルシアではフォルシアに興味をお持ちいただけた方に、社員との面談のご案内をしています。
採用応募の方、まずはカジュアルにお話をしてみたいという方は、お気軽に下記よりご連絡ください。


採用お問い合わせフォーム 募集要項

※ 弊社社員に対する営業行為などはお断りしております。ご希望に沿えない場合がございますので予めご了承ください。