Software Design 2024年5月号を読んだ
5 min read
2024 年 5 月号は TypeScript 特集だったので気になってました。 正直未だに雰囲気で使ってるところもあったので、改めて情報を整理するいい機会になった。
widening
const user = { name: "foo", age: 25 };
上みたいなオブジェクトを定義したときにそれぞれのプロパティがリテラルじゃなくてプリミティブな型として推論されるのは知っていたけど、その型の拡大を「widening」と呼ぶとのこと。
satisfies 演算子
as const satisfies
を使うことによって推論結果を保持しつつ型を制限することができる。
satiesfies 演算子は TypeScript4.9 で導入されました。アノテーションと何が違うのかよくわかっていなくて積極的に使えてませんでしたが、アノテーションの違いとしては型推論がそのまま保持されることが便利ポイント。
こちらの記事も参考になりました。
Union 型
オブジェクトの型でオプショナルなプロパティを設定するときに、以下のようにプロパティ名の後ろに?
を付けるパターンを使うか、Union 型を使うか迷うことがあったなと読んでいて思い出しました。
type Option1 = {
foo?: string;
};
type Option2 = {
foo: string | undefined;
};
私はわりと Option1 の書き方をしていることが多かったのですが、実際にオブジェクトを定義するときに書き忘れたのか意図して省略したのかが判別できないといった点を考慮できてなかったなと感じました。 Option2 の書き方でも良さそう。
Result 型
Rust を少し触ったときにも出てきた成功と失敗を表現するための Result 型を TypeScript で表現するとこう。
type Success<T> = { success: true; value: T };
type Failure<E> = { success: false; error: E };
type Result<T, E = unknown> = Success<T> | Failure<E>;
function readDate(): Result<string, Error> {
if (Math.random() < 0.5) {
return { success: true, value: "data" };
} else {
return { success: false, error: new Error("Failed to read data") };
}
}
const result = readData();
if (result.success) {
console.log(result.value);
} else {
console.log(result.error);
}
今まで例外処理は try-catch だけ使っていて Result 型を使ってハンドリングしたことがなかったけど、こうみると便利そうに見えるし、型からも例外が起こり得るのだなと読み取れて良さそう。Result型用のライブラリもある。
構造的型付けとブランド型
型システムには「名前的型付け」と「構造的型付け」が存在して、TypeScriptは後者を採用している。TypeScriptが構造的型付けを採用した理由はダックタイピングやオブジェクトリテラルなどJavaScriptの特徴と相性が良いためだが、TypeScriptでも名前的型付けを実現する方法がある。
意図せずに型が互換性を持ってしまった場合に名前的型付けを実現することで区別できるが、その方法としてprivateプロパティを持つclassを使ったやり方とブランド型がある。デザインパターンとして存在しているのは理解したけど、個人的にはブランド型の型アサーションを使ったやり方に若干不安を感じた。
ブランド型を使った型の区別の例
type UserId = {
__brand: "UserId";
id: number;
}
type ProductId = {
__brand: "ProductId";
id: number;
}
const userId = { id: 1 } as UserId;
実践Mapped Types
普段Mapped Typesを使わずにアプリケーションのコードを書いてきたので難しめな印象を持っていたがたぶん理解できたと思う。
条件型
入力された型に対して別の型を返すことができる。 この三項演算子の形が入れ子になっていくとすごい読みづらい。。。
type R1 = true extends boolean ? 1 : 2;
// => 1
type F<T> = T extends { v: boolean } ? 1 : 2;
type R1 = F<{v: true}>; // => 1
type R2 = F<{v: false}>; // => 1
type R3 = F<{v: "foo"}>; // => 2
type R4 = F<boolean>; // => 2
inferによる型の取り出し
条件型の機能でinferキーワードと一緒に使うことで型に名前をつけて取り出すことができる。
type ValueType<T> = T extends { value: infer U } ? U : never;
type F = ValueType<{ value: number }>;
// => number
Mapped Types
オブジェクト型のキー初期化では、in演算子を使った特殊な宣言を行える。
type T = { a: number; b: string; };
type M = {
[k in keyof T]: T[k]
}
// => { a: number; b: string; };
Pick2の実装
これらの機能を使ってPickと同じふるまいをするPick2の実装が以下。
type Pick2<T, K extends keyof T> {
[P in K]: T[P]
}
type F = <Pick2<{a: number; b: string}, "b">>
// => { b: string };
IDE上で読みやすくするユーティリティ
type User = {
id: string;
name: string;
createAt: number;
}
type Optional<T extends {}, K extends keyof T> = Omit<T, K> & {
[k in K]?: T[k] | undefined;
}
type DraftUser = Optional<User, "id" | "createAt">;
インターセクションやユーティリティ型を含んだ複雑な型はIDE上だとLanguage Serverが定義されたそのままの方情報を保持しようとするため読みづらいが、Identify
type Identify<T> = T extends infer U ? { [K in keyof U]: U[K] } : never;
type DraftUser = Identify<Optional<User, "id" | "createAt">>
/*
{
name: string;
id?: string | undefined;
createAt?: number | undefined;
}
*/
章の中だと以下の記載だったけど、上の方が正しい型になる。 in演算子のところがおそらく間違っている。
type Identify<T> = T extends infer U ? { [K in keyof U]: K } : never;
type DraftUser = Identify<Optional<User, "id" | "createAt">>
/*
{
name: "name";
id?: "id" | undefined;
createAt?: "createAt" | undefined;
}
*/
Mapped Typesのパターン
type F<T extends {}> = {
[k in keyof T]: T[k] extends string ? true : false;
}
type User = {
id: number;
name: string;
createdAt: number;
}
type R1 = F<User>
/*
{
id: false;
name: true;
createdAt: false;
}
*/
おわり
Mapped Types周りの知見がほとんどなかったのでキャッチアップできてよかった。ただ筆者の方も書いている通りそもそも難しかったり読み手の負担が増えることもあるので使い所は考えたほうが良さそう。特に条件型が入れ子になるようなコードの例を最初に見たときに数分固まってしまったので。