キーワード: 型レベル整数, 幽霊型
前回の記事の予告通り, TypeScript 4.0 で次元つきの物理量の計算を安全に行うためのライブラリを作ってみました. ただし現状では PoC で, 実用に足るかまでは考慮していません.
github.com
物理量についての計算を行う場合, その次元や単位系には特に注意を払う必要があります. 次元の違う値同士 (例えば質量と長さ) の足し算には意味がありませんし, 単位系の違う値同士の計算は誤った結果を導いてしまいます (火星探査機の事故が有名ですね).
こういった次元や単位系の取り違えを型システムを使って静的に検出する手法については, Haskell のような型システムが比較的高機能な言語においていくつか先行事例があります (例: dimensional, units). 今回作ったものは, 同様のことを TypeScript でも実現できないか試みたものです.
具体例を紹介すると, 以下のように, 次元や単位系が一致していない箇所に対して型エラーを発生させることができます.
import {
mass,
length,
mks,
cgs,
add,
mul,
conv,
} from "@susisu/ts-dimensional";
const m1 = mass(1, mks);
const m2 = mass(2, mks);
const m3 = mass(3, cgs);
const l1 = length(1, mks);
const l2 = length(2, mks);
const l3 = length(3, cgs);
add(m1, m2);
add(m1, l1);
add(m1, m3);
mul(m1, l1);
add(mul(m1, l1), mul(m2, l2));
mul(m1, l3);
const m4 = conv(m1, cgs);
add(m3, m4);
この記事ではこのライブラリの実装について大まかに解説します. これを実現する上で鍵となるのは, 型レベルでの整数の計算と, 幽霊型のようなものを実現する方法です.
次元つきの物理量
まずはどういった計算を安全に行いたいかを考えていきましょう.
最初に次元を表す値の型を定義します. 物理量の次元は M (質量), L (長さ), T (時間) といった基本的な次元の積であるので, ここではそれぞれの基本的な次元ごとの冪指数を持つ辞書として表すこととします.
import { Base, bases } from "./dimension";
export type DimensionRepr = Readonly<{ [B in Base]: number }>;
ここで Base
は基本的な次元ですが, ここでは簡単のため M, L, T の 3 つに絞っています. bases
は後でイテレーションする際に使います (このように定義しておくとイテレーション時に重複や漏れがなくなるので便利).
export const bases = ["M", "L", "T"] as const;
export type Base = typeof bases[number];
続いて単位系を定義します. 単位はそれぞれ記号と, なんらか基本となる単位に対する係数のペアと定義し, 単位系はそれらを基本的な次元ごとにまとめたものとします.
export type UnitRepr = Readonly<{
symbol: string;
factor: number;
}>;
export type UnitSystemRepr = Readonly<{ [B in Base]: UnitRepr }>;
最後に, 物理量は (値, 次元, 単位系) の 3 つの組で表すこととします.
export type QuantityRepr = Readonly<{
value: number;
dimension: DimensionRepr;
unitSystem: UnitSystemRepr;
}>;
さて表現を決めたところで, 今度はこれらの間の計算を定義していきましょう.
まずは次元同士の掛け算・割り算です. ここは特に不安になる要素はありませんね.
export function mulD(d1: DimensionRepr, d2: DimensionRepr): DimensionRepr {
return {
M: d1.M + d2.M,
L: d1.L + d2.L,
T: d1.T + d2.T,
};
}
export function divD(d1: DimensionRepr, d2: DimensionRepr): DimensionRepr {
return {
M: d1.M - d2.M,
L: d1.L - d2.L,
T: d1.T - d2.T,
};
}
続いて物理量同士の足し算・引き算ですが, ここで最初に書いたとおり, 事前条件として次元と単位系の一致が要求されます.
export function add(q1: QuantityRepr, q2: QuantityRepr): QuantityRepr {
return {
value: q1.value + q2.value,
dimension: q1.dimension,
unitSystem: q1.unitSystem,
};
}
export function sub(q1: QuantityRepr, q2: QuantityRepr): QuantityRepr {
return {
value: q1.value - q2.value,
dimension: q1.dimension,
unitSystem: q1.unitSystem,
};
}
これらの条件を, 型を使って守られるようにするのが最終的な目標となります. ここでは一旦条件が何であるかをコメントで書くことにとどめて先に進みましょう.
物理量同士の掛け算・割り算についても, 単位系が一致するという事前条件が課されます.
export function mul(q1: QuantityRepr, q2: QuantityRepr): QuantityRepr {
return {
value: q1.value * q2.value,
dimension: mulD(q1.dimension, q2.dimension),
unitSystem: q1.unitSystem,
};
}
export function div(q1: QuantityRepr, q2: QuantityRepr): QuantityRepr {
return {
value: q1.value / q2.value,
dimension: divD(q1.dimension, q2.dimension),
unitSystem: q1.unitSystem,
};
}
物理量を別の単位系での値に変換する関数も定義しておきましょう. これについては特に事前条件はありません.
export function conv(q: QuantityRepr, s: UnitSystemRepr): QuantityRepr {
return {
value: q.value * convFactor(q.dimension, q.unitSystem, s),
dimension: q.dimension,
unitSystem: s,
};
}
function convFactor(d: DimensionRepr, s1: UnitSystemRepr, s2: UnitSystemRepr): number {
return bases.reduce((factor, base) => factor * (s1[base].factor / s2[base].factor) ** d[base], 1);
}
さて, 物理量同士の計算を行う際の事前条件を型で判定するためには,
- 型レベルで次元と単位系を表現すること
- 値に対して型レベルの次元と単位系でラベルを付けること
の 2 つが必要となります. 特に前者のうち次元については, 掛け算や割り算のことを考慮すると, 単に異なる次元を区別できるだけでなく, 次元同士の演算も型レベルで行わなくてはなりません.
ということで, ここからはしばらくは次元や単位系を型レベルで表現することについて考え, 最後にそれらを使って, 値に対してラベル付けを行っていきます.
型レベルで次元を表現するために, まずは自然数から始めていきましょう. これはおおよそ前回の記事で紹介したものと同じですが, ここでは N 個の never
型の要素を持つタプル型で自然数 N を表すこととします.
export type NaturalKind = never[];
type AsNatural<N extends NaturalKind> = N;
export type Nat = {
[0]: AsNatural<[]>,
[1]: AsNatural<[never]>,
[2]: AsNatural<[never, never]>,
[3]: AsNatural<[never, never, never]>,
[4]: AsNatural<[never, never, never, never]>,
[5]: AsNatural<[never, never, never, never, never]>,
[6]: AsNatural<[never, never, never, never, never, never]>,
[7]: AsNatural<[never, never, never, never, never, never, never]>,
[8]: AsNatural<[never, never, never, never, never, never, never, never]>,
[9]: AsNatural<[never, never, never, never, never, never, never, never, never]>,
};
加減算は TypeScript 4.0 の新機能である variadic tuple types を使って行います. 足し算や引き算をするのに再帰的型定義の沼に足を踏み入れなくても済むのが良いですね.
export type Incr<N extends NaturalKind> = AsNatural<[never, ...N]>;
export type Decr<N extends NaturalKind> =
N extends [never, ...infer N0]
? (N0 extends NaturalKind ? AsNatural<N0> : never)
: never;
export type Add<N1 extends NaturalKind, N2 extends NaturalKind> = AsNatural<[...N1, ...N2]>;
export type Sub<N1 extends NaturalKind, N2 extends NaturalKind> =
N1 extends [...N2, ...infer N3]
? (N3 extends NaturalKind ? AsNatural<N3> : never)
: never;
型レベル整数
続けて整数を定義していきましょう. ここでは素朴に, 符号と絶対値を表す自然数のペアとして表すこととします.
import { NaturalKind } from "./natural";
export type IntegerKind = {
sign: Sign,
abs: NaturalKind,
};
export type Sign = "+" | "-";
type AsInteger<Z extends IntegerKind> = Z;
これだけでは 0 の表現として +0 と -0 の二通りが存在して曖昧なので, +0 を標準とするよう定義しておきます.
import { Nat } from "./natural";
type Canonical<Z extends IntegerKind> =
Z["abs"] extends Nat[0]
? AsInteger<{ sign: "+", abs: Z["abs"] }>
: Z;
整数を作るためのユーティリティと, よく使う範囲の整数も定義しておきます.
export type MkInteger<S extends Sign, N extends NaturalKind> = Canonical<AsInteger<{
sign: S,
abs: N,
}>>;
export type Int = {
[-9]: MkInteger<"-", Nat[9]>,
[-8]: MkInteger<"-", Nat[8]>,
[-7]: MkInteger<"-", Nat[7]>,
[-6]: MkInteger<"-", Nat[6]>,
[-5]: MkInteger<"-", Nat[5]>,
[-4]: MkInteger<"-", Nat[4]>,
[-3]: MkInteger<"-", Nat[3]>,
[-2]: MkInteger<"-", Nat[2]>,
[-1]: MkInteger<"-", Nat[1]>,
[0]: MkInteger<"+", Nat[0]>,
[1]: MkInteger<"+", Nat[1]>,
[2]: MkInteger<"+", Nat[2]>,
[3]: MkInteger<"+", Nat[3]>,
[4]: MkInteger<"+", Nat[4]>,
[5]: MkInteger<"+", Nat[5]>,
[6]: MkInteger<"+", Nat[6]>,
[7]: MkInteger<"+", Nat[7]>,
[8]: MkInteger<"+", Nat[8]>,
[9]: MkInteger<"+", Nat[9]>,
};
続けて計算ですが, まず +1, -1 は愚直に計算するだけです.
import { Incr as IncrN, Decr as DecrN } from "./natural";
export type Incr<Z extends IntegerKind> =
Z["abs"] extends Nat[0] ? MkInteger<"+", Nat[1]>
: Z["sign"] extends "+" ? MkInteger<"+", IncrN<Z["abs"]>>
: MkInteger<"-", DecrN<Z["abs"]>>;
export type Decr<Z extends IntegerKind> =
Z["abs"] extends Nat[0] ? MkInteger<"-", Nat[1]>
: Z["sign"] extends "+" ? MkInteger<"+", DecrN<Z["abs"]>>
: MkInteger<"-", IncrN<Z["abs"]>>;
次に足し算と引き算を定義するために, 先に自然数同士の引き算の値域を整数に拡張したものを定義します.
import { Sub as SubN } from "./natural";
type SubNZ<N1 extends NaturalKind, N2 extends NaturalKind> =
SubN<N1, N2> extends never
? MkInteger<"-", SubN<N2, N1>>
: MkInteger<"+", SubN<N1, N2>>;
これを使って整数同士の足し算と引き算を以下のように定義します. 符号の分岐でやや煩雑になっていますが, 大したことはしていません.
import { Add as AddN } from "./natural";
export type Add<Z1 extends IntegerKind, Z2 extends IntegerKind> =
Z1["sign"] extends "+"
? (
Z2["sign"] extends "+"
? MkInteger<"+", AddN<Z1["abs"], Z2["abs"]>>
: SubNZ<Z1["abs"], Z2["abs"]>
)
: (
Z2["sign"] extends "+"
? SubNZ<Z2["abs"], Z1["abs"]>
: MkInteger<"-", AddN<Z1["abs"], Z2["abs"]>>
);
export type Sub<Z1 extends IntegerKind, Z2 extends IntegerKind> =
Z1["sign"] extends "+"
? (
Z2["sign"] extends "+"
? SubNZ<Z1["abs"], Z2["abs"]>
: MkInteger<"+", AddN<Z1["abs"], Z2["abs"]>>
)
: (
Z2["sign"] extends "+"
? MkInteger<"-", AddN<Z1["abs"], Z2["abs"]>>
: SubNZ<Z2["abs"], Z1["abs"]>
);
型レベル次元
ここまでで型レベルで次元を表現するための準備が整ったので, 実際に定義をしていきます.
まずは先程も挙げたように, 基本的な次元として M, L, T の 3 つを定義します.
export const bases = ["M", "L", "T"] as const;
export type Base = typeof bases[number];
続いてこれらを組み合わせた次元を, 値と同様に, 基本的な次元ごとに整数の冪指数を持つ辞書型として定義します. 平方根なども考えると冪指数が有理数となることも考えられますが, ここでは簡単のため整数に限ることとします. (型レベルで有理数の計算を行うことは一応可能ではあるはずですが...)
import { IntegerKind } from "./integer";
export type DimensionKind = { [B in Base]: IntegerKind };
type AsDimension<D extends DimensionKind> = D;
ユーティリティと, 基本的な次元に対応するものも定義しておきます.
import { Int } from "./integer";
export type MkDimension<D extends Partial<DimensionKind>> = AsDimension<{
[B in Base]: B extends keyof D ? NonEmpty<ElimUndefined<D[B]>, Int[0]> : Int[0]
}>;
type ElimUndefined<X> = X extends undefined ? never : X
type NonEmpty<X, T> = [X] extends [never] ? T : X;
export type One = MkDimension<{}>;
export type Mass = MkDimension<{ M: Int[1] }>;
export type Length = MkDimension<{ L: Int[1] }>;
export type Time = MkDimension<{ T: Int[1] }>;
次元同士の掛け算と割り算は, 単に整数の足し算と引き算を行うだけなので簡単です.
import { Add as AddZ, Sub as SubZ } from "./integer";
export type Mul<D1 extends DimensionKind, D2 extends DimensionKind> = AsDimension<{
[B in Base]: AddZ<D1[B], D2[B]>
}>;
export type Div<D1 extends DimensionKind, D2 extends DimensionKind> = AsDimension<{
[B in Base]: SubZ<D1[B], D2[B]>
}>;
ということで, ここまでで型レベルでの次元の表現が完成しました.
型レベル単位系
続いて単位系についても型レベルで表現していきます. とはいえ次元とは違い, 単位系については型の上で異なる単位系同士を区別できることしか要求されないため, 以下のように単なる文字列の部分型として定義します.
export type UnitSystemKind = string;
type AsUnitSystem<S extends UnitSystemKind> = S;
export type MkUnitSystem<S extends string> = AsUnitSystem<S>;
型レベル物理量
最後に型レベルでの物理量の表現, というよりは, 物理量に対して付与するラベルを考えます. これは事前条件にかかわる次元と単位系の 2 つがあれば十分なので, それらのペアとして定義します.
import { DimensionKind } from "./dimension";
import { UnitSystemKind } from "./unitSystem";
export type QuantityKind = {
dimension: DimensionKind,
unitSystem: UnitSystemKind,
};
type AsQuantity<Q extends QuantityKind> = Q;
export type MkQuantity<D extends DimensionKind, S extends UnitSystemKind> = AsQuantity<{
dimension: D,
unitSystem: S,
}>;
これで型レベルでの準備が完了しました.
型のラベルを付ける
次に上で定義した型レベルでの表現を使って, 値に対してラベル付けをすることを考えていきます.
値に対して型でラベルを付けるためには幽霊型, つまりクラスや型エイリアスの定義において使用されない型引数を用いるのが一般的な方法ですが, TypeScript においては使用されない型引数は完全に消えてしまう (クラスは構造的に扱われてしまい, また不透明な型エイリアスもない) ため, 全く同じ手法を用いることはできません.
というわけで「幽霊型のようなもの」を実現するために, 以下のようなコンテナ型を定義します.
import { DimensionKind } from "./dimension";
import { UnitSystemKind } from "./unitSystem";
import { QuantityKind } from "./quantity";
import { DimensionRepr, UnitSystemRepr, QuantityRepr } from "./repr";
type Type<T> = (x: T) => T;
export type Dimension<D extends DimensionKind> = Readonly<{
__type__: Type<D>;
repr: DimensionRepr;
}>;
export type UnitSystem<S extends UnitSystemKind> = Readonly<{
__type__: Type<S>;
repr: UnitSystemRepr;
}>;
export type Quantity<Q extends QuantityKind> = Readonly<{
__type__: Type<Q>;
repr: QuantityRepr;
}>;
ここで型引数の D
, S
, Q
は幽霊型として扱いたかったものですが, これらを右辺で使用しなくてはならないという制約があるため, __type__
というプロパティを用意しています.
ここで __type__
の型として使っている Type<T> = (x: T) => T
は何でも良いというわけではなく,
- 値が存在する
- 部分型付けにおいて,
Type<T>
が T
に関して不変である
ということが重要です. もし値が存在しなければ, これらのコンテナ型の値を用意することもできませんし, 部分型付けにおいて不変でなければ, 型引数を曖昧にするようなキャストを許してしまうことになります. 実際に, 任意の型 T
に対して, (x: T) => T
型の値としては必ず恒等関数が存在しますし, 部分型付けにおいて T
に関して不変でもあります.
といったところで, これらのコンテナを作成するためのコンストラクタを用意します. ただし, 型引数が戻り値にしか現れていないことから明らかなように, これらのコンストラクタを使ったラベル付けについての正当性は TypeScript は保証してくれないことには注意が必要です.
const id = <T>(x: T): T => x;
export function dimension<D extends DimensionKind>(repr: DimensionRepr): Dimension<D> {
return { __type__: id, repr };
}
export function unitSystem<S extends UnitSystemKind>(repr: UnitSystemRepr): UnitSystem<S> {
return { __type__: id, repr };
}
export function quantity<Q extends QuantityKind>(repr: QuantityRepr): Quantity<Q> {
return { __type__: id, repr };
}
物理量 Quantity
については特によく使うため, ショートハンドを用意しておきます.
import { MkQuantity } from "./quantity";
export type Qty<D extends DimensionKind, S extends UnitSystemKind> = Quantity<MkQuantity<D, S>>;
export function qty<D extends DimensionKind, S extends UnitSystemKind>(
value: number,
dimension: Dimension<D>,
unitSystem: UnitSystem<S>
): Qty<D, S> {
return quantity({
value,
dimension: dimension.repr,
unitSystem: unitSystem.repr,
});
}
コンテナの準備ができたところで, ラベル付けされた値や関数を順番に定義していきましょう.
まずは次元について, 以下のように定数を定義しておきます. 再度の注意になりますが, ここでのラベル付けに対する正当性については TypeScript は保証してくれませんので, ラベルと値が一致しているかは人間が判断する必要があります.
import { One as OneD, Mass as MassD, Length as LengthD, Time as TimeD } from "./dimension";
export const oneD = dimension<OneD>({ M: 0, L: 0, T: 0 });
export const massD = dimension<MassD>({ M: 1, L: 0, T: 0 });
export const lengthD = dimension<LengthD>({ M: 0, L: 1, T: 0 });
export const timeD = dimension<TimeD>({ M: 0, L: 0, T: 1 });
続いて次元同士の掛け算と割り算は以下のように定義できます. 戻り値の次元が引数の次元の積や商になっているところがポイントです.
import { Mul as MulD, Div as DivD } from "./dimension";
import { mulD as mulDRepr, divD as divDRepr } from "./repr";
export function mulD<D1 extends DimensionKind, D2 extends DimensionKind>(
d1: Dimension<D1>,
d2: Dimension<D2>
): Dimension<MulD<D1, D2>> {
return dimension(mulDRepr(d1.repr, d2.repr));
}
export function divD<D1 extends DimensionKind, D2 extends DimensionKind>(
d1: Dimension<D1>,
d2: Dimension<D2>
): Dimension<DivD<D1, D2>> {
return dimension(divDRepr(d1.repr, d2.repr));
}
続けて単位系についても同様に定義します. ここでは例として, MKS 単位系を標準としつつ, 別の単位系として CGS 単位系を用意しています.
import { MkUnitSystem } from "./unitSystem";
export type MKS = MkUnitSystem<"MKS">;
export type CGS = MkUnitSystem<"CGS">;
export const mks = unitSystem<MKS>({
M: { symbol: "kg", factor: 1 },
L: { symbol: "m", factor: 1 },
T: { symbol: "s", factor: 1 },
});
export const cgs = unitSystem<CGS>({
M: { symbol: "g", factor: 1e-3 },
L: { symbol: "cm", factor: 1e-2 },
T: { symbol: "s", factor: 1 },
});
最後に物理量です. まずは便利のために, 先程用意した基本的な次元の物理量を作成するためのコンストラクタを用意しておきます.
export function num<S extends UnitSystemKind>(
value: number,
unitSystem: UnitSystem<S>
): Qty<OneD, S> {
return qty(value, oneD, unitSystem);
}
export function mass<S extends UnitSystemKind>(
value: number,
unitSystem: UnitSystem<S>
): Qty<MassD, S> {
return qty(value, massD, unitSystem);
}
export function length<S extends UnitSystemKind>(
value: number,
unitSystem: UnitSystem<S>
): Qty<LengthD, S> {
return qty(value, lengthD, unitSystem);
}
export function time<S extends UnitSystemKind>(
value: number,
unitSystem: UnitSystem<S>
): Qty<TimeD, S> {
return qty(value, timeD, unitSystem);
}
続いて足し算と引き算を定義します. これらは引数の次元と単位系の両方が一致していることが必要だったので, それを反映した型の宣言を行います.
import { add as addRepr, sub as subRepr } from "./repr";
export function add<D extends DimensionKind, S extends UnitSystemKind>(
q1: Qty<D, S>,
q2: Qty<D, S>
): Qty<D, S> {
return quantity(addRepr(q1.repr, q2.repr));
}
export function sub<D extends DimensionKind, S extends UnitSystemKind>(
q1: Qty<D, S>,
q2: Qty<D, S>
): Qty<D, S> {
return quantity(subRepr(q1.repr, q2.repr));
}
これで次元と単位系が一致した引数しか受け付けず, また戻り値の次元と単位系は引数のものと同じである, ということが表現できました.
続いて掛け算と割り算です. こちらは引数の単位系が一致していればよく, また戻り値の次元は引数の次元の積あるいは商となるように宣言します.
import { mul as mulRepr, div as divRepr } from "./repr";
export function mul<D1 extends DimensionKind, D2 extends DimensionKind, S extends UnitSystemKind>(
q1: Qty<D1, S>,
q2: Qty<D2, S>
): Qty<MulD<D1, D2>, S> {
return quantity(mulRepr(q1.repr, q2.repr));
}
export function div<D1 extends DimensionKind, D2 extends DimensionKind, S extends UnitSystemKind>(
q1: Qty<D1, S>,
q2: Qty<D2, S>
): Qty<DivD<D1, D2>, S> {
return quantity(divRepr(q1.repr, q2.repr));
}
最後に単位系の変更ですが, これについてもここまでと同様に, 戻り値は変換先の単位系であると宣言します.
import { conv as convRepr } from "./repr";
export function conv<D extends DimensionKind, S1 extends UnitSystemKind, S2 extends UnitSystemKind>(
q: Qty<D, S1>,
toUnitSystem: UnitSystem<S2>
): Qty<D, S2> {
return quantity(convRepr(q.repr, toUnitSystem.repr));
}
ここまでで一通り必要な定義は完成したので, 最初に挙げた例を再度見てみましょう.
まずは計算対象の物理量をいくつか定義します.
const m1 = mass(1, mks);
const m2 = mass(2, mks);
const m3 = mass(3, cgs);
const l1 = length(1, mks);
const l2 = length(2, mks);
const l3 = length(3, cgs);
足し算は次元と単位系が共に一致しているときのみ型検査を通過します.
add(m1, m2);
add(m1, l1);
add(m1, m3);
掛け算は単位系が一致しているときのみ型検査を通過し, また戻り値の次元は引数の次元の積になっています.
mul(m1, l1);
add(mul(m1, l1), mul(m2, l2));
mul(m1, l3);
単位系を変更することで, 別の単位系の物理量との間で計算を行うことが可能になります.
const m4 = conv(m1, cgs);
add(m3, m4);
まとめ
といったように, 次元と単位系を型レベルで表現し, それらを使って値に対してラベルを付けることで, 安全な物理量の扱いができるようになりました.
TypeScript の型で自然数の計算, などと聞くと単に遊んでいるだけのように思われるかもしれませんが (これは正解で, 遊んでいます), こういった型遊びには便利な応用も存在するということを知っていただけたら幸いです.