newtype-tsのiso<T>().wrapで作ったオブジェクトの実体
newtype-tsという、TypeScriptでNominal Typingっぽいことが実現できるライブラリがある。
このwrapされたオブジェクトは型の上では _A, _URI というプロパティしか持っていない。
しかし、ランタイム上ではもともとの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>が返る。
Isoは同じ作者の別のライブラリである monocle-ts
のInterfaceで、Iso<S,A>
はS→AのgetとA→SのreverseGetを持つことになっている。
コンストラクタにgetとreverseGetの実装を渡して作れる。
とはいえ今回はIso<any,any>になっているので、newtype-tsの実装を見てみると
この関数はまた同じ作者の別のライブラリであるfp-ts
の関数。
identityは同じfp-tsの関数で引数をそのまま返す関数。
ということなので、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の実現方法を試してみた方が良さそう。
これとか。
これとバリューオブジェクトパターンの組み合わせとか。
Discussion