Phantom property pattern

TypeScriptは、JavaScriptエンジンの動的セマンティクス上に、静的な型システムのセマンティクスが重なっているものです。ここで、JavaScriptとしては正しく実行できても、TypeScriptの型システム上ではちゃんと型が付かないという場合もあります。ときには、TypeScriptで型がつくように大幅に書き換えないといけない場合も出てきたりして、そうなると大変です。それでも、TypeScriptの型システムは高度な機能を持っているので、大体の場合は、うまく表現してやると、JavaScriptらしい書き方のままで型をつけることができてしまいます。今回は、JavaScriptでよくある、プリミティブ値をそのまま取り回すパターンに型を付けたいと思います。

プリミティブ型を扱うプログラミングは、素朴でわかりやすいですが、型の考えからするとかなり悪いものです。こういうプログラミングを行うと、あらゆるデータを文字列で表す様子から、ときに”stringly typed”と揶揄されます。文字列は、表したいものはとりあえず何でも表現できるのでとても便利ですが、いろいろな種類のデータが一つの型で表されてしまうのなら、型が役立たずになってしまいます。すべてがstring型のプログラムで、どうやってその文字列の中身がテキストなのか、URLなのか、メールアドレスなのかを判断するのでしょうか? 文脈を読み込んで察するか、ハンガリアン記法に回帰するか、さもなくばエスパーで当てるしかないでしょう。

この問題に対処するひとつの「まっとうな」方法は、プリミティブ値を直接使わず、適宜ラッパーオブジェクトを作り、その上でプログラミングを行うことでしょう。プリミティブ値ではなくオブジェクトを使えば、メソッドを追加することも容易です。しかし、実際にJavaScriptでそういうことが行われづらい原因として、ひとつはプリミティブ値をコンストラクタでラップするが手間だという点、ひとつは===による同一性比較がオブジェクト型に対しては工夫を加えなければ意味上の同値性比較にならない点、ひとつは性能上の問題点、ひとつはシリアライズ・デシリアライズの自明さが失われる点が挙げられます。要するに、プリミティブ値をオブジェクトで包むのはわりとデメリットもあるわけです。

今ここに、この問題へのもうひとつの対処方法、幽霊プロパティパターン phantom property pattern を提唱します。このパターンを使うと、プリミティブ値の型を好きなだけ定義できる。つまり、言語組み込みのプリミティブ値を取り回すプログラムでありながら、各プリミティブ値を別々の型として取り扱うことができるようになります。

幽霊プロパティパターンでは、まず、次のような型を定義します。

export type Tag<X, R extends keyof any, Y> = X & {
    [rel in R]: Y
}

x: Tag<X, R, Y>は「xはXで、xのRはYである」というような意味になります。

次に、幽霊シンボルを定義します。

// This is a phantom symbol, which does not exist at run time
declare const _unit: unique symbol
export type unit = typeof _unit

_unitは幽霊シンボルで、宣言しますが、実際には定義しません。_unitの型はユニークシンボル型ですが、このままでは型に名前がなく使いづらいので、unitというエイリアス名を付けます。

ここに定義したTag型コンストラクタと幽霊シンボルを組み合わせると、プリミティブ値の性質を幽霊プロパティとして表現できます。

例として、角度の単位を幽霊プロパティとして付加した型を定義してみましょう。

export type Scale<X, U> = Tag<X, unit, U>
export type Radian = Scale<number, "radian">
export type Degree = Scale<number, "degree">

ここで定義したRadian, Degreeは両方ともプリミティブ値の数値を元とする型ですが、実際には存在していない[_unit]プロパティの型が異なるため、相互に代入不可能となっています。

{
    const x: Degree = 30 as Degree
    const y: Radian = x // type error!
    console.log(x, y)
}

いいですね。これを使って、たとえば三角関数双曲線関数に単位をつけてあげるといい感じになります。

export const sin = Math.sin as (x: Radian) => number
export const tanh = Math.tanh as (x: Radian) => number
export const asin = Math.asin as (x: number) => Radian
export const atanh = Math.atanh as (x: number) => Radian

{
    const x = 30 as Degree
    const y = sin(x) // type error!
    console.log(y)
}

弧度法で渡すべきところをうっかり度数法で渡すミスを、これで防げますね。 相互に変換する関数も用意してあげましょう。

// Ref: https://tauday.com/tau-manifesto
const τ = 2 * Math.PI

/** converts radian to degree */
export function toDegree(x: Radian): Degree {
    return (x / τ * 360) as Degree
}

/** converts degree to radian */
export function toRadian(x: Degree): Radian {
    return (x / 360 * τ) as Radian
}

{
    const x = 30 as Degree
    const y = sin(toRadian(x)) // ok
    console.log(y)
}

例をもう一つ、今度はUnix時間とISO8601を扱ってみます。 今度は単位ではなくデータフォーマットの話なので、新しくdataformat型を定義しています。

declare const _dataformat: unique symbol
export type dataformat = typeof _dataformat

export type UnixTime = Tag<Unit<number, "millisecond">, dataformat, "unixtime">
export type ISODateTime = Tag<string, dataformat, "iso8601">
export const now = Date.now as () => UnixTime
export const toISODateTime = (t: UnixTime): ISODateTime => new Date(t).toISOString() as ISODateTime
export const toUnixTime = Date.parse as (d: ISODateTime) => UnixTime
export function isISODateTime(s: string): s is ISODateTime {
    return /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,3})?Z$/.test(s)
}

{
    const t = 1514810096000 as UnixTime
    console.log(toISODateTime(t))
    const d = "2018-01-01T12:34:56Z"
    if (isISODateTime(d)) {
        console.log(toUnixTime(d))
    }
}

UnixTime型はデータフォーマットだけでなく、単位も幽霊プロパティで付加していますね。一口にUnix時間と言っても、実際にはミリ秒単位のものと秒単位のものが混ざっていることがよくあるのですが、こういう工夫が事故を防いでくれるはずです。

ISODateTime型の方は、型ガード関数を用意しました。こういう関数を用意して使うほうが、直接as ISODateTimeでダウンキャストするよりも安全です。

欠点

  • 本当ならinterface Tag<X, R, Y> extends X { [rel in R]: Y }とでも定義したいところですが、現状のTypeScriptではプリミティブ型を拡張できないため、やむなく交叉型 intersection type で表現しています。
  • 幽霊シンボルそれ自体はexportしないほうがいいでしょう。実在しない識別子なので、アクセスしても型エラーになりませんが実行時エラーになる可能性があります。型レベルで使うだけでシンボル自体はなくてもいいので、シンボルの型に名前を付けて、そちらをexportするといいです。
  • interfaceではないため、スニペットコンパイラのエラーメッセージ等で、型エイリアスが展開されて表示されてしまいます。unique symbolと表示されるだけなのは非常にまずいです。
    • f:id:mandel59:20180901034739p:plain
    • ワークアラウンドとして、幽霊シンボル自体にタグをつけるように書き換えると、なんとか分かるようになります。
      • f:id:mandel59:20180901035321p:plain
declare const _unit: unique symbol
export type unit = Tag<typeof _unit, "name", "unit">
export type Unit<X, U> = Tag<X, unit, U>

declare const _dataformat: unique symbol
export type dataformat = Tag<typeof _dataformat, "name", "dateformat">

まとめ

幽霊プロパティパターンを使うと、実行時コストに影響を与えることなく(ゼロコストで)、プリミティブ値にユーザー定義の型をつけることができるようになります。

2019/08/20追記: この記事を書いた時点では、先行事例があるということをちゃんと調査していませんでしたが、実は"Branding"と呼ばれて、それなりに使われている技法であることを知りました。(TypeScriptのソースコードでも使われています。

以下の記事では、Brandingや、その変形であるFlavoringについて解説されています。 Need Flexible Nominal Typing for TypeScript? Use Flavoring, not Branding