👻

F# スマートコンストラクタをジェネリック+幽霊型で汎用化……したかったけど良くなかった(追記)

に公開

追記(2026/5/28): この記事の方法は悪い方法です

スマートコンストラクタの記述量を減らしたくて幽霊型を使っていたが、これには致命的な欠陥があった。任意のバリデーション関数と、任意のタイプをその場で自由に組み合わせられてしまう、という点だ。

たとえばこういうふうに書けてしまう。

type EmailTag = class end
type Email = ValidatedValue<string, string, EmailTag>
let validateEmail (s: string) = if s.Contains("@") then Ok () else Error "無効なメールです"

// その場で型とバリデーションルールを合成してEmail型の値を定義する
let email: Result<Email, string> = ValidatedValue.create validateEmail "tatakae@example.com"

ほかにも、

  • 型ごとの固有のふるまいを足しづらい
  • コンパイルエラーやログに固有の型名が表示されず、幽霊型が表示されて追跡しづらそう

このへんの理由により、これは悪い方法だと私は判断した。幽霊型自体は使いどころを選べば便利そうだが、今回は使いどころが悪かった。判断の経緯を残すため、以下に旧記事は全文残しておく。

旧記事

元コード

namespace Accounts.Host.Contexts.Identity.Domain

open System

type AccountId = private AccountId of Guid

[<RequireQualifiedAccess>]
module AccountId =
    let create id =

        if id = Guid.Empty then
            Error "AccountId cannot be empty."
        else
            Ok (AccountId id)

    let value (AccountId id) = id

スマートコンストラクタについて四苦八苦している記事はこちら。

https://zenn.dev/hitoshiro/articles/e950bc03cf75f9

:::message
筆者がTypeScriptチョットワカルので、たまにTypeScriptの話が混じります。適当に読み飛ばしてください。
:::

F#のジェネリック

https://learn.microsoft.com/ja-jp/dotnet/fsharp/language-reference/generics/

TypeScriptのジェネリクスとほぼほぼ同じだ。違いは型パラメータにシングルクォート'がつくぐらい。

TypeScript

function identity<T>(value: T): T {
    return value;
}

F#

let identity<'T> (value: 'T) : 'T = 
    value

これを使ってスマートコンストラクタを汎用化する。

幽霊型を使わずにジェネリック

幽霊型を使わずに汎用型を定義してみる。

/// 値とエラーを型引数に取る
type BadValidatedValue<'Value, 'Error> = private BadValidatedValue of 'Value

module BadValidatedValue =
    let create (validate: 'Value -> Result<unit, 'Error>) (value: 'Value) : Result<BadValidatedValue<'Value, 'Error>, 'Error> =
        match validate value with
        | Ok () -> Ok (BadValidatedValue value)
        | Error err -> Error err

    let value (BadValidatedValue v) = v

この汎用型を使って、ユーザー名とパスワードを定義してみる。どちらもstringを渡して作成するように定義する。

※ テストのために書いているので、いったん[<RequireQualifiedAccess>]属性のことは忘れる。

type BadUserName = BadValidatedValue<string, string> // 👈 BadPasswordと同じ型
module BadUserName =
    let validate (name: string) : Result<unit, string> =
        if String.IsNullOrWhiteSpace name then
            Error "ユーザー名は空にできません"
        else
            Ok ()

    let create rawUserName = BadValidatedValue.create validate rawUserName
    let value = BadValidatedValue.value

type BadPassword = BadValidatedValue<string, string> // 👈BadUserNameと同じ型
module BadPassword =
    let validate (password: string) : Result<unit, string> =
        if String.IsNullOrWhiteSpace password then
            Error "パスワードは空にできません"
        else
            Ok ()
    let create rawPassword = BadValidatedValue.create validate rawPassword
    let value = BadValidatedValue.value

BadUserName型もBadPassword型も、同じBadValidatedValue<string, string>型のエイリアスとして定義されてしまっている。ただのエイリアスは構造が同じなら同じ型として扱われるので、BadPassword型しか受け付けない関数にBadUserName型の値を渡せてしまう。

module Test =
    let createUser (username: BadUserName) (password: BadPassword) =
        printfn "ユーザーを作成: %s / %s" (BadUserName.value username) (BadPassword.value password)

    let test () =
        let nameResult = BadUserName.create "Alice"
        let passwordResult = BadPassword.create "secret"
        match nameResult, passwordResult with
        | Ok username, Ok password -> createUser username username // 成功
                                                        // --------
                                                        // BadPassword型を引数に取る箇所に、BadUserName型の値を渡せてしまう
        | Error err, _ -> printfn "ユーザー名のエラー: %s" err
        | _, Error err -> printfn "パスワードのエラー: %s" err

これらの型の区別をつけるために幽霊型を採用する。

幽霊型を使って型の区別のつくジェネリック

/// 値とエラー、タグ(中身のない型)を型引数に取る
type ValidatedValue<'Value, 'Error, 'Tag> = private ValidatedValue of 'Value

/// module定義の内容は'Tag以外すべて同じ
module ValidatedValue =
    let create (validate: 'Value -> Result<unit, 'Error>) (value: 'Value) : Result<ValidatedValue<'Value, 'Error, 'Tag>, 'Error> =
        match validate value with
        | Ok () -> Ok (ValidatedValue value)
        | Error err -> Error err
    let value (ValidatedValue v) = v

利用する側は以下のように、適当な空の型を定義して使うことになる。

type UserNameTag = UserNameTag // 判別共用体で単一のケース識別子しか定義しない。中身は空
type UserName = ValidatedValue<string, string, UserNameTag> // Passwordとは異なる型

module UserName =
    let validate (name: string) : Result<unit, string> =
        if String.IsNullOrWhiteSpace name then
            Error "ユーザー名は空にできません"
        else
            Ok ()

    let create rawUserName = ValidatedValue.create validate rawUserName
    let value = ValidatedValue.value

type PasswordTag = PasswordTag
type Password = ValidatedValue<string, string, PasswordTag> // UserNameとは異なる型

module Password =
    let validate (password: string) : Result<unit, string> =
        if String.IsNullOrWhiteSpace password then
            Error "パスワードは空にできません"
        else
            Ok ()

    let create rawPassword = ValidatedValue.create validate rawPassword
    let value = ValidatedValue.value

こうすると、先ほどのようにPassword型しか受け付けない関数にUserName型の値を渡したとしてもコンパイルエラーになる。

module Test =
    let createUser (username: UserName) (password: Password) =
        printfn "ユーザーを作成: %s / %s" (UserName.value username) (Password.value password)

    let test () =
        let nameResult = UserName.create "Alice"
        let passwordResult = Password.create "secret"
        match nameResult, passwordResult with
        | Ok username, Ok password -> createUser username username // 🚫コンパイルエラー!
                                                        // --------
                                                        // Password型の引数にUserName型は渡せない
        | Error err, _ -> printfn "ユーザー名のエラー: %s" err
        | _, Error err -> printfn "パスワードのエラー: %s" err

…………ここまで書いてやっと気づく。

これってTypeScriptのBranded Typesでは?

※ とてもわかりやすいTypeScript Branded Typesの解説記事
https://zenn.dev/farstep/articles/typescript-branded-types

  • TypeScriptのBranded Types ... 実体のないプロパティを交差型で合成して区別
  • F#の幽霊型 ... 実体のない型を型パラメータに追加して区別

発想も目的も同じだ。 となると疑問がひとつ湧く。

単一ケースの判別共用体じゃダメなんですか?

F#の判別共用体には、単一ケースで使って生成元の型と区別できるという便利な使い方がある。

// これはただの型エイリアス。string型とは区別されない
type BadUserName = string

// string型から新しいUserName型を作る
type UserName = UserName of string 

つまりこうしても良さそうなものだ。

// ValidatedValue<string, string>から新しいUserName型を作る
type UserName = UserName of ValidatedValue<string, string>

これでもちょっとvalue関数で書くべき量が増える(これも面倒だが……)が使えはする。F#らしくてカッコイイ気もする。ただ問題は、元の汎用型であるValidatedValue<'Value, 'Error>型とはまったく異なる型として定義されてしまうことだ。

一番想像できる困りごとは、ValidatedValue型を引数として受け取る関数を定義したくなったときだ。たとえばこのような適当な関数を定義する。

let printValue (vv: ValidatedValue<'Value, 'Error>) =
    let x = ValidatedValue.value vv
    printfn "ValidatedValueの中身: %A" x

これに対し、判別共用体の単一ケースで作った型を渡そうとしても、ValidatedValue<'Value, 'Error>とは異なる型なので受け付けてくれない。

let myUserName = UserName.create ("TestUser") // 型は「UserName」になる
printValue myUserName // 🚫ValidatedValue<'Value, 'Error>ではないのでコンパイルエラー

型が UserName > ValidatedValue<'Value, 'Error> > string ... と一回多くラップされていることになるので、何らかの手段でアンラップしなければならなくなる。そして型の表現はprivateで非公開になっているので、アンラップ関数をまた別に用意するか、アンラップする式を書くかなにかしないといけない……(面倒)。

その点、幽霊型ならシンプルだ。

let printValue (vv: ValidatedValue<'Value, 'Error, 'Tag>) =
    let x = ValidatedValue.value vv
    printfn "ValidatedValueの中身: %A" x

let myUserName = UserName.create ("TestUser") // 型はValidatedValue<'Value, 'Error, 'Tag>になる
printValue myUserName // ✅OK

判別共用体は便利だが、ほんとうにその世界に閉じたものだけに適用しないと、却って面倒なことになる。気をつけたい。

Discussion