TypeScript の型システムについて
はじめに
はじめまして、データサイエンティストの阿部です。
最近、業務で TypeScript を使用する機会が増えたことから、この言語を体系的に学ぶようになりました。特に、 TypeScript には便利なユーティリティ型が多数存在し、これを活用することで型システムをより広く活用できることを学びました。また他のプログラミング言語と比べても、 TypeScript の型システムは多くのことができるようです。
この興味をさらに深める中で、「type-challenges」というサイトに出会いました。ここでは、 TypeScript の型システムを駆使して解くチャレンジ問題が提供されており、問題を通じて学びを深めることができました。
この記事では、 type-challenges の問題とその解法を通して、 TypeScript の型システムで出来ることをご紹介したいと思います。ぜひ、この機会に TypeScript の魅力を感じていただければ幸いです。
概要
TypeScript の型について
TypeScript は、 Java や C++ と同様に静的型付けのプログラミング言語です。これは、プログラムのコンパイル時に変数や関数の型が決定されることを意味します。 TypeScript の型システムを利用することで、コードの安全性と可読性を向上させることができます。
TypeScript で使用される主な型には以下のものがあります:
- 数値型(number): 整数や浮動小数点数を扱います。
- 文字列型(string): 文字の集合を表します。
- 真偽値型(boolean):
true
またはfalse
の値を取ります。 - 配列型(Array): 同じ型の値を持つリストを表します。
- オブジェクト型(object): キーと値のペアを持つデータ構造です。
TypeScript では、変数を宣言する際に型を指定することができます。以下にその例を示します。
// 数値型
let age: number = 25;
// 文字列型
let name: string = "John";
// 真偽値型
let isStudent: boolean = true;
// 配列型
let scores: number[] = [90, 85, 78];
// オブジェクト型
let person: { name: string; age: number } = {
name: "Alice",
age: 30
};
type-challenges について
type-challenges とは、 TypeScript における型パズルの問題集です。与えられた条件を満たす型を適切に実装して、問題を解いていきます。 イメージとしては、以下のような問題がたくさん掲載されています。この例では、5行目を適切に修正することで問題を解くことができます。
/*
問題 : hogehoge....
*/
/* _____________ ここにコードを記入 _____________ */
type SampleType<T> = any; //ここを変える!
/* _____________ テストケース _____________ */
import type { Equal, Expect } from "@type-challenges/utils";
type cases = [
TestCase1,
TestCase2,
...
];
難易度は easy < medium < hard < extreme の順に分かれており、hard 以降はかなり難しいレベル感らしいです (筆者はまだ medium の途中までしか解けていません)。 この記事では、easy に存在する12問の問題を紹介します。
TypeScript の基礎的な型機能
実際の問題に入っていく前に、問題を解く上でキーとなる TypeScript の型機能を少し紹介します。
リテラル型
リテラル型は、特定の値を持つ型を定義するための機能です。一般的な型(例えば、 number
や string
)は複数の値を持つことができますが、リテラル型は特定の値だけを許可します。
例えば、 "apple"
や 100
など、特定の文字列や数値をリテラル型として扱うことができます。
let fruit: "apple" = "apple";
fruit = "banana"; // エラー: 型 '"banana"' を 'fruit' に割り当てることはできない
let score: 100 = 100;
score = 90; // エラー: 型 '90' を 'score' に割り当てることはできない
ユニオン型
ユニオン型は、変数が複数の異なる型のいずれかを持つことを許可する型です。
let value: string | number;
value = "Hello";
value = 42;
value = true; // エラー: 型 'true' を 'value' に割り当てることはできない
リテラル型と合わせて使う場合が多いです。
type Grade = 1 | 2 | 3 | 4 | 5;
let myGrade: Grade;
myGrade = 3;
myGrade = 6; // エラー: 型 '6' を 'Grade' に割り当てることはできない
型のジェネリクス
TypeScript の型システムにおけるジェネリクスとは、型をパラメータのように受け取ることで、汎用的な型を作成することができる機能です。
type Identity<T> = (arg: T) => T;
const identity: Identity<number> = (arg) => {
return arg;
};
const result = identity(42); // resultはnumber型になる
extends 句
TypeScript の型システムにおける extends
は、 A extends B
のように書き、型 A
が型 B
の部分型であることを表します。
部分型とは例えば、次のような関係を指します:
- リテラル型
"banana"
はstring
型の部分型である - リテラル型
10
はnumber
型の部分型である - リテラル型
"aa"
は ユニオン型"aa" | "bb"
の部分型である
ジェネリクスに型制限を設ける際に使用することが多いです。
type StringWrapper<T extends string> = ...
// ⭕️
const myString: StringWrapper<"Hello"> = ...
// ❌エラー: string以外の型を渡すとエラーになる
const invalidString: StringWrapper<number> = ...
インデックス型
インデックスアクセス型は、オブジェクト型の特定のプロパティの型を取得するための構文です。
type Person = { name: string; age: number };
type T = Person["name"]; // T はstring型になる
マップ型
マップ型の最も基本的な使用方法は下記のように、ユニオン型をそれぞれオブジェクト型のキーにした型を作成することです。
type fruits = "apple" | "banana" | "orange";
type fruitsObject = {
[key in fruits]: number;
};
// type fruitsObject = {
// apple: number;
// banana: number;
// orange: number;
// } と同じ意味
ユーティリティ型
ユーティリティ型とは、既存の型を基に新しい型を生成するための TypeScript に組み込まれている便利な型のことです。以下の User
型を用いて、いくつかのユーティリティ型を紹介します。
type User = {
id: number;
name: string;
age: number;
};
Pick<T, K>
型 T
から指定したプロパティ K
だけを抽出して新しい型を作成します。
type UserInfo = Pick<User, 'name' | 'age'>;
// UserInfoは { name: string; age: number; }
Omit<T, K>
型 T
から指定したプロパティ K
を除外して新しい型を作成します。
type UserWithoutEmail = Omit<User, 'id'>;
// UserWithoutEmailは { name: string; age: number; }
Exclude<T, U>
型 T
から型 U
に含まれる型を除外します。
type A = 'a' | 'b' | 'c';
type B = Exclude<A, 'a'>; // Bは 'b' | 'c'
Extract<T, U>
型 T
から型 U
に含まれる型だけを抽出します。
type A = 'a' | 'b' | 'c';
type B = Extract<A, 'a' | 'b'>; // Bは 'a' | 'b'
問題紹介
それでは本題の type-challenges の問題について見ていきます。問題形式で解答およびその説明を載せています。 まだ出てきていない型機能については、適宜説明を加えています。
1. Pick
問題
特定のプロパティだけを取り出すユーティリティ型 Pick<T, K>
を実装してください。
type User = {
id: number;
name: string;
age: number;
}
type UserInfo = Pick<User, 'name' | 'age'>;
// UserInfo は { name: string; age: number; } と同じ
解答
type MyPick<T, K extends keyof T> = {
[P in K]: T[P]
}
説明
keyof
はオブジェクト型のキーを取得する演算子で、キーの集合をユニオン型として表現します。
例えば、keyof User
は "id" | "name" | "age"
になります。
[P in K]: T[P]
は、マップ型の構文で、T[P]
はインデックスアクセス型です。
意味としては、入力された必要な型(のユニオン型)K
についてのみからなるオブジェクト型を返すという意味になります。
2. Readonly
問題
オブジェクトのすべてのプロパティを読み取り専用にするユーティリティ型 Readonly
を実装してください。
type ReadOnlyObj = Readonly<{
a: number;
b: number;
c: number;
}>;
// ReadOnlyObj は { readonly a: number; readonly b: number; readonly c: number; } と同じ
解答
type MyReadonly<T> = {
readonly [P in keyof T]: T[P]
}
説明
[P in keyof T]: T[P]
は先ほども登場したマップ型の構文です。ここに readonly
を追加することで、各プロパティが読み取り専用になります。
3. Tuple to Object
問題
タプル型を受け取り、その各値をキーと値に持つオブジェクト型に変換する型を実装してください。
type tuple = ['tesla', 'model 3', 'model X', 'model Y']
type result = TupleToObject<tuple>
// 期待される結果: { 'tesla': 'tesla', 'model 3': 'model 3', 'model X': 'model X', 'model Y': 'model Y' }
解答
type TupleToObject<T extends readonly (string|number|symbol)[]> = {
[P in T[number]]: P
}
説明
はじめに、TupleToObject<T extends readonly (string|number|symbol)[]>
の部分について解説します。
タプル型は配列型として定義されており、ジェネリクスで制限をかける場合は T extends readonly any[]
と書くことになります。
オブジェクト型の key には string
, number
, symbol
型しか設定できないのですが、T extends readonly any[]
ですと オブジェクト型の key として不適切な値(例: TupleToObject<[[1, 2], {}]>
)を入れてもエラーになりません。
その対策として、 T extends readonly (string|number|symbol)[]
という制約を設けることで、オブジェクト型の key となる値に制限することができます。
最後に [P in T[number]]: P
の部分について解説します。
タプル型 ( ⊂ 配列型 ) の要素をリテラル型のユニオン型は T[number]
と書きます。
type Colors = ['red', 'green', 'blue'];
type ColorType = Colors[number]; // ColorType は 'red' | 'green' | 'blue' 型になる
{[P in T[number]]: P}
と書くことによってリテラル型の値を key と value に持つオブジェクトを型として定義できます。
これらを組み合わせると解答になります。
4. First of Array
問題
配列 T
を受け取り、その最初の要素の型を返す First<T>
を実装してください。
type arr1 = ['a', 'b', 'c']
type arr2 = [3, 2, 1]
type head1 = First<arr1> // 期待される型: 'a'
type head2 = First<arr2> // 期待される型: 3
解答
type First<T extends any[]> = T extends [] ? never : T[0]
説明
T extends any[]
で T
が配列であることを保証します。
T extends U ? A : B
は条件型と呼ばれる型で、「T
がU
の部分型であれば、型A
、そうでなければ型B
を返す」という意味です。
したがって T extends [] ? never : T[0]
は、T
が空配列の場合は never
型を返し、それ以外の場合は配列の最初の要素を返します。
5. Length of Tuple
問題
タプル T
を受け取り、その長さを返す型 Length<T>
を実装してください。
type tesla = ['tesla', 'model 3', 'model X', 'model Y']
type spaceX = ['FALCON 9', 'FALCON HEAVY', 'DRAGON', 'STARSHIP', 'HUMAN SPACEFLIGHT']
type teslaLength = Length<tesla> // 期待される結果: 4
type spaceXLength = Length<spaceX> // 期待される結果: 5
解答
type Length<T extends readonly any[]> = T['length']
説明
タプル型は配列型の一種で、length
プロパティを使ってその長さを取得できます。
6. Exclude
問題
ユーティリティ型 Exclude
を実装してください。
type Result = MyExclude<'a' | 'b' | 'c', 'a'> // 'b' | 'c'
解答
type MyExclude<T, U> = T extends U ? never : T
説明
条件型はユニオン型に対して分配的に適用されます。
つまり、ユニオン型は extends
を使用すると、1つずつ判定され、各結果を再びユニオンした結果が返ります。
今回の問題に当てはめると以下のようなイメージです。
MyExclude<'a' | 'b' | 'c', 'a'> =
'a' extends 'a' ? never : T
| 'b' extends 'a' ? never : T
| 'c' extends 'a' ? never : T
7. Awaited
( easy の中ではやや難しいです)
問題
Promise の中身の型を取得する型を実装してください。
type ExampleType = Promise<string>
type Result = MyAwaited<ExampleType> // string
解答
type MyAwaited<T extends PromiseLike<any>> = T extends PromiseLike<infer U>
? (U extends PromiseLike<any> ? MyAwaited<U> : U)
: never
説明
infer
型は条件型の中で使用し、型を推論するために使います。
例えば、何らかの配列型を受け取りその要素の型を返すようなユーティリティ型を考えたとき下記のように書きたくなりますが U
は定義されていないため使用することができません。
type ElementType<T extends any[]> = T extends U[] ? U : never; //エラー
そこで infer
型を下記のように使用することで実現することができます。
type ElementType<T extends any[]> = T extends (infer U)[] ? U : never;
type Result = ElementType<string[]>; // Resultはstring型になる
これを以下のように配列型から Promise
に変えることで、 Promise
の中身を取り出すことが出来ます。
type MyAwaited<T extends Promise<any>> = T extends Promise<infer U>
? U
: never
上記で概ね良さそうですが、満点を取るためにはあと2ステップあります。
まず then
プロパティを持つオブジェクトも Promise
とみなしたいので、 PromiseLike
型を使用します。
( { then: (onfulfilled: (arg: number) => any) => any }
← このようなオブジェクトです)
type MyAwaited<T extends PromiseLike<any>> = T extends PromiseLike<infer U>
? U
: never
次に、 Promise
が入れ子になっている構造にも対応する必要があります。
すなわち Promise<Promise<string>>
のような型です。
これは、型機能には再帰的に型定義をすることが可能なことを利用します。
すなわち infer
で取り出した型 U
に対して、 U extends PromiseLike<any> ? MyAwaited<U> : U
と書くことで再帰的に PromiseLike
で囲まれているかを判定してその中身を取り出すことが出来ます。
8. If
問題
下記のような If 型を実装してください。
type A = If<true, 'a', 'b'>; // expected to be 'a'
type B = If<false, 'a', 'b'>; // expected to be 'b'
解答
type If<C extends boolean, T, F> = C extends true ? T : F
説明
条件型を使用して、簡潔に書くことができます。
9. Concat
問題
下記のような2つの配列型を結合する Concat 型を実装してください。
type Result = Concat<[1], [2]>; // expected to be [1, 2]
解答
type Concat<T extends readonly any[], U extends readonly any[]> = [...T, ...U]
説明
型機能にもスプレッド構文が存在します。スプレッド構文を使用することで、T
と U
のそれぞれの配列型の中身の型を展開することができます。
10. Includes
下記のように、配列型の中に特定の型が存在するかを判定する型を作成してください。
問題
type isPillarMen = Includes<['Kars', 'Esidisi', 'Wamuu', 'Santana'], 'Dio'> // expected to be `false`
解答
type Includes<T extends readonly any[], U> =
T extends [infer First, ...infer Rest]
? (Equal<First, U> extends true
? true
: Includes<Rest, U>
)
: false
説明
これは Equal
型(type-challengesに用意されている等しいことを表す型)を使用しないと難しいため、少しずるいですが Equal
型を使用して解きました。
解き方としては、再帰的な型定義を用いて配列を先頭から順に見ていきます。
(Equal<First, U> extends true ? true : Includes<Rest, U>)
は現在見ている先頭の要素が U
と等しければ true
を返して終了し、そうでなければ再帰的に次の要素を見ていくという処理になっています。
11. Push
問題
以下のように指定された要素を配列の末尾に追加した新しい配列型を返す Push 型を作成してください。
type Result = Push<[1, 2], '3'> // [1, 2, '3']
解答
type Push<T extends any[], U> = [...T, U]
説明
こちらも Concat
型と同じようにスプレッド構文を使用することで簡潔に書くことができます。
12. Parameter
問題
ユーティリティ型 Parameter
を実装してください。
const foo = (arg1: string, arg2: number): void => {}
type FunctionParamsType = MyParameters<typeof foo> // [string, number]
解答
type MyParameters<T extends (...args: any[]) => any> = T extends (
...args: infer U
) => any
? U
: never;
説明
T extends (...args: any[]) => any
で T
は関数型であることを保証します。
そして T extends (...args: infer U) => any ? U : never;
で infer
型を使用し引数の型を指定し、関数型である場合はその型を返します。
おわりに
本記事では、TypeScript の基本的な型機能と type-challenges の初級問題をご紹介しました。TypeScript の型機能を活用することで、柔軟で便利な型を作成できる可能性を感じていただけたのではないでしょうか。この記事が TypeScript への興味を深める一助となれば幸いです。ぜひ type-challenges に挑戦し、さらに理解を深めてみてください。最後までお読みいただきありがとうございました。
エムシーデジタルでは、技術力向上のためのイベントや勉強会なども定期的に実施しています。 もしエムシーデジタルで働くことに興味を持っていただいた方がいらっしゃいましたら、カジュアル面談も受け付けておりますので、お気軽にお声掛けください! 採用情報や面談申込みはこちらから