TypeScript 型パズル置き場

- タプルから特定の型に一致するものを絞り込んだり、除外したりするユーティリティ型
type FilterTuple<T extends unknown[], Condition> = T extends [infer First, ...infer Rest]
? First extends Condition
? [First, ...FilterTuple<Rest, Condition>]
: FilterTuple<Rest, Condition>
: [];
type ExcludeTuple<T extends unknown[], Condition> = T extends [infer First, ...infer Rest]
? First extends Condition
? ExcludeTuple<Rest, Condition>
: [First, ...ExcludeTuple<Rest, Condition>]
: [];
// 使用例
type OriginalTuple = [1, 'hello', 2, 'world', 3];
type FilteredTuple = FilterTuple<OriginalTuple, number>; // [1, 2, 3]
type ExcludedTuple = ExcludeTuple<OriginalTuple, number>; // ['hello', 'world']

オブジェクトのうち1レコードだけを求めるOneofユーティリティ型
import { expectTypeOf } from "vitest";
type Obj = {
foo: string;
bar: number;
buzz: boolean;
};
type Oneof<T extends Record<string, unknown>> = {
[K in keyof T]: K extends string ? { [_ in K]: T[K] } : never;
}[keyof T];
const example: Oneof<Obj> = { foo: "foo" };
expectTypeOf(example).toEqualTypeOf<{ foo: string } | { bar: number } | { buzz: boolean}>()
TypeScript上では1つであることは保証できず、1つ以上であることになる。
複数渡してしまった場合に先頭を取り出すロジックを書く必要があることに留意が必要。

JSON構造を.繋ぎで指定するためのユニオン型を生成する
type JSONKey = string | number
type Join<K extends JSONKey, P extends JSONKey> = `${K}.${P}`;
type Paths<T extends object> = {
[K in keyof T]: K extends JSONKey
? T[K] extends object
? Join<K, Paths<T[K]>>
: K
: never;
}[keyof T];
使用例
import locale from "../locale/ja.json";
type Locale = Paths<typeof locale>
// "home.title" | "service.title" | "about.content.title" | "about.content.description"

ビルダーパターン
type Builder<T> = FluentBuilder<T>;
type FluentBuilder<T, R = {}> = {
[K in Exclude<keyof T, keyof R>]-?: BuilderFunction<T, K, R>;
} & (RequiredKeys<T> extends keyof R
? {
build: () => R;
}
: {});
type BuilderFunction<T, K extends keyof T, R> = <V extends T[K]>(
value: V,
) => FluentBuilder<
T,
{ [P in keyof R | K]: P extends keyof R ? R[P] : V }
>;
type RequiredKeys<T> = {
[K in keyof T]-?: {} extends Pick<T, K> ? never : K;
}[keyof T];
// usage
type User = {
name: string;
age: number;
active: boolean;
introduction?: string;
};
declare const userBuilder: Builder<User>;
const user1 = userBuilder.active(true).age(1).name("1").build();
// ^?{ name: "a", age: 20, active: true}
Type Challengesにも出てくる、Builderパターンを型で再現しました。
問題では型推論が効くことだけが条件でしたが、より実践で使えるように考慮事項を追加しています。
- buildメソッドの出現条件を設定
全ての必須パラメータが網羅されていない限り、buildメソッドを呼べなくしています。 - オプショナルなプロパティにも対応
問題ではオプショナルなプロパティに対して関数が呼び出せなくても正解になりますが、実際に期待される挙動は呼び出せること。
オプショナルなプロパティはあってもなくてもbuildできるようにしています。 - 型ホバー時の可読性考慮
userBuilderにホバーして際に以下のように表示されるようにしました。
const userBuilder: {
name: BuilderFunction<User, "name", {}>;
age: BuilderFunction<User, "age", {}>;
active: BuilderFunction<User, "active", {}>;
introduction: BuilderFunction<...>;
}
ライブラリの中にはホバーすると内部の知識が型に漏れ出ていて、何をしている型なのかがぱっと見わかりづらくユーザーに意識させてしまうものがあります。
見える型にもこだわって設計してこそ型マスター!

OptionalなneverをRecordから取り除く方法
type Example = {
a: string;
b?: never;
c: number;
d: never;
};
Union型をExtract
すると他の型に存在しないプロパティまで型に含まれてしまい、上記のようなoptionalなnever型ができてしまう。
neverを取り除きたいだけなら以下のような型で取り除ける。
type FilterNever<T extends Record<PropertyKey, unknown>> = {
[P in keyof T as T[P] extends never ? never : P]: T[P]
}
ただし、これだとoptionalなneverは取り除くことができない。
type FilterNever<T extends Record<PropertyKey, unknown>> = {
[P in keyof T as T[P] extends never | (never | undefined) ? never : P]: T[P]
}
こう改良すれば正しくマッチしてoptionalなneverも取り除くことができる。

オブジェクトのキーに存在するかを判別する
key in obj
やObject.hasOwn(obj, key)
はkey
変数がobj
のプロパティとして存在しているかどうかを判別しているにもかかわらず、型のnarrowingが行われない。
このようなユーティリティを作っておけば、この関数を使った分岐のブロック内ではkeyがkeyof Tであることが確定する!
const isKeyof =
<T extends object>(object: T) =>
(key: PropertyKey): key is keyof T =>
Object.hasOwn(object, key);
// 使用例
const obj = { a: "a", b: "b"}
declare const key: PropertyKey
// ^? const key: PropertyKey
if (isKeyof(obj)(key)) {
obj[key]
// ^? const key: "a" | "b"
}
引数を2つ取る関数ではなく、高階関数として定義することで、変数名のisKeyofが第一引数に対して修飾していることが明示され、返される関数が型ガード関数になるので、同じオブジェクトに対しての判別を複数回行いたい場合にも対応できるようになる。
declare const user: { id: string, name:string, age: number }
const isKeyofUser = isKeyof(user);
declare const [key1, key2]: [PropertyKey, PropertyKey]
if (isKeyofUser(key1)) {
key1
// ^? const key1: "id" | "name" | "age"
}
if (isKeyofUser(key2)) {
key2
// ^? const key2: "id" | "name" | "age"
}

あまり使わないけどいつか役に立ちそうな型宣言
// 普通の関数
declare const normal: () => void
normal()
// 関数がプロパティとして子関数を持つことができる
declare const callable: { (): void; fn: () => void };
callable();
callable.fn();
// new構文でインスタンス化可能なオブジェクトの型も宣言可能
declare const Newable: { new (): void; fn: () => void };
new Newable();
Newable.fn();
ライブラリとかで型を宣言するのに使えそう。