20
19

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

JavaScript/TypeScript でパターンマッチもどき

Last updated at Posted at 2019-11-09

やりたいこと

関数型っぽい書き方に慣れてくると、
メソッド生やすんじゃなくてパターンマッチが書きたくなってきたりします。

例えば Scala なら、ケースクラスと match 式で、
こんな感じのものがさくっと書けたりしますね。

Scalaの例(各種形状から面積を求める)
import scala.math

object Main extends App {
    sealed trait Shape
    case class Circle(r: Double) extends Shape
    case class Rectangle(w: Double, h: Double) extends Shape
    case class Triangle(a: Double, b: Double, c: Double) extends Shape
    
    def area(shape: Shape) = shape match {
        case Circle(r) => r * r * math.Pi
        case Rectangle(w, h) => w * h
        case Triangle(a, b, c) => {
            val s = (a + b + c) / 2;
            math.sqrt(s * (s - a) * (s - b) * (s - c))
        }
    }
    
    println(area(Circle(3)))
    println(area(Rectangle(3, 4)))
    println(area(Triangle(3, 4, 5)))
}

フィールドに条件付けたりとか凝ったことはできなくていいから、
JavaScript や TypeScript でも似たようなことがやりたいなあ。よしやろう。

JavaScript 版

かんたんに。

  • ケースクラス ➡ 第一要素をタグにしたタプル
  • match式 ➡ オブジェクトに突っ込んだ関数群を呼ぶ
JavaScript
const match = ([key, ...values], mapping) => mapping[key](...values);
// 未定義でエラーにしたくない場合
// const match = ([key, ...values], mapping) => mapping[key] && mapping[key](...values);

const circle = r => ['circle', r];
const rectangle = (w, h) => ['rectangle', w, h];
const triangle = (a, b, c) => ['triangle', a, b, c];

const area = shape => match(shape, {
    circle: r => r * r * Math.PI,
    rectangle: (w, h) => w * h,
    triangle: (a, b, c) => {
        const s = (a + b + c) / 2;
        return Math.sqrt(s * (s - a) * (s - b) * (s - c));
    }
});

console.log(area(circle(3)));
console.log(area(rectangle(3, 4)));
console.log(area(triangle(3, 4, 5)));

TypeScript 版

  • (追記) コメントで提示して頂いた方法のほうが見通しが良く間違いを防ぎやすいので、まずそちらを。以下は共用体型操作をする一例だと思って見て頂ければと思います。

同じことをやろうとすると、関数 match(tuple, mapping) の型を定義するのが結構むずかしい。でもがんばって書きます。

(typescript 3.9+ でエラーになるようなので Step1 を修正, 2020/06/24)

TypeScript(matchの定義)
// タグ付きタプル
type TaggedTuple = [string, ...any[]];

// A | B => (A => void) | (B => void)
type Wrap<T> = T extends never ? never : (t: T) => void;

// 共用体型を関数の交差型に
// A | B | C => (A => void) & (B => void) & (C => void)
type ToIntersection<Union> =
  Wrap<Wrap<Union>> extends Wrap<infer Intersection> ? Intersection : never;

// タプルの Union から、タプルを一つ抽出, A | B | C => C
type LastTuple<Tuples extends TaggedTuple> =
  ToIntersection<Tuples> extends Wrap<infer OneTuple> ?
    OneTuple extends TaggedTuple ? OneTuple : never :
    never;

// タプルの Union から、第一要素が Selector であるようなものを抽出
type FindTuple<Tuples extends TaggedTuple, Selector extends string> =
  LastTuple<Tuples>[0] extends Selector ? {
    [0]: LastTuple<Tuples>
  } : {
    [0]: FindTuple<Exclude<Tuples, LastTuple<Tuples>>, Selector>[0]
  };

// Mapping オブジェクトの導出 (推論エラー回避のため分割)
// Step 1
// [A, ...As] | [B, ..Bs]
// => { A: (A, ...As) => void, B: (B, ...Bs) => void };
type Step1<Tuples extends TaggedTuple> = {
  [Selector in Tuples[0]]:
  FindTuple<Tuples, Selector>[0] extends infer U ?
    U extends any[] ? (...args: U) => void : never : never;
};

// Step 2
// { A: (A, ...As) => void, B: (B, ...Bs) => void }
// => { A: (...As) => unknown, B: (...Bs) => unknown };
type Step2<FromStep1> = {
  [Selector in keyof FromStep1]:
    FromStep1[Selector] extends (_: any, ...values: infer Values) => void ?
        (...values: Values) => unknown : never;
};
type MappingObject<Tuples extends TaggedTuple> = Step2<Step1<Tuples>>;

export function match<T extends TaggedTuple, M extends MappingObject<T>>(
  [key, ...values]: T, mapping: M
) {
  return mapping[key as keyof M](...values) as ReturnType<M[keyof M]>;
}

mapping から tuple を導出することもできますが、
それだと漏れチェックも補完もできないので、
tuple から mapping を導出しています。

使い方は、型付けた以外は JavaScript とだいたい同じ。

TypeScript(Usage)
type Circle = ['circle', number];
type Rectangle = ['rectangle', number, number];
type Triangle = ['triangle', number, number, number];
type Shape = Circle | Rectangle | Triangle;

const circle = (r: number): Circle => ['circle', r];
const rectangle = (w: number, h: number): Rectangle => ['rectangle', w, h];
const triangle = (a: number, b: number, c: number): Triangle => ['triangle', a, b, c];

const area = (shape: Shape) => match(shape, {
    circle: r => r * r * Math.PI,
    rectangle: (w, h) => w * h,
    triangle: (a, b, c) => {
        const s = (a + b + c) / 2;
        return Math.sqrt(s * (s - a) * (s - b) * (s - c));
    }
});

console.log(area(circle(3)));
console.log(area(rectangle(3, 4)));
console.log(area(triangle(3, 4, 5)));

match の定義は TypeScript では書けないんじゃないかと思ってたんですが。
なんとかなるもんですね。

型定義部分の補足と懸念点

型定義が何とかなったのは、共用体型の分解ができてしまうためです。

分解する部分だけ書き出してみると、こう。

type F<X> = X extends never ? never : (x: X) => void;
type G<X> = F<F<X>> extends F<infer Y> ?
  Y extends F<infer Z> ? Z : never : never;

type R = G<'A' | 'B' | 'C'>; // 'C'

何が起きているのかわかりにくいので G の中身を追っかけてみます。

例として共用体型 T1|T2 を考えてみます。

まず、共用体型を関数の共用体型に変換します。

F<T1|T2> := ((x:T1)=>void) | ((x:T2)=>void)

次に、関数の共用体型を関数の交差型に変換します。

F<F<T1|T2>> extends F<infer Y> ? Y : never

Y := ((x:T1)=>void) & ((x:T2)=>void)

共用体型➡交差型の変換は、すでに解説記事/コメントを書いてくださっている方がいるので、詳しく知りたい方は最後の参考からどうぞ。

今回はこの関数の交差型を、一つの関数とみなして引数を取り出します。

F<F<T1|T2>> extends F<infer Y> ? Y extends F<infer Z> ? Z : never : never

Z :=
  ((x:T1)=>void) & ((x:T2)=>void) extends
    (x: infer R) => void ? R : never;

さて、Z は何になるでしょう。
普通に考えたら T1|T2 に戻るような気がするのですが、
TypeScript はこれを T2 と推論します。

何故かと言うと、仕様上(TypeScript の場合実装上と言うべきか)、
関数の交差型は一つの関数に統合されないためです。
今回 1 引数だからできそうな気がしますが、
2 引数以上だとおかしなことになります。
結果として、このケースでは最後の推論候補が選ばれます。

何で never とかではなく最後の候補なのか、というと、
どういう判断でこうしたのか正直よくわかりません。
教えて TypeScript の偉い人。あるいは識者の方。

腑に落ちないですが、
この仕組み使うことで共用体型を分解することができます。

本家の issue とかにも、これに依存したコード例を書いている人が
ちらほらいるので、いきなり使えなくなることは無いとは思いますが、
将来的に breaking change が無いとも言い切れないのが若干の懸念点。

タプルではなくオブジェクト版の match も作れそうな気がしますが、力尽きたので終了。

参考

20
19
7

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
20
19

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?