👻

newtype-tsのiso<T>().wrapで作ったオブジェクトの実体

2023/11/15に公開

newtype-tsという、TypeScriptでNominal Typingっぽいことが実現できるライブラリがある。
https://github.com/gcanti/newtype-ts/blob/master/README.md#L37-L55

このwrapされたオブジェクトは型の上では _A, _URI というプロパティしか持っていない。
https://github.com/gcanti/newtype-ts/blob/04b8f4ed9b47dcff2c485693678105821f156e52/src/index.ts#L15-L21

しかし、ランタイム上ではもともとのwrapする前の型として振る舞う。

import { Newtype, iso } from 'newtype-ts';

interface EUR extends Newtype<{ readonly EUR: unique symbol }, number> {}

// isoEUR: Iso<EUR, number>
const isoEUR = iso<EUR>();

// myamount: EUR
const myamount = isoEUR.wrap(0.85);

console.log(typeof myamount); // number
console.log(myamount);        // 0.85

逆に _A, _URIは型の上では存在していても、実際には存在しないので注意が必要。

このようにiso<T>().wrap関数では型と実体をずらすことで、型解析上は別の型として扱える、ランタイム上ではプリミティブな値として振る舞うオブジェクトを作ることができている。
念のため実際にどういう実装になっているか見てみる。

isoの実装の時点で、Iso<any,any>が返る。
https://github.com/gcanti/newtype-ts/blob/04b8f4ed9b47dcff2c485693678105821f156e52/src/index.ts#L77-L86

Isoは同じ作者の別のライブラリである monocle-tsのInterfaceで、Iso<S,A>はS→AのgetとA→SのreverseGetを持つことになっている。
https://github.com/gcanti/monocle-ts/blob/213e9b192a3572a1baa7243adb4455bb7a0439e5/src/Iso.ts#L39-L56
コンストラクタにgetとreverseGetの実装を渡して作れる。

とはいえ今回はIso<any,any>になっているので、newtype-tsの実装を見てみると
https://github.com/gcanti/newtype-ts/blob/04b8f4ed9b47dcff2c485693678105821f156e52/src/index.ts#L79
unsafeCoerceという関数を渡されている。
この関数はまた同じ作者の別のライブラリであるfp-tsの関数。
https://github.com/gcanti/fp-ts/blob/05b78c4083d04bfb420dec32ac493af49abbfc14/src/function.ts#L147-L150
identityは同じfp-tsの関数で引数をそのまま返す関数。
https://github.com/gcanti/fp-ts/blob/05b78c4083d04bfb420dec32ac493af49abbfc14/src/function.ts#L140-L145

ということなので、unsafeCoerceは同じものを返すだけなのに型上は変換していることにできる関数ということになる。
そこから作られたnewtype-tsのIsoも実際の値には何も影響を与えず型だけを変換するget, reverseGetをもつことになる。

なので iso<EUR>.wrapを使うと、ランタイム上ではnumberだけどタイプチェックの段階ではEURというオブジェクトを作ることができる。

(余談)
ところでこのwrapされた後のオブジェクトは、ランタイム上では元のプリミティブな型であるのにもかかわらず型上では_A, _URIを持つオブジェクトなので、逆にプリミティブな型として扱えないという欠点がある。
たとえばNumber.prototype.toExponentialを使おうとすると型エラーになるが、無視して実行する正常に動く。

❯ yarn ts-node --transpile-only sample.ts
yarn run v1.22.19
$ /Users/hoge/node_modules/.bin/ts-node --transpile-only sample.ts
8.5000000000e-1
✨  Done in 0.72s.

このため、元々の型として(トランスパイルエラーを出さずに)扱いたい場合は一旦reverseGetで元の型に戻してから操作する必要がある。

const myamount = isoEUR.wrap(0.85);
const numberamount = isoEUR.unwrap(myamount)
console.log(numberamount.toExponential(10));

これを欠点として重く捉える場合は、別のNominal Typingの実現方法を試してみた方が良さそう。
これとか。
https://typescript-jp.gitbook.io/deep-dive/main-1/nominaltyping#intfsuno
これとバリューオブジェクトパターンの組み合わせとか。
https://typescriptbook.jp/reference/object-oriented/class/class-nominality

Discussion