LoginSignup
4
5

More than 3 years have passed since last update.

TypescriptでObjectの型からDiscriminated Unionsを作成し、ADTのパターンマッチのようなことをする

Last updated at Posted at 2020-03-31

この記事では、Union Typeをリストのように使用して、他の関数型言語のADT(代数的データ型)のパターンマッチに相当するものを作ってみます。(タイトルが分かりにく過ぎたので直しました。)

Literal TypeのUnion Typeをリストのように使用する

Literal TypeのUnion Type("a"|"b"|"c"などの、keyofで得られるやつ)を、Mapped typeとIndex typeを使用することで、リストのように使用することができます。
例えば、あるリストの個々の要素の型をF<T>という形に変換(マップ)したリストが欲しいなら、以下のように書けます。

type F<T> = { a: T, b: T };
type MappedType<Ts extends string> = { [T in Ts]: F<T> }[Ts];

type TExamples = "c" | "d" | "e";
type TMappedExample = MappedType<TExamples>;
// TMappedExample == F<"c"> | F<"d"> | F<"e">
//                == {a: "c", b: "c"} | {a: "d", b: "d"} | {a: "e", b: "e"}

Typescriptの2.7以降は、Index typeに対して、Union Typeをインデックスに指定すると、Distributive conditional typeと同じように分配してくれるようです。(公式ドキュメントに仕様の記載がないので不安ではありますが)

オブジェクト型を挟むことで、Literal Typeに限らず、色々な型を使用することもできます。

type F<T> = { a: T, b: T };
type MappedType<Dic> = { [T in keyof Dic]: F<Dic[T]> }[keyof Dic];

type TExamples = { "0": number, "1": string, };
type TMappedExample = MappedType<TExamples>;
// TMappedExample == F<number> | F<string>
//                == {a: number, b: number} | {a: string, b: string} | {a: boolean, b: boolean}

以下のようにDistributive conditional typeを使用しても同様のことができますが、型は作成できるものの、実際に関数を書く際に、asなどのキャストを使用しないとうまく型推論してくれないようです。

type F<T> = { a: T, b: T };
type MappedType<Ts> = Ts extends Ts ? F<Ts> : never;

type TExamples = number | string;
type TMappedExample = MappedType<TExamples>;
// TMappedExample == F<number> | F<string>
//                == {a: number, b: number} | {a: string, b: string}

Objectの型からDiscriminated Unionsを作成する

TypescriptのEnumsって使いにくいらしいので、代わりにDiscriminated Unionsの人気が高いですが、Discriminated Unionsも宣言がちょっと面倒くさいですよね。ということで、Objectの型からDiscriminated Unionsのようなものを生成してみましょう。

// Discriminated Unionsを格納する各オブジェクト
type TaggedUnionTypeStructure<Dic, Key extends keyof Dic> = { kind: Key, data: Dic[Key] };
// TaggedUnionTypeStructureを含むオブジェクトの型を作成する
type TaggedUnionTypeDistributer<Dic> = { [Key in keyof Dic]: TaggedUnionTypeStructure<Dic, Key> };
// オブジェクトの値部分の型を得る汎用の型
type ValuesType<Dic> = Dic[keyof Dic];
// DicからDiscriminated Unionsを生成する型
type TaggedUnionType<Dic> = ValuesType<TaggedUnionTypeDistributer<Dic>>;

// kindとdataからDiscriminated Unionsの値を生成する関数
function createUnion<Dic, Key extends keyof Dic>(key: Key, data: Dic[Key]): TaggedUnionType<Dic> {
    return { kind: key, data: data };
}

// switch文に相当する関数
function unionSwitch<Dic, R>(val: TaggedUnionType<Dic>, funcs: { [Key in keyof Dic]: ((val: Dic[Key]) => R) }): R {
    // 与えられる引数が信用できない場合は、funcs[val.kind]が存在するかを確認するとよい
    return funcs[val.kind](val.data);
}

// example
// TExample1 == TExample1a
type DicExample1 = { a1: { e1: number, e2: string }, a2: { e3: boolean }, };
type TExample1 = TaggedUnionType<DicExample1>;
type TExample1a = { kind: "a1", data: { e1: number, e2: string } } | { kind: "a2", data: { e3: boolean } };

let f1 = (a: TExample1): TExample1a => a;
let f2 = (a: TExample1a): TExample1 => a;

const funcs = {
    a1: (data: { e1: number, e2: string }) => String(data.e1) + data.e2,
    a2: (data: { e3: boolean }) => String(data.e3),
};

const example_value = createUnion<DicExample1, "a1">("a1", { e1: 20, e2: "asdf" });
// example_value == { kind: "a1", data: { e1: 20, e2: "asdf" } }
const example_result = unionSwitch(example_value, funcs);
// example_result == "20asdf"

え、kind以外のフィールドをdataにまとめたくないって? しょうがないなぁ。

// Discriminated Unionsを格納する各オブジェクト
type TaggedUnionTypeStructure<Dic, Key extends keyof Dic> = Dic[Key] & { kind: Key };
// TaggedUnionTypeStructureを含むオブジェクトの型を作成する
type TaggedUnionTypeDistributer<Dic> = { [Key in keyof Dic]: TaggedUnionTypeStructure<Dic, Key> };
// オブジェクトの値部分の型を得る汎用の型
type ValuesType<Dic> = Dic[keyof Dic];
// DicからDiscriminated Unionsを生成する型
type TaggedUnionType<Dic> = ValuesType<TaggedUnionTypeDistributer<Dic>>;


// kindとdataからDiscriminated Unionsの値を生成する関数
function createUnion<Dic, Key extends keyof Dic>(key: Key, data: Dic[Key]): TaggedUnionType<Dic> {
    return { kind: key, ...data };
}

// switch文に相当する関数
function unionSwitch<Dic, R>(val: TaggedUnionType<Dic>, funcs: { [Key in keyof Dic]: ((val: Dic[Key]) => R) }): R {
    // 与えられる引数が信用できない場合は、funcs[val.kind]が存在するかを確認するとよい
    return funcs[val.kind](val);
}

// example
// TExample1 == TExample1a
type DicExample1 = { a1: { e1: number, e2: string }, a2: { e3: boolean }, };
type TExample1 = TaggedUnionType<DicExample1>;
type TExample1a = { kind: "a1", e1: number, e2: string } | { kind: "a2", e3: boolean };

let f1 = (a: TExample1): TExample1a => a;
let f2 = (a: TExample1a): TExample1 => a;

const funcs = {
    a1: (data: { e1: number, e2: string }) => String(data.e1) + data.e2,
    a2: (data: { e3: boolean }) => String(data.e3),
};

const example_value = createUnion<DicExample1, "a1">("a1", { e1: 20, e2: "asdf" });
// example_value = { kind: "a1", e1: 20, e2: "asdf" }
const example_result = unionSwitch(example_value, funcs);
// example_result == "20asdf"

以下のようにconditional typeを使用しても、型自体は定義できますが、createUnionやunionSwitchで型推論に問題が生じるようです。

NG例1:

type TaggedUnionTypeStructure<Dic, Key extends keyof Dic> = { kind: Key, data: Dic[Key] };
type TaggedUnionTypeDistributer<Dic> = { [Key in keyof Dic]: TaggedUnionTypeStructure<Dic, Key> };
// ValuesTypeをconditional typeを使用して定義
type ValuesType<Dic> = Dic extends {[key in keyof Dic]: infer Val} ? Val : never;
type TaggedUnionType<Dic> = ValuesType<TaggedUnionTypeDistributer<Dic>>;

// Error: Type '{ kind: Key; data: Dic[Key]; }' is not assignable to type 'ValuesType<TaggedUnionTypeDistributer<Dic>>'.(2322)
function createUnion<Dic, Key extends keyof Dic>(key: Key, data: Dic[Key]): TaggedUnionType<Dic> {
    return { kind: key, ...data };
}

function unionSwitch<Dic, R>(val: TaggedUnionType<Dic>, funcs: { [Key in keyof Dic]: ((val: Dic[Key]) => R) }): R {
    return funcs[val.kind](val);
}

NG例2:

type TaggedUnionTypeStructure<Dic, Key extends keyof Dic> = { kind: Key, data: Dic[Key] };
type TaggedUnionTypeDistributer<Dic, Key> = Key extends keyof Dic ? TaggedUnionTypeStructure<Dic, Key> : never;
type TaggedUnionType<Dic> = TaggedUnionTypeDistributer<Dic, keyof Dic>;

// Error: Type '{ kind: Key; data: Dic[Key]; }' is not assignable to type 'TaggedUnionTypeDistributer<Dic, keyof Dic>'.(2322)
function createUnion<Dic, Key extends keyof Dic>(key: Key, data: Dic[Key]): TaggedUnionType<Dic> {
    return { kind: key, data: data };
}

// Error: Argument of type 'Dic[keyof Dic & number] | Dic[keyof Dic & string] | Dic[keyof Dic & symbol]' is not assignable to parameter of type 'Dic[keyof Dic & string] & Dic[keyof Dic & number] & Dic[keyof Dic & symbol]'.
function unionSwitch<Dic, R>(val: TaggedUnionType<Dic>, funcs: { [Key in keyof Dic]: ((val: Dic[Key]) => R) }): R {
    return funcs[val.kind](val.data);
}

2020/07/21 修正

これまで、createUnionを以下のようにしていました。

function createUnion<Dic, Key extends keyof Dic = keyof Dic>(key: Key, data: Dic[Key]): TaggedUnionType<Dic> {
  // ...
}

これだと、以下のように、型に合わないものが作成できてしまいます。(これでいいのかTypescript)

const example_value:TExample1 = createUnion<DicExample1>("a1", { e3: true });
// example_value == { kind: "a1", data: { e3: true } }

修正後のコードでは、故意にKeyパラメータにUnion型を与えない限りは大丈夫です。

こう少し短めの再現コードは以下。

type KeyAndValue<D> = { [K in keyof D]: [K, D[K]] }[keyof D];
function createT<D, K extends keyof D = keyof D>(key: K, value: D[K]): KeyAndValue<D> {
    return [key, value];
}

type Dic = { a: number, b: string };
// type T = ["a", number] | ["b", string]
type T = KeyAndValue<Dic>;

const ng: ["a", "string"] = ["a", "string"];

// Error: Type '["a", "string"]' is not assignable to type '["a", number] | ["b", string]'.
const ng1: T = ng;

// OK: ng2 = ["a", "string"]
const ng2: T = createT<Dic>("a", "string");

まとめ

Index typeにUnion typeを入れることで、Literal TypeのUnion Typeをリストのように使用できました。これにより、Objectの型からDiscriminated Unionsを作成できました。

参考

4
5
0

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
4
5