LoginSignup
205

More than 3 years have passed since last update.

TypeScriptの型演習(解答・解説編)

Last updated at Posted at 2019-02-23

この記事は、「TypeScriptの型演習」の解答および解説を収録した記事です。問題に挑戦したい方は先に下記の記事をご覧ください。

1-1. 関数に型をつけよう

function isPositive(num: number): boolean {
    return num >= 0;
}

関数の引数に: numberを追加しました。これが引数に対する型アノテーションです。TypeScriptでは、関数の引数の型を指定するにはこのように明示するのが基本です。型が指定されていない場合はエラーとなります(--noImplicitAnyオプションの効果)。ただし、問題1-3のように文脈から推論できる場合は引数の型アノテーションを省略できます。

また、関数にはこのように返り値の型アノテーション(: boolean)も可能ですが、返り値については関数の定義から推論してくれます。今回の場合、返り値がnum >= 0であり、これの型がbooleanであることを推論してくれます。

型アノテーションが何だか分からなかった方は、この機会に理解しておきましょう。

別解

function isPositive(num: number) {
    return num >= 0;
}

前述のようにTypeScriptは返り値の型を推論できますから、これくらい簡単な関数ならば敢えて: booleanと書かなくてもよいかもしれません。

1-2. オブジェクトの型

interface User {
    name: string;
    age: number;
    private: boolean;
}

オブジェクトの型は、このようにinterface構文を使って定義するのがよく見られます。プロパティ名: 型;という形でプロパティを列挙しましょう。

別解

type User = {
    name: string;
    age: number;
    private: boolean;
};

このようにtype文を用いて型を定義してもよいです。オブジェクト型は{ name: string; age: number; private: boolean; }のように書くことができ、これにUserという名前を付けていると見なせます。

1-3. 関数の型

type IsPositiveFunc = (arg: number) => boolean;

関数の型はこのように(引数名: 型) => 返り値の型という形で書くことができます。型システム上、型にかかれている引数名(arg)に意味はありません。

問題とは関係ありませんが、isPositiveに代入されている関数num => num >= 0に引数の型アノテーションが無いという点は注目に値します。この場合、関数の代入先であるisPositiveの型がIsPositiveFuncであることから、引数numの型がnumberであると推論できます。これが、1-1の解説で触れた「引数の型が文脈から推論できる場合」に相当します。

別解

interface IsPositiveFunc {
  (arg: number): boolean;
}

このように、オブジェクト型の記法でも関数の型を定義することもできます。

1-4. 配列の型

function sumOfPos(arr: number[]): number {
  return arr.filter(num => num >= 0).reduce((acc, num) => acc + num, 0);
}

number型の値の配列の型はnumber[]と書くことができます。

別解

function sumOfPos(arr: Array<number>): number {
  return arr.filter(num => num >= 0).reduce((acc, num) => acc + num, 0);

配列の型はArray<number>のように書くことも可能です。

2-1. ジェネリクス

function myFilter<T>(arr: T[], predicate: (elm: T) => boolean): T[] {
  const result = [];
  for (const elm of arr) {
    if (predicate(elm)) {
      result.push(elm);
    }
  }
  return result;
}

myFilterに型引数Tを追加しました。型Tは渡される配列の要素の型を想定しています。そのため、第1引数の型はT[]となります。第2引数は配列の要素を1つ受け取って真偽値を返す関数なので、型は(elm: T) => booleanとなります。

myFilterの返り値はT[]です。実は返り値の型アノテーションを省略してもちゃんと推論してくれるので、それでもOKです。なかなか賢いですね。

2-2. いくつかの文字列を受け取れる関数

type Speed = "slow" | "medium" | "fast";

"slow"などは文字列のリテラル型であり、"slow"という文字列のみを受け付ける型です。また、|はユニオン型の記法であり、複数の型のどれか1つに当てはまる型という意味になります。よって、ここで定義した型Speed"slow"という文字列または"medium"という文字列または"fast"という文字列の型となり、問題の要件を満たしています。

"slow""medium"といった文字列はSpeed型を持つのでgetSpeed関数の引数に渡すことができますが、"varyfast"のような他の文字列はSpeed型を持たないため型エラーとなります。

また、この例ではgetSpeed関数の中身も注目に値します。中身のコードを読むと明らかに、この関数が数値を返すのは引数が"slow""medium""fast"のときだけです。万が一他の値が渡された場合には、この関数の末尾に到達して何も返りません(undefinedが返ることになります)。しかし、TypeScriptは引数がSpeed型であるという情報を用いて「この関数は必ずどれかのreturn文に到達する」と推論します。それによって、関数の返り値型をnumberとすることが許されています。試しにSpeed型に"veryfast"のような他の文字列を追加するとエラーになることが分かります。

2-3 省略可能なプロパティ

interface AddEventListenerOptionsObject {
  capture?: boolean;
  once?: boolean;
  passive?: boolean;
}
declare function addEventListener(
  type: string,
  handler: () => void,
  options?: boolean | AddEventListenerOptionsObject
): void;

この問題の最初のポイントは、AddEventListenerOptionsObject型の定義です。各プロパティに?がついており、これはそのプロパティが省略可能であることを表しています。そのため、{}{capture: false, once: true}などのオブジェクトはAddEventListenerOptionsObject型を持ちます。(なお、言うまでもありませんがわざわざAddEventListenerOptionsObject型を定義したのは分かりやすさのためです。この型に名前を付けずにaddEventListenerの定義中で使うことも可能です。)

また、第3引数はこのオブジェクトの他に真偽値の引数も受け付けることになっています。したがって、第3引数の型は先述のユニオン型を用いてboolean | AddEventListenerOptionsObjectになっています。これが2つ目のポイントです。

最後に、第3引数は省略可能でした。このことを表現するために、addEventListenerの宣言でoptions引数に?が付いています。なお、addEventListenerの型は(type: string, handler: () => void, options?: boolean | AddEventListenerOptionsObject) => voidとなり、型にもこの?が残っていることが分かります。

2-4. プロパティを増やす関数

function giveId<T>(obj: T): T & { id: string } {
  const id = "本当はランダムがいいけどここではただの文字列";
  return {
    ...obj,
    id
  };
}

この問題のポイントは返り値のT & { id: string }型です。これはインターセクション型であり、オブジェクトに新しいプロパティを増やしたい場合の典型的な方法です。Tがオブジェクトの場合、これはTのプロパティとidプロパティを両方持つようなオブジェクトの型になります。

ただし、TypeScriptは賢いので実は返り値の型アノテーションを書かなくても勝手に推論してくれます。それだとこの問題の意味がないのでやめてほしいですが。

2-5. useState

type UseStateUpdateArgument<T> = T | ((oldValue: T) => T);
declare function useState<T>(
  initialValue: T
): [T, (updator: UseStateUpdateArgument<T>) => void];

この問題の新しい点はタプル型の使用です。タプル型は要素ごとに型が異なる配列を表す型でしたので、useStateのようなAPIに型を付けるのに適しています。

UseStateUpdateArgument<T>はステート更新関数の引数の型です。ステート更新関数は、新しいステートの値を直接受け取るか、新しいステートを古いステートから計算する関数を受け取るかの両方が可能です。これを表すためにユニオン型を使用しています。

3-1. 配列からMapを作る

function mapFromArray<T, K extends keyof T>(arr: T[], key: K): Map<T[K], T> {
  const result = new Map();
  for (const obj of arr) {
    result.set(obj[key], obj);
  }
  return result;
}

今回、mapFromArrayは2つの型引数を持ちます。1つ目はTで、これは渡される配列の要素の型です。2つ目はKで、2つ目の引数の型です。これは使用するプロパティ名を表すリテラル型を期待しています。引数keyで指定されるプロパティ名はTが持つプロパティの名前でなければいけませんから、型引数の制約にそのことを書いています。それがK extends keyof Tの部分です。これはKkeyof Tの部分型でなければいけないということを示しており、keyof TTが持つプロパティ名いずれかの型です。今回の使用例では、T{id: number; name: string}なのでkeyof T"id" | "name"となっています。Kはその部分型(つまり"id" | "name"に当てはめることができる型)なので、"id""name""id" | "name"などが可能です。問題の使用例ではmapFromArray(data, "id")として使用されていますから、Kには"id"という型が入ります。

返り値のMap型は2つの型引数をとる型です。1つ目はキーの、2つ目は値の型です。今回、Mapのキーとなるのは各オブジェクトobjの、keyで指定されたプロパティ、すなわちobj[key]の型です。例えばkey"id"の場合はobj["id"]の型となります。いまオブジェクトの型はTで、キーの名前はリテラル型としてKに入っていますから、プロパティアクセス型を用いてobj[key]の型はT[K]と表現できます。

Mapに入る値はオブジェクトそのものなので、2つ目の型引数は普通にTです。

以上が解答の説明です。なお、今回は返り値の型のアノテーション(Map<T[K], T>)を省略してしまうとTypeScriptが推論できず、Map<any, any>にされてしまいます。このように型アノテーションで指示する方法の他に、new Map()new Map<T[K], T>()として型を教えてあげる方法もあります。

3-2. Partial

type MyPartial<T> = { [K in keyof T]?: T[K] };

Mapped typesの基本的な利用法です。MyPartial<T>は、keyof Tに属する各プロパティ名Kに対して、型T[K]を持つKという省略可能なプロパティが存在するようなオブジェクトの型となります。T[K]というのは元のオブジェクトのプロパティの型と同じですから、結果としてMyPartial<T>は元々のオブジェクトの各プロパティが省略可能となっただけのオブジェクトの型となります。

3-3. イベント

class EventDischarger<E> {
  emit<Ev extends keyof E>(eventName: Ev, payload: E[Ev]) {
    // 省略
  }
}

問題文がややこしい割に、やることは単純です。今回のように、引数に渡された文字列に応じて型の挙動を変えたい場合はその文字列をリテラル型として取得するのが定番です。これは3-1でもやりましたね。今回は型引数Evを第1引数の型としました。例えばed.emit("start", { ... })の場合、Evには"start"型が入ります。さらに、Ev extends keyof Eとすることによって、Eに定義されていないイベント名を拒否しています。ed.emit("foobar", { ... })のような呼び出しはこれによって型エラーとなります。

イベント名が型Evとして得られているので、第2引数の型はEから適切なものを取得します。Eイベント名: データの型という形のオブジェクトなので、目的の型はE[Ev]で得られます。

3-4. reducer

type Action =
  | {
      type: "increment";
      amount: number;
    }
  | {
      type: "decrement";
      amount: number;
    }
  | {
      type: "reset";
      value: number;
    };

const reducer = (state: number, action: Action) => {
  switch (action.type) {
    case "increment":
      return state + action.amount;
    case "decrement":
      return state - action.amount;
    case "reset":
      return action.value;
  }
};

アクションの型をActionとし、ユニオン型を用いて定義しています。いわゆる代数的データ型を模したパターンであり、TypeScriptプログラミングでは頻出です。

3-5. undefinedな引数

type Func<A, R> = undefined extends A ? (arg?: A) => R : (arg: A) => R;

Aundefinedかどうかで動作を変えたいということで、conditional typeの出番です。undefined extends Aは正確にはundefined型がA型の部分型であるという条件を表しています。簡単な言葉で言うとこれはAundefinedを受け入れる型であるかどうかを判定しています。例えばAundefinedである場合のほかに、Anumber | undefinedの場合も合致します。Aundefinedを指定可能な場合は引数を省略可能にしたいという問題の趣旨に合致していますね。

undefinedA型の部分型であるときFunc<A, R>(arg?: A) => Rとなります。これにより、引数の省略がOKになります。そうでないときはこれまで通り(arg: A) => Rです。

4-1. 無い場合はunknown

function getFoo<T extends object>(
  obj: T
): T extends { foo: infer E } ? E : unknown {
  return (obj as any).foo;
}

この問題は、conditional typeにおけるinferの典型的な使用例です。まず、getFooに型引数Tを持たせることで、引数objの型をTとして取得しています。この際T extends objectという制約を与えることで、オブジェクトでないものが渡されるのは型エラーとしています。

返り値はT extends { foo: infer E } ? E : unknownであり、ここでconditional typeが使われています。T extends { foo: infer E }というのは、Tfooプロパティを持つ型かどうかで場合分けをするという意味です。また、持つ場合は、fooプロパティの型をEとして取得します。条件が満たされる場合の返り値の型はE、すなわちfooプロパティの型です。条件が満たされない場合の型は、問題文に従いunknownとしています。

なお、conditional typeが関わっている場合は例によってTypeScriptの型推論能力は頼りになりません。この場合はそのままだとobj.fooは存在しないというエラーが出るので、(obj as any)とすることでエラーを抑制する必要があります。

4-2. プロパティを上書きする関数

function giveId<T>(obj: T): Pick<T, Exclude<keyof T, "id">> & { id: string } {
  const id = "本当はランダムがいいけどここではただの文字列";
  return {
    ...obj,
    id
  };
}

giveIdの引数objの型を型引数Tとするのはいつも通りです。返り値の型はPick<T, Exclude<keyof T, "id">> & {id: string}です。

PickExcludeはTypeScriptの標準ライブラリで定義されている型です。Pick<T, K>はオブジェクトTのうち名前がKに含まれるプロパティのみを持つようなオブジェクトの型を返します。例えばPick<{foo: number; bar: string}, 'foo'>{foo: number}となります。K部分に"foo" | "bar"のようなユニオン型を与えることで複数プロパティを持ったオブジェクト型を得ることができます。

Exclude<T, U>Tがユニオン型のとき、Tの構成要素のうちUの部分型であるものを除いた型になります。今回の場合、keyof TTのプロパティ名全てのユニオン型ですから、Exclude<keyof T, "id">T"id"以外のプロパティ名全てのユニオン型となります。なお、keyof T"id"が含まれない場合はExclude<keyof T, "id">keyof Tです。

これらを組み合わせることで、Pick<T, Exclude<keyof T, "id">>Tからidプロパティを除いたオブジェクトの型となります。まずこの型を作ることでidプロパティを消し、それから{ id: string}とのインターセクション型を付けることでidプロパティの上書きを達成しています。

なお、このPick<T, Exclude<keyof T, K>>というパターンは頻出なのでOmitという名前が付けられているのをよく見ます。

type Omit<T, K extends keyof T> = Pick<T, Exclude<keyof T, K>>;

別解

function giveId<T>(
  obj: T
): { [K in keyof T]: K extends "id" ? string : T[K] } & { id: string } {
  const id = "本当はランダムがいいけどここではただの文字列";
  return {
    ...obj,
    id
  } as any;
}

Pick<T, Exclude<keyof T, "id">>の代わりにconditional typeを使ってTidプロパティの型をstringに書き換えたものを作る解法です。Tidプロパティが存在しなかった場合のために& {id: string}はやはり必要です。

4-3. unionは嫌だ

type Spread<Ev, EvOrig, E> = Ev extends keyof E
  ? EvOrig[] extends Ev[]
    ? E[Ev]
    : never
  : never;
class EventDischarger<E> {
  emit<Ev extends keyof E>(eventName: Ev, payload: Spread<Ev, Ev, E>) {
    // 省略
  }
}

Conditional typesの性質をよく理解している必要がありやや難しい問題です。この問題では、型引数Ev"start" | "end"のようなユニオン型なのか、それとも"start"のような単一のリテラル型なのかを判断する必要があります。そのために、payloadの型であるSpread<Ev, EvOrig, E>ではまずEv extends keyof Eという条件分岐によりunion distributionを発生させ、Evをユニオンの構成要素に分解します。なお、Ev extends keyof Eは常に条件を満たしますので、union distributionを発生させる以外の意味はありません。else側に特に意味はないのでneverとしています。

次のEvOrig[] extends Ev[]という条件判定は、EvOrigEvの部分型かどうか判定する目的で行っています。[]として配列型にしているのはEvOrigに対してunion distributionが発生するのを防ぐためです。EvOrigは元々の(union distributionが起きる前の)Evです。

もし元々のEv"start"のような単一のリテラル型だった場合、ここでEvEvOrigは両方"start"となりますから条件を満たします。その結果E[Ev]が得られます。一方、元々のEv"start" | "end"のようなユニオン型だった場合、Ev"start"EvOrig"start" | "end"という状況になります。この場合はEvOrig[] extends Ev[]は満たせませんから型はneverとなります。Evに対してunion distributionが発生しているとはいえ、どのEvに対しても結果はneverなので、結果としてSpread<Ev, EvOrig, E>never | never | ... | neverとなり、それはneverと等しくなります。

結局、Spread<Ev, Ev, E>は、Evが単一のリテラル型のときはE[Ev]となり、そうでないときはneverとなります。never型はどんなオブジェクトも持ち得ない型ですから、(TypeScriptの型システムを欺いてnever型の値を作ったりしない限りは)このとき引数payloadに何を渡しても型エラーとなり、目的を達成することができました。

4-4. 一部だけPartial

type PartiallyPartial<T, K extends keyof T> = Partial<Pick<T, K>> &
  Pick<T, Exclude<keyof T, K>>;

4-2の類題です。TKに属するプロパティを持つ部分とそうでない部分にPickを使って分け、前者にPartialを適用したあと再び結合しています。

4-5. 最低一つは必要なオプションオブジェクト

type PartiallyPartial<T, K extends keyof T> = Partial<Pick<T, K>> &
  Pick<T, Exclude<keyof T, K>>;

type AtLeastOne<T> = Spread<T, keyof T>;
type Spread<T, K extends keyof T> = K extends keyof T
  ? PartiallyPartial<T, Exclude<keyof T, K>>
  : never;

筆者の以前の記事「TypeScriptで最低一つは必須なオプションオブジェクトの型を作る」を読んでいる皆さんにとっては簡単なボーナス問題です。ひとつ上のPartiallyPartial<T, K>を再利用しています。

詳しくはあの記事を読んでいただきたいですが、目標はもともとのOptionsに対して

PartiallyPartial<Options, 'bar' | 'baz'> | PartiallyPartial<Options, 'foo' | 'baz'> | PartiallyPartial<Options, 'foo' | 'bar'>

という型を生成することです。そのために、keyof Tで得られる'foo' | 'bar' | 'baz'のそれぞれの要素をunion distributionを用いながらPartiallyPartial<...>に変換します。

4-6. ページを描画する関数

type PageGenerators = {
  [P in Page["page"]]: (page: Extract<Page, { page: P }>) => string
};

今回、ページの種類は"top""mypage""ranking"の3種類です。これらのユニオン型はPage["page"]として取得できます。PageGenerators型のオブジェクトはこれらの名前のプロパティを持っていなければいけませんから、[P in Page["page"]]というmapped typeを利用します。右辺の型でPはそれぞれ"top", "mypage", "ranking"というリテラル型になります。

右辺の型は関数となる必要がありますが、例えばP"mypage"の場合は{ page: "mypage", userName: string }という型が引数に渡される必要があります。この型を得るのがExtract<Page, { page: P }>という部分です。Extractは標準ライブラリにある型で、ユニオン型であるPageに属する要素のうち{ page: P }の部分型であるものだけが抜き出されます。これにより、P"mypage"の場合は、Pageの中から{ page: "mypage", userName: string }だけが残ります。

4-7. 条件を満たすキーだけを抜き出す

type KeysOfType<Obj, Val> = {
  [K in keyof Obj]-?: Obj[K] extends Val ? K : never
}[keyof Obj];

これは、TypeScriptでキーの型を扱いたいときにmapped typeを経由するという、結構頻出のテクニックです。これは、まずmapped type { ... }でオブジェクト型を作り、それに対してLookup型[keyof Obj]でアクセスしています。

前半部分のmapped typeを抜き出してみましょう。

type Data = {
  foo: string;
  bar: number;
  baz: boolean;

  hoge: string;
  piyo: number;
};

type Mapped<Obj, Val> = {
  [K in keyof Obj]-?: Obj[K] extends Val ? K : never
};

/* T1は
 {
   foo: "foo";
   bar: never;
   baz: never;
   hoge: "hoge";
   piyo: never;
 }
 型
*/
type T1 = Mapped<Data, string>;

このように、mapped typeを用いると、Objと同じキー名を持ちつつ、各キーの型が「条件を満たすならキー名そのもの、条件を満たさないならnever」となるようなオブジェクト型を作ることができます。ここで条件を満たさない場合にneverを用いたのがポイントです。このnever型は、ユニオン型を取ると消えてしまいます。このmapped typeをよく見ると-?という構文が使われていますが、これは元のオブジェクト型からオプショナル性(?)を取り除く機能です。これがないと{ hoge?: "hoge"; }のようになりhogeの型が"hoge" | undefinedとなるため、結果の型にundefinedが混ざってしまいます。別の方法としては、最後にNonNullable<...>undefinendを取り除くという方法もあります。

結局、このオブジェクトのプロパティの型全てのユニオン型を取ると、"foo" | never | never | "hoge" | neverとなり、これは"foo" | "hoge"に等しいので目的が達成できます。「プロパティの型全てのユニオン型を取る」の部分はLookup型を用いて{ ... }[keyof Obj]とすればできます。Lookup型は本来オブジェクトの指定したキーの型を得るための型ですが、キー名としてユニオン型を与えると、それらのキーを持つプロパティの型のユニオン型を得ることができます。例えばObj["foo" | "bar"]Obj["foo"] | Obj["bar"]です。これは、「キー名が"foo"かもしれないし"bar"かもしれない」と考えると、その値が「Obj["foo"]かもしれないしObj["bar"]かもしれない」と解釈でき、自然ですね。

このように、mapped型を用いてキー名をオブジェクト型のプロパティの型のほうに持ってきてから操作し、最後にLookup型でユニオンを取るというテクニックは便利でよく使われます。

4-8. オプショナルなキーだけ抜き出す

type PickUndefined<Obj> = {
  [K in keyof Obj]-?: undefined extends Obj[K] ? K : never
}[keyof Obj];

type MapToNever<Obj> = {
  [K in keyof Obj] : never
}

type OptionalKeys<Obj> = PickUndefined<MapToNever<Obj>>

先ほどの問題の応用編のような問題です。PickUndefined<Obj>は4-7のKeysOfTypeと似ていますが、条件がObj[K] extends Valではなくundefined extends Obj[K]となっています。これは、Obj[K]undefined型のときは満たされますがnever型のときは満たされない条件です。

MapToNever<Obj>はmapped typeで全てのプロパティがnever型となったオブジェクト型を作っています。
OptionalKeys<Obj>は、この2つの型を順番に適用するだけの型となっています。

なぜこれでうまく動くのかは、MapToNever<Obj>の結果を抜き出してみれば分かります。

/*
Oは
{
    foo: never;
    bar?: undefined;
    baz?: undefined;
    hoge: never;
    piyo?: undefined;
}
型
*/
type O = MapToNever<Data>;

このように、MapToNeverの結果は、オプショナルなプロパティはundefined、それ以外のプロパティはneverというオブジェクト型になっています。全てneverに飛ばしたはずなのになぜundefinedが現れているのかという点が疑問ですが、これはオプショナルなプロパティの型には自動的にundefiendが付加されるという性質によるものです。MapToNeverでは、あえて-?を使わないことで、mapped type元のオブジェクトのオプショナルプロパティを敢えて引き継ぐようにしています。

このようにしてオプショナルプロパティとそれ以外のプロパティを区別することができたので、あとはPickUndefinedundefinedのものを抜き出せば終わりです。

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
205