TypeScriptで関数型指向で抽象を扱うにはどうすれば良いのか @Kyoto.js 22
自己紹介
mrsekut.icon
mrsekut (まる)
tryangle株式会社
crocchaというハンドメイド向けのSNS
開発の全般をやっています
好きな言語はHaskellです
コメント、感想、指摘、補足など、(いつでも)このページに直接書き込んでもらえると嬉しいですmrsekut.icon
最近、批判・フィードバックに飢えているので、気軽にお願いします
~1:00
コーディング時の考えることを減らしたい
今書いているプロダクト
Next.js実装のto B向けの販売管理システム
約7万行
2024/6現在は主に一人で書いている
他2名に手伝ってもらっている時期もあり
どんどん抽象して、考えることを減らさないと暴発する
~2:00
TypeScriptで、どのように抽象を扱うかを考えている
今回は、その考えの経過と整理を共有します
(& 悩みの共有、という感じです...mrsekut.icon)
結論が出ているわけではないので、どんな意見でも頂けるとありがたいです
まとめ
業務で使っているTypeScriptでの抽象の扱いを模索していた
データ構造に着目してみた
→汎用的なデータ構造に逃してみたがあまり上手く行かず
操作に着目してみた
→データ構造の内部を知っているかどうかで分類できそう
「内部を知らない操作」が大事そう
→TypeScriptでの実現方法が不明 ←イマココmrsekut.icon
なにか知見があれば教えてください!
~2:30
データ構造に着目する
いわゆる、Making illegal states unrepresentable
そもそも間違った状態にならないように厳密に型の設計をする
例えば、決済方法を定義する
「銀行振込」「クレジットカード」の2種類がある
それぞれ必要となる属性は異なっている
https://gyazo.com/b4585ee12fa69af305538e714a4ecce9
こうしない
code:ts
type PaymentMethod = {
type: 'bankTransfer' | 'creditCard';
accountNumber?: AccountNumber;
bankName?: BankName;
cardNumber?: CardNumber;
cardHolder?: CardHolder;
}
nullableなものが多い
typeで分岐した後も、各属性の存在を確認する必要がある
こうする
code:ts
type PaymentMethod = BankTransferPayment | CreditCardPayment;
type BankTransferPayment = {
type: 'bankTransfer';
accountNumber: AccountNumber;
bankName: BankName;
};
type CreditCardPayment = {
type: 'creditCard';
cardNumber: CardNumber;
cardHolder: CardHolder;
};
型を見るだけで仕様が伝わる
「型エラーがなければ値としては正しい」ことが保証される
~4:45
一方で、どのようにして抽象を表すのだろう?
上記の書き方は、安全に寄与するが、具体的すぎる
例えば、A、B、Cのパターンを取る時に、
T = A | B | Cと定義するのは容易だが、
Tを扱う時に、毎回3つの条件分岐を書いているのは抽象化と言えるのか?
~5:45
1つ目のアプローチ: 汎用的なデータ構造に切り出す
「データ構造」を2つに分けて考えてみる
具体的なデータ構造
特定のドメインに特化しないようなデータ構造
例えば、
type Either<L, R> = Left<L> | Right<R>;
type Maybe<T> = Just<T> | Nothing;
type Array<T> = ...
Reducerに渡すEvent型
JotaiのAtomの型
etc.
Haskell視点でみるとMonadのinstanceになるような型?mrsekut.icon
具体的なデータ構造を最小にまで減らす
しかし実際は、あまりうまくいかないmrsekut.icon
Entityの構造に、そういった構造が現れることが少ない
無理やりやると厳密な型定義にできない
よくよく見てみると、汎用的なデータ構造にはフローのための構造が多いことに気づくmrsekut.icon
ドメインを表す構造ではなく、そういった構造を包むコンテナの意味合いが大きい
例えば、Either, Maybeはエラーを制御するためのデータ構造
→このアプローチは、求めているものと違いそう
~8:00
2つ目のアプローチ: 操作に着目する
データ構造ではなく、「操作」で抽象化する
外部から見れば、
内部のデータ構造はどうだって良い
決済方法を扱う時に、「銀行振込とクレカで別の属性を持っている」という知識は不要
共通の操作で扱える
支払う (pay)という操作は、「銀行振込」「クレジットカード」で共通している
そして、操作をインターフェースとして外部に公開する
https://gyazo.com/9e382ac6e546644eff6f59363421b3ab
~9:15
(余談) 操作だけを公開したいが、たぶんできなそう
データ構造は隠蔽したい
しかし、TypeScriptでは隠蔽する方法がない
黙ってclassを使え、というのはある
こんな感じのネストした構造があった時、
code:ts
type C = { b: B };
type B = { a: A };
type A = { value: number };
Cに依存しているものは、c.b.a.valueとすれば内部が見えてしまう
aやvalueを隠蔽することができない
全ての子の構造を知ってるのと同じ
インターフェースが広すぎる、カプセル化が全くできない
_で始めるなど運用でカバーするしかない
code:ts
const c: C = { b: { _a: { _value: 1 } } };
~10:15
操作を2種類に分けられそう
以下の2つに分けられる
データ構造の内部を知っている操作
データ構造の内部を知らない操作
データ構造の内部を知っている操作の例
code:ts
const pay = (method: PaymentMethod, info: PaymentInfo) => {
switch (method.type) {
case 'creditCard':
return ...;
case 'bankTransfer':
return ...;
}
}
関数の内部で、'creditCard'と'bankTransfer'がハードコードされている
この関数は、
「PaymentMethodが、CreditCardPaymentとBankTransferPaymentから成る」
ということを知っている
故に、関数とデータ構造は密に結合している
(先程のPaymentMethodの再掲)
code:ts
type PaymentMethod = CreditCardPayment | BankTransferPayment;
type CreditCardPayment = {
type: 'creditCard';
cardNumber: CardNumber;
cardHolder: CardHolder;
};
type BankTransferPayment = {
type: 'bankTransfer';
accountNumber: AccountNumber;
bankName: BankName;
};
~11:30
データ構造の内部を知らない操作の例
恐らく、TypeScriptではclassを使わないと実現できない...?mrsekut.icon
なにか実現できそうな方法があれば知りたい
classを使うと以下のように書ける
code:ts
interface IPaymentMethod { // 命名適当です
pay(info: PaymentInfo): string;
}
const pay_ = (method: IPaymentMethod, info: PaymentInfo) => {
return method.pay(info);
}
関数pay_は
PaymentMethodIにのみ依存しており、
PaymentMethodの内部構造は知らない
これすでにclassなしで書けているように見えますtakker.icon
何か自分が見落としてる?
(コメントしてもらってたのを見落としてましたmrsekut.icon)
このコードの断片だけを見ると書けているように見えますmrsekut.icon
が、残りの部分も書こうとすると、classがないと「内部を知らない操作」を書けないはずです
ただ、classを使えば、IPaymentMethodを実装したclassを支払方法ごとに定義することで実現できます
code:ts
const creditCardPayment = new CreditCardPayment(...);
const bankTransferPayment = new BankTransferPayment(...);
pay_(creditCardPayment, paymentInfo)
pay_(bankTransferPayment, paymentInfo))
上記のように書けば、以下のことを実現できます
pay_自信は2つのclassの内部構造を知らない
2つのclasss毎に異なるpay操作ができる
これで伝わるか謎ですが
他の言語だと、例えばHaskellでは型クラスを使って以下のように書ける
code:hs
class IPaymentMethod p where
pay :: p -> PaymentInfo -> String
pay' :: (IPaymentMethod p) => p -> PaymentInfo -> String
pay' method info = pay method info
関数pay'は
PaymentMethodI型クラスを実装している型だけを要請しており
実際にpがどういうデータ構造なのかは知らない
良さ
データ構造と操作の結合が疎になる
~13:00
2種類の操作の使い分け
データ構造の内部を知っている操作
データ構造と操作が同じmoduleに属するならあり
密に結合するが、許容できる
データ構造が変わると、操作も修正が必要
だが、外部module視点では、操作がインターフェースになるので影響なし
データ構造の内部を知らない操作
操作が、複数moduleに跨るものなら、こちらのほうが良さそう
データ構造と操作の結合を疎にできる
操作は、複数moduleを跨ぐ汎用的な機能、と見なすことができる
あるあるだと、CRUDのsaveとかそういうやつ
まとめ
業務で使っているTypeScriptでの抽象の扱いを模索していた
データ構造に着目してみた
→汎用的なデータ構造に逃してみたがあまり上手く行かず
操作に着目してみた
→データ構造の内部を知っているかどうかで分類できそう
「内部を知らない操作」が大事そう
→TypeScriptでの実現方法が不明 ←イマココmrsekut.icon
なにか知見があれば教えてください!
コメント
siotouto.iconpayが必須なら中に入れ込んでしまうという場合について考えられるのかなぁとザックリ。ただ、欲しい条件は多分満たしてないんだろうとも思いつつ・・・
code:ts
type PaymentMethodTypeInfo = CreditCardTypeInfo | BankTransferTypeInfo;
type CreditCardInfo = ......
type BankTransferInfo = ......
type CreditCardTypeInfo = {
type: 'creditCard';
info: CreditCardInfo;
};
type BankTransferTypeInfo = {
type: 'bankTransfer';
info: BankTransferIno;
};
type PaymentMethod = PaymentMethodTypeInfo & {
pay(info: PaymentInfo): string;
};
const payByCreditCard =
(cardInfo: CreditcardInfo)
=> (info: PaymentInfo): string => { ...... }
みたいな感じでpayまで入れてしまうのを考えていました
空(ソラ)で書いてるので全然記法とかおかしいかも
ありがとうございます!!!!mrsekut.icon*2
コードを補ってみました。こんな感じですかね
code:ts
// 使う
const main = () => {
const info: PaymentInfo = null; // TODO
const creditCard = instantiate({
type: 'creditCard',
info: {
cardNumber: 1234,
cardHolder: 'Taro',
},
});
// payが生えている!
const result = creditCard.pay(info);
};
code:ts
// ↓↓ PaymentMethod module ↓↓
type PaymentMethod = PaymentMethodTypeInfo & {
pay(info: PaymentInfo): string;
};
// instance化するutility
const instantiate = (info: PaymentMethodTypeInfo): PaymentMethod => {
switch (info.type) {
case 'creditCard':
return {
...info,
pay: payByCreditCard(info.info),
};
case 'bankTransfer':
return null as unknown as PaymentMethod; // TODO
}
};
type PaymentMethodTypeInfo = CreditCardTypeInfo | BankTransferTypeInfo;
type CreditCardTypeInfo = { type: 'creditCard'; info: CreditCardInfo };
type BankTransferTypeInfo = { type: 'bankTransfer'; info: BankTransferInfo };
type CreditCardInfo = { cardNumber: number; cardHolder: string };
type BankTransferInfo = { accountNumber: number; bankName: string };
const payByCreditCard = (cardInfo: CreditCardInfo) => (info: PaymentInfo) => {
return null as unknown as string; // TODO
};
type PaymentInfo = unknown; // TODO
ポイント
CreditCardTypeInfoとCreditCardInfoを分離している
payByCreditCard()は、CreditCardInfoにのみ依存する
そのため、'creditCard'というハードコードは現れない
従って、クレカの構造とはやや結合が疎になっている
実行時に、クレカと確定する段階でinstantiateしてpayを生やす
classを使わないOOPみたいな雰囲気
ただし、immutableではあるのでより良い
instanceを持ち回ると関数も持ち回るの嫌じゃない?と思ったけど、
どのみち密に結合している関数なので実際に問題は生じなさそう?
mrsekut.iconも元々何を所望していたのかわからなくなってきている
内部のデータ構造を知る/知らない操作に気づいてから、考え方が型クラスに引っ張られている
所望していた要件というのを改めて整理してみよう
そもそもの出発地点は、TypeScriptでのmoduleの設計、という部分だった
それを具体化して、データ構造と操作に着目して今回の発表をまとめた、という流れがある
siotouto.icon 最近ジェネリクスを触っていて、ここ書き逃げしたのを思い出していた
やりたい事は多分、言語によらず、複数の型から1つの型への関数について、どう合流させるかなのではないかと思っている
f:(a: TypeA) => TypeX, g:(b: TypeB) => TypeX, h:(c: TypeC) => TypeX の3つを定義することはそれぞれの言語で簡単に出来る。
問題は上記3つの違う型の関数を、同名で定義出来る(オーバーロード出来る)か、というところが本質な気がする?
そしてjavascriptにはないためtypescriptにも持ち込まれていない
オーバーロードと呼ばれているものはあるが、そこで今回のようにcaseで分岐させる必要がありそう。
たしかに、そこですね。オーバーロードの有無だmrsekut.icon
関数の内部で、'creditCard'と'bankTransfer'がハードコードされている
siotouto.icon typescriptがjavascriptで動作する以上は、「誰か」が動作時に必要な型を弁別する仕掛けを持つ必要がある
もし関数をmapするobjectがあったとしても、そのobject自体がPaymentの構造を知ること自体は避けられない(ちなみにこれの型を付けるのがめちゃくちゃ難しくてsiotouto.iconは出来てない)
object自体は [P in Payment["type"]] を中心にExtractなど上手くすれば特に不要なキャストなどもなく型が付くが、合流させる関数の内部で型のキャストが必要そう
それなら例で挙げられている関数をカリー化するだけでも同等程度にしか内部の構造を知らないと思う
code:ts
const pay = (method: PaymentMethod): (info: PaymentInfo) => ResultType => {
switch (method.type) {
case 'creditCard': return creditCardPay(method); // may be imported
case 'bankTransfer': return bankTransfer(method); // may be imported
}
}
今書いたのでカリー化した場合の型注釈の書き方おかしいかも
methodの型さえ書き間違えなければ、案外
ちゃんと常にreturnされるかのチェックがあるのでexhaustiveチェックになっているし
余分なcaseは追加出来ないし、
methodがそれぞれの型に判別されるので、各関数名が間違っていても分かる
↑ので変にバグりにくくて嬉しそう
が、それぞれに(method)ってcall書く必要があるのはちょっとダサいのでobjectでやる方でキレイに出来るならちょっとその方法が知りたいなぁという感じsiotouto.icon
よく見たらすでに書かれていた・・・siotouto.icon
感想
初めてHelpfeel社にお邪魔した
https://gyazo.com/c5005b91d59f9911fcadee284a5b1c73 https://gyazo.com/4e8a864d80c20aaa29964b3fc4c6b51e
ビーバークッションだ!takker.icon
かわいかったmrsekut.icon
意外とめっちゃ狭い
社員は100人越えているはずなのに、デスクが10人分ぐらいしかないの良い(?)
みんなリモートならオフィスなんて狭い方が良い
あと小さい防音室みたいなのもあるのも良かった
帰りは22時にオフィスを出た
駅まで近すぎ
信号ひっかからなかったら徒歩1分もない
帰宅したら23:45ぐらいだった
Helpfeelさんにお酒とピザも用意してもらっていた
本当に感謝、ありがとうございますmrsekut.icon
買い出し行ったり、事前に注文したり地味に大変だもんなあ、ありがたい..
https://gyazo.com/a1fcfcec2a707d6e47458ec9a8712bd0
発表した
今回意識してたのは、
時間に余裕を持ったスライドを作ること
話すときは、観客の顔を見るようにすること
最初の方は30秒ぐらい余裕あったはずなのに、最終的には30秒ぐらいオーバーしていた
まあでもこれぐらいなら全然調整できる範囲だからまあ良いか
以前よりは観客の顔を見ながら話せた
頷いてくれたり、「?」な表情をしているのが見える
伝わってなさそうだと補強した説明をすることになる
補強した説明をする時間の余裕が必要
もうちょいウケを狙っても良さそう
他の人の発表を見てて思った
他の人の発表はわかりやすいウケポイントがいくつもあった
今回のウケは伏せ字にしてるところぐらいだったmrsekut.icon
もうちょいウケポイントを増やしてもいい
Helpfeel社での発表は物理的に観客との距離が近いのも良い
関西の小さめのイベントはウケを意識しても良さそう(?)
懇親会とか
mrsekut.iconのCosenseを見たことありますという人が2人ぐらい観測された
参考になっていますと言われて嬉しかった
発表の冒頭での、もうそろ2万ページ、というのがちゃんとウケててよかった
懇親会の時間が長いのは最高ですね
会場が狭いのもあって、声が混線して、話している人の声が聞き取りづらかったりした
業務の話をした
自分が主導するプロダクトだと意思決定が多くて大変ですよねという話をした
技術選定もそうだし、アーキテクチャの決定もそうだし、
過去の自分が時間がなくて妥協した部分が、後にしっかり自分を攻撃してくる
副業の話もした
副業するとやっぱり自分の時間が取れなくなることがネックらしい
まあそうだよなあ、悩ましい
SacalaとかHaskellの話もした
強い人が多い印象がある
通常のScalaとぜんぜん違う、という話は、そうなんだ、となった
業務での関数型言語の採用はやはりまだ厳しいそう、という印象は受けた 詳しい人が導入しても、その人が抜けるとメンテできなくなったり
普通のScalaぐらいだとできるけど、Scalazを入れたりすると沼るとか
某社はPureScriptを使っていたけど、人材採用で苦戦してやめたとか
業務でやるには良い塩梅というものを探さないといけなさそう
例えば、TSでneverthrow使うぐらい、のレベル感
関数型言語が評価されてきている流れはあるが、まだまだこんなものなのか、という気持ちにはなった