この記事は、「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
の部分です。これはK
がkeyof T
の部分型でなければいけないということを示しており、keyof T
はT
が持つプロパティ名いずれかの型です。今回の使用例では、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;
A
がundefined
かどうかで動作を変えたいということで、conditional typeの出番です。undefined extends A
は正確にはundefined
型がA
型の部分型であるという条件を表しています。簡単な言葉で言うとこれはA
がundefined
を受け入れる型であるかどうかを判定しています。例えばA
がundefined
である場合のほかに、A
がnumber | undefined
の場合も合致します。A
にundefined
を指定可能な場合は引数を省略可能にしたいという問題の趣旨に合致していますね。
undefined
がA
型の部分型であるとき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 }
というのは、T
がfoo
プロパティを持つ型かどうかで場合分けをするという意味です。また、持つ場合は、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}
です。
Pick
とExclude
はTypeScriptの標準ライブラリで定義されている型です。Pick<T, K>
はオブジェクトT
のうち名前がK
に含まれるプロパティのみを持つようなオブジェクトの型を返します。例えばPick<{foo: number; bar: string}, 'foo'>
は{foo: number}
となります。K
部分に"foo" | "bar"
のようなユニオン型を与えることで複数プロパティを持ったオブジェクト型を得ることができます。
Exclude<T, U>
はT
がユニオン型のとき、T
の構成要素のうちU
の部分型であるものを除いた型になります。今回の場合、keyof T
はT
のプロパティ名全てのユニオン型ですから、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を使ってT
のid
プロパティの型をstring
に書き換えたものを作る解法です。T
にid
プロパティが存在しなかった場合のために& {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[]
という条件判定は、EvOrig
がEv
の部分型かどうか判定する目的で行っています。[]
として配列型にしているのはEvOrig
に対してunion distributionが発生するのを防ぐためです。EvOrig
は元々の(union distributionが起きる前の)Ev
です。
もし元々のEv
が"start"
のような単一のリテラル型だった場合、ここでEv
とEvOrig
は両方"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の類題です。T
をK
に属するプロパティを持つ部分とそうでない部分に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元のオブジェクトのオプショナルプロパティを敢えて引き継ぐようにしています。
このようにしてオプショナルプロパティとそれ以外のプロパティを区別することができたので、あとはPickUndefined
でundefined
のものを抜き出せば終わりです。