RustのResultをTypescriptでも導入したい!
導入
例外処理の話をするのには理由があります。
大学生になって、始めて触った言語はC++でした。今では考えられないほど使いにくかったMicrosoft Visual Studio2008と格闘しながら、書籍「ロベールのC++」のコードを写経していました。(驚くなかれ、ロベールのC++は1000ページあります。先にも後にもこれ以上長い本を読んだことはありません)
当時はコンパイルエラーを直すのに精一杯で、(コード補完が無かった気がする)実行時エラーを気にする余裕はありませんでした。エンジョイするはずだった大学生最初の夏休みを潰しました。
その後しばらくプログラミングからは離れてしまいましたが、修士1年生の時にインターンシップ先に選んだ会社でJavaと出会います。
C++のメモリリークに悩まされた経験から、メモリ管理が必要ないJavaはとても使いやすかったのを覚えています。すぐに簡単なゲームを作れるようになりました。
次第に、複雑なアプリケーションを作るようになると、実行時エラーをどう扱うかについて悩むようになりました。その場しのぎでエラーをthrowして、呼び出し元に処理をさせることを繰り返すと、次第にコードが手に負えなくなりました。今では実行時エラーと例外について当時より詳しくなり、マシなコードを書いていますが、設計の難しさは変わりません。
例外処理の方法
Try&catch構文はC系の言語でサポートされ、Javascriptにも取り入れられました。正常系とそれ以外(今後は異常系と呼びます)をコードブロックで分けることで、見た目にも分かりやすく、コードを書く人にとっては、正常系の(想定される)動作のプログラミングに集中できるメリットがあります。
デメリットは、エラーや例外処理が後回しになることです。誰でも必ず、キャッチした例外をそのまま投げているコードを保守した経験はあるでしょう。コードが大規模になれば、try&catch地獄が待っています!その頃には、どんな例外がどこから投げられているか分からない、クソコードになっているはずです。例外処理に関しては、怠惰さ=美徳とは言えません。
最近のプログラミング言語には、try&catch構文はあまり取り入れられてません。
Golangの例を見てみましょう。正常系と例外を同じブロックで扱えるために、Golangでは二値(多値)returnを取り入れています。次のコードはDBからユーザ情報を取得する例です。
type User struct{
Name string `json:"name"`
}
func FindUser(id string) (User,error){
var user User
//db setup
if err := db.QueryRaw(`SELECT * FROM users WHERE id = ?`,id).Scan(&user.Name);err!=nil{
return err
}
return user
}
func SignIn(id string){
user, err :=FindUser(id)
if err !=nil{
//error handling
}else{
//do something..
}
}
例外処理は、golangのシンプルさが際立っていますね。Try&catchよりも見やすいです。ただし例外処理が複数ある場合は、if文が乱立して処理が追いにくくなるでしょう。
Typescriptでも似たようなことができます。多値返却はサポートされていませんが、union型を使えばgolangと同じことができるでしょう。以下はそのままtypescriptに置き換えたものです。
type User = {name:string}
type UserResult = User|{error:Error}
function findUser(id:string): UserResult{
//db connection
const user = db.query(`SELECT * FROM users WHERE id = ${id}`)
if (!user){
return {error:new Error("no user")}
}else{
return {name:user.name}
}
}
function isUser(result:UserResult): result is User{
return !(`name` in result)
}
function signIn(id:string){
const result = findUser(id)
if(!isUser(result)){
//error handling
}else{
//
}
}
If文による例外処理は、Try&catchよりも分かりやすいでしょうか?エラー処理として優れているのはどちらでしょうか?
私が考える、If文のメリットとデメリットは以下の通りです。
メリット
- 正常系と異常系の距離が近くなり、何をやっているか(どのエラー処理をしているか)分かりやすい
- 異常系を先に処理してreturnすることで、正常系の処理に集中できる
デメリット
- 異常系の処理が1つのブロックで扱えない
処理が目で負いやすい半面、処理自体が複雑になる傾向にあります。確かに私が保守しているgoのコードは、基本if文の連続です。goらしいといえばそれまでなんですが、決して見やすいとは言えません。より改善できる案はないでしょうか?
Result型の例外処理とは?
Try&Catchによる従来の例外処理と、If文を使った例外処理よりも優れた方法はあるでしょうか?
今までの論点をまとめると、
- エラー処理をなるべくまとめて扱いつつ
- 正常系と異常系のエラーのコードブロックを分けない
- try-catchやif elseのようにネストになりにくい
の3点を満たしたエラー処理を一緒に考えていきましょう。ここでヒントになるのはfilter関数です。
[...].filter((item)=>..).map(/**handling */)
フィルター関数が優れている点は、正常系の処理が透過的に行えることです。
やっていることはif文と変わりませんが、ネストもないですし、同じコードブロックで処理できてます。
結果をフィルターしてるだけで例外処理はできていませんが、スタート地点には良いでしょう。
例外処理をするには、フィルター関数を拡張したfilter_or_elseを考えます。下のような感じです。
const [results,errors] = [...].filter_or_else(item=>).map(/**handling */)
if (errors.length !==0) {
//Error handling
}
正常系だけでなく異常系も返すため、例外処理が行えるようになりました。
ただしif文が残っているので、以下のように修正します。
const [results,errors] = [...].filter_or_else(item=>).mapOk(/**handling */).mapError(/**Error handling */)
結構良い感じのインターフェースになりました!
ただし入力値と返り値の型が違っていますね。これだとfilter関数のように.filter().filter()..と重ねて使えません。
改善しましょう。もう少しの辛抱です。
type Result<ERR,OK> = Err | OK
const results: Result<Error,Ok>[]
const results_1 = results.filter_result(result=>).mapOk(/** */).mapError(/**handling */)
const results_2 = results_1.filter_result(result=>).mapOk(/** */).mapError(/**handling */)
入力値と戻り値を同じ型にしました。これで、filter関数のように透過的に使えます。
後は、リスト以外にも使えるように修正するだけです。filter_resultという名前も、リストを前提としているので取り除いてしまいましょう。結果はこうなります。
class Result<ERR,OK>{
//instance are immutable
readonly value: {error:ERR} | {ok:OK}
//apply fn only when value is OK
mapOk<T>(fn:(value:OK)=>T):Result<ERR,T>{
if ('ok' in this.value){
return Result.Ok(fn(this.value.ok))
}else{
return Result.Err(this.value.error)
}
}
//apply fn only when value is ERR
mapError<F>(fn:(value:ERR)=>F):Result<F,OK>{
if (`ok` in this.value){
return Result.Ok(this.value.ok)
}else{
return Result.Err(fn(this.value.error))
}
}
//OK and ERR can be same type, so which argument defines type of value
//and should be private constructor
private constructor(value:OK|ERR,which:boolean){
if(which){
this.value = {ok:value as OK}
}else{
this.value = {error:value as ERR}
}
}
//use like Result.Ok<string,User>({name:"mori"})
static Ok<Err,OK>(value:OK){
return new Result<Err,OK>(value,true)
}
//use like Result.Err<string,User>('no user')
static Err<Err,OK>(value:Err){
return new Result<Err,OK>(value,false)
}
}
ちょっと長くなりましたが、各行にコメントを書いたので意味は分かると思います。
リスト以外にも扱えるように、ジェネリックを使っています。これを使って先ほどのログイン処理を書いてみましょう。
type User = {name:string}
findUser(id:string):Result<Error,User>{
const user = db.query(`SELECT * FROM users WHERE id = ${id}`)
if (!user) {
return Result.Err<Error,User>(new Error("no user"));
} else {
return Result.Ok<Error,User>({name:user.name})
}
}
function signIn(id:string){
const result = findUser(id)
result.mapError(err=>{
//error handling
}).mapOk(user=>{
//something like session update
})
}
先ほどのisUser関数が不要になり、findUserの返り値もシンプルに処理できました。Result型を受け取りResult型を返す関数を繋ぎ合わせることで、透過的にエラーを扱えるようにもなりました。
さらには、先ほど上げたエラー処理の3つの論点
- エラー処理をなるべくまとめて扱いつつ
- 正常系と異常系のエラーのコードブロックを分けない
- try-catchやif elseのようにネストになりにくい
もそれぞれ達成されています。すばらしいですね。
Result型はRustやhaskellなどの関数型言語の標準ライブラリで入っていますが、typescriptでは残念ながら入っていません。ミニマムなResult型でも十分パワフルですが、私のプロジェクトでは以下のようにResult型を定義しています。コードは長くなりますが、コピーアンドペーストで使えるので良かったら参考にしてみてください。
export type Result<T, F> = Success<T, F> | Failure<T, F>;
interface IResult<T, F> {
isSuccess(): this is Success<T, F>;
isFailure(): this is Failure<T, F>;
readonly value: T | F;
map<R, L>(fnL: (f: F) => L, fnR: (t: T) => R): Result<R, L>;
mapR<R>(r: (t: T) => R): Result<R, F>;
mapL<L>(l: (f: F) => L): Result<T, L>;
fmap<R, L>(fnL: (f: F) => Result<R, L>, fnR: (t: T) => Result<R, L>): Result<R, L>;
fmapR<R>(fnR: (t: T) => Result<R, F>): Result<R, F>;
fmapL<L>(fnL: (t: F) => Result<T, L>): Result<T, L>;
}
export class Success<T, F> implements IResult<T, F> {
constructor(value: T) {
this.value = value;
}
readonly value: T;
isSuccess() {
return true;
}
isFailure() {
return false;
}
fmap<R, L>(fnL: (f: F) => Result<R, L>, fnR: (t: T) => Result<R, L>): Result<R, L> {
return fnR(this.value);
}
fmapR<R>(fnR: (t: T) => Result<R, F>): Result<R, F> {
return fnR(this.value);
}
fmapL<L>(fnL: (t: F) => Result<T, L>): Result<T, L> {
return success<T, L>(this.value);
}
map<R, L>(fnL: (f: F) => L, fnR: (t: T) => R): Result<R, L> {
return success<R, L>(fnR(this.value));
}
mapR<R>(fnR: (t: T) => R): Result<R, F> {
return success<R, F>(fnR(this.value));
}
mapL<L>(_: (f: F) => L): Result<T, L> {
return success<T, L>(this.value);
}
}
export class Failure<T, F> implements IResult<T, F> {
constructor(value: F) {
this.value = value;
}
readonly value: F;
isSuccess() {
return false;
}
isFailure() {
return true;
}
fmap<R, L>(fnL: (f: F) => Result<R, L>, fnR: (t: T) => Result<R, L>): Result<R, L> {
return fnL(this.value);
}
fmapR<R>(fnR: (t: T) => Result<R, F>): Result<R, F> {
return failure(this.value);
}
fmapL<L>(fnL: (t: F) => Result<T, L>): Result<T, L> {
return fnL(this.value);
}
map<R, L>(fnL: (f: F) => L, fnR: (t: T) => R): Result<R, L> {
return failure<R, L>(fnL(this.value));
}
mapR<R>(_: (t: T) => R): Result<R, F> {
return failure<R, F>(this.value);
}
mapL<L>(fnL: (f: F) => L): Result<T, L> {
return failure<T, L>(fnL(this.value));
}
}
export function success<T, F>(value: T) {
return new Success<T, F>(value);
}
export function failure<T, F>(value: F) {
return new Failure<T, F>(value);
}
使い方は先ほどと殆ど同じです。
type User = {name:string}
findUser(id:string):Result<User,Error>{
const user = db.query(`SELECT * FROM users WHERE id = ${id}`)
if (!user) {
return failure(new Error("no user"));
} else {
return success({name:user.name})
}
}
function signIn(id:string){
const result = findUser(id)
result.map(user=>{
//something like session update
},err=>{
//error handling
})
}
それでは、よいコーディングライフを!
Discussion