🧩

Type Challengesの問題を解いてみる: First of Array

2023/01/27に公開

職場で自チーム内にフロントエンド初心者のエンジニアが多かったので、不定期でType Challengesを解いてみようの会を開いてました。この記事はそのときに作成した資料をブラッシュアップしたものです。

私自身もフロントエンドの経験は少ないので、間違ってるところあるかもしれません。

どういう人向け?

以下のような人向けの記事です。

  • Type Challengesやってみたけど、easyすら難しくてよくわからん。
  • TypeScriptのドキュメントを一通り読んでみたけど、実際に型をどうやって使うか想像つかない…
  • 他の人の答えを見てみたけど、どうやってその回答を導き出したのか気になる。

この記事では、数学の証明みたいな感じで順を追って解法を説明したいと思います。

挑戦する問題

配列型(タプル型)の1つ目の要素の型を返すFirst<T>を定義する。

サンプル
type arr1 = ["a", "b", "c"]
type head1 = First<arr1> // => "a"

type arr2 = [3, 2, 1]
type head2 = First<arr2> // => 3

前提知識

問題を解く上で最低限の知識を説明します。

ジェネリクス

詳しく説明すると長くなるので詳細は上の記事を読んでもらうとして、ジェネリクスを一言で表すと「型を引数として扱う」機能のことです。
普通の引数と型引数の対応を見るとわかりやすいかも…?(以下)

普通の引数
// 引数nにはどんな値でも渡せる
function myFunc(n) {
    return n + 1;
}

const result = myFunc(3); // => 3 + 1 => 4

普通の引数は関数名のあとの()内に書きますが、型引数は関数名・型名のあとの<>内にかきます。

型引数
// 型引数Tにはどんな型でも渡せる
type MyType<T> = {
    value: T;
}

type Result = MyType<number>; // => { value: number; }

ちなみに型引数はどんな名前でもいいですが、TとかUがよく使われるみたいです。

リテラル型

1つ目の要素の型を返すなら、First<["a", "b", "c"]>はstringじゃないの?と感じるかもしれませんが、期待する値は"a"になっています。
これはTypeScriptのリテラル型によるもので、TypeScriptでは特定の数値や文字列しか持てない型(=リテラル型)を定義できます。

// "Hello"という型の変数に"Hello"という文字列を代入
const val: "Hello" = "Hello"

// valは"Hello"しか代入できないためエラー
val = "Good bye";

先ほど例に上げた["a", "b", "c"]"a""b""c"という3つリテラル型をもつ配列の型なので、1つ目の型は"a"になります。

使う道具

問題を解く上で使うTypeScriptの型や機能を紹介します。

インデックスアクセス型

オブジェクトのプロパティの型や、配列の特定の要素の型を参照する方法です。以下の例では配列型(タプル型)の各要素の型をインデックスを指定して取得しています。

type list = [number, string];

type first = list[0] // => number
type second = list[1] // => string

リテラルの配列型でも同様です。

type list = [0, "s"]

type first = list[0] // => 0
type second = list[1] // => "s"

型引数の制約

ジェネリクスの型を特定の条件を満たす型に限定することができます。

// 制約なし(Tは何でもOK)
type Foo<T> = T;
type foo1 = Foo<number>; // OK;
type foo2 = Foo<boolean>; // OK;

// Tをnumberに制約
type Bar<T extends number> = T;
type bar1 = Foo<number>; //  OK;
type bar2 = Foo<boolean>; // NG;

// Tをidプロパティを持つオブジェクトに制約
type Baz<T extends { id: string }> = T;
type baz1 = Bar<{id: string, age: number}>; // OK
type baz2 = Bar<{age: number}>; // NG

解法・考え方

これで準備が整いました。それでは問題を解いていきましょう。

とりあえずarr1に直接インデックスアクセス型を使って最初の型を取り出してみます。

type arr1 = ["a", "b", "c"];

// インデックスアクセス型を使うと"a"が取れる
type first = arr1[0];  // => "a";

無事にarr1の1つ目の型が取得できました。
このarr1の部分に好きな型を指定できるようにすればよさそうです(初学者だとこの発想がまず浮かばないかも…?)。

好きな型を指定できる = 型を引数化する = ジェネリクスで型引数を受け取れるようにする、ということでarr1をジェネリック型にしてみましょう。

// arr1を型引数化
type first<arr1> = arr1[0];
// ↓↓
// ついでに頭文字を大文字に、引数名をTに変更
type First<T> = T[0]; // エラー: Type '0' cannot be used to index type 'T'

エラーが出ちゃいました。「0は型Tのインデックスには使えないよ~」とのこと。
これは配列型やタプル型ではない型に対して(数値による)インデックスアクセス型を使うと発生するエラーです。

いま型引数Tにはどんな型でも渡すことができるので、string1といった配列型ではない型が渡される可能性があります。
ここで型引数に「この型は配列型である」という制約をかけます。

// Tに配列の制約をかける。配列の型に制限はないのでanyにしておく。
type First<T extends any[]> = T[0];

エラーが消えました。あとは実際に使ってみましょう。

type First<T extends any[]> = T[0];

type result1 = First<["a", "b", "c"]>; // => "a"
type result2 = First<[1, 2, 3]>; // => 1
type result3 = First<string>; // エラー: Type 'string' does not satisfy the constraint 'any[]'.

問題なく動いてますね。

応用

実は先ほど定義したFirst<T>に空配列を渡すと、undefinedが返ってきます。

type First<T extends any[]> = T[0];

type result = First<[]>; // => undefined

これは配列に存在しないインデックスでアクセスするとundefinedが返ってくるJavaScriptの仕様によるものです。
undefinedでもいい気がしますが、TypeScriptには値を持たないことを意味するnever型という型が存在します。
なのでTに空配列を渡すとneverが返ってきたほうがTypeScript的には自然だと言えるでしょう。詳しい解説は省略しますが、以下がその回答例です。

type First<T extends any[]> = T extends [] ? never : T[0];

type result = First<[]>; // => never

T extends [] ? ...の部分はConditional Typesといい、三項演算子のように型が制約を満たしているかどうかで異なる型を返すことができます。
Conditional TypesもType Challengesに頻出の型なので、一緒に覚えておくことをおすすめします。

他の人たちの回答例

ちょっと変わった回答があったので軽く紹介します。

lengthプロパティを見る方法

type First<T extends any[]> = T['length'] extends 0 ? never : T[0];

[1, 2, 3].length[1, 2, 3]['length']が同じことを利用して、配列が空かどうか見てます。面白いですね。

keyofを使う方法

type First<T extends any[]> = "0" extends keyof T ? T[0] : never;

配列型に対してkeyof型演算子を使うとインデックスの一覧がユニオン型として取得できることを利用した回答です(以下)。
配列に要素があれば必ず'0'が含まれるのでConditional Typeの条件が真になる、という仕組みです。

type keys1 = keyof ['a', 'b', 'c']; // => '0' | '1' | '2'
type foo = '0' extends '0'| '1' | '2' ? number : string; // => number

inferを使う方法

inferをここで説明するのは難しいので別の問題で取り上げるとして、以下のような書き方もできるみたいです。
配列に要素があればその1つ目の要素をFとしてそれを返す、みたいな仕組みでしょうか。

type First<T extends unknown[]> = T extends [infer F, ...any] ? F : never;

終わりに

こうやって順を追って回答を導いていくと「この型はこういうときに使うんだな」といったイメージが湧きやすいと思います。
他の問題の資料もいくつか用意があるので、そのうち公開する予定です。

Discussion