Skip to content

lue-bird/elm-review-opaque-type

Repository files navigation

⚠️ This rule is pretty idealistic, especially for packages, so decide carefully before committing to using it

elm-review-opaque-type

(🔧) Review.OpaqueType.forbid reports types that are exposed without their variants.

If you want to learn more about opaque types first:

import Review.Rule
import Review.OpaqueType
import NoMissingTypeExpose

config : List Review.Rule.Rule
config =
    [ Review.OpaqueType.forbid

    -- so that your exposed type aliases don't reference hidden types
    , NoMissingTypeExpose.rule
    ]

why?

Claim: "opaque types give you neither convenience, confidence, nor the rewards".

  • the stored value does not know as much as your type suggests.

    type Email = Email String
    domain = \(Email email) -> ??

    compare with e.g.

    type Email
        = Email { local : Local, domain : Domain }
    domain = \(Email email) -> email.domain

    why not take this free gift from storing the parsed data, even if you don't need it right now?

    When using opaque types, you still have to validate broad values. I know you have this regex around that "should work". The code will barely grow in complexity if you make it parse instead, if at all.

    And once you've tightly defined the type, your job is done forever since there's no way to construct invalid values, even internally. If it makes sense, maybe publish it and let everyone profit

  • no module has "authority" over a piece of data. This is effectively an argument against encapsulation, where certain data can only be read and edited by certain privileged functions. When your type makes it impossible to construct values that don't make sense, there's no need to hide access away.

    For example, elm/html does not expose the Html type and the only way to "use" it is by passing it to the runtime in view. But what if you wanted to convert the html to a String, have a global sanitizing function, encode it or easily test for specific properties in pure elm?

    Most ui libraries have the same problem where the only way to use their ui types is by converting them to the opaque Html type. A better alternative would be a type like Html.Parser.Node or Web.DomNode

    Another problematic area is transporting elm type values through another format. Some packages provide encode and decode if they're generous, others don't. What if you wanted xml, bytes or yaml instead? lamdera for example will simply not wire opaque types between frontend and backend – and that makes sense. What if a new patch version of the package stores the type's data differently? What if someone altered the wired bytes so that the opaque data wouldn't even pass validation? ...

    Limiting access to values might not be a great idea because you can't and shouldn't really account for all possible use-cases when writing the type.

  • usually there's no safe way to construct them, which can make benchmarking and testing the insides of modules with opaque types in an application harder. Generally, it's encouraged to only test a module from the outside but sometimes you might want to check if some implementation detail specifically is working

    -- module PersonalNumberUk exposing (PersonalNumberUk)
    
    type PersonalNumberUk
        = PersonalNumberUk
            { prefix : Prefix, digits : Vector6 Digit, finalLetter : FinalLetter }
    
    -- we want to test this
    finalLetterFromChar : Char -> Maybe FinalLetter

    try for example

    -- module PersonalNumberUk exposing (PersonalNumberUk)
    import PersonalNumberUk.FinalLetter exposing (FinalLetter)
    
    type PersonalNumberUk
        = PersonalNumberUk
            { prefix : Prefix, digits : Vector6 Digit, finalLetter : FinalLetter }
    
    -- module PersonalNumberUk.FinalLetter exposing 
    fromChar : Char -> Maybe FinalLetter
    
    -- module PersonalNumberUk.Test exposing (tests)
    tests : Test
    tests =
        Test.test "final letter parses a|A as A"
            (\() ->
                'a'
                  |> PersonalNumberUk.FinalLetter.fromChar
                  |> Expect.equal
                      -- now we can directly check for the value
                      PersonalNumberUk.FinalLetter.A
            )

    that way, it's not part of the API of PersonalNumberUk but still accessible from tests and the main module. (Btw, if you have a better example for this, tell me)

but what are the alternatives?

from stupidly obvious to powerful

  • Did you hide the variants because constructing a value of that type is useless/impossible? Like

    type YourTypeOnlyTag = YourTypeOnlyTag Never

    There's no harm in exposing those variants. Add the Never to be extra sure nobody gets the idea to construct it.

  • Do you lose guarantees if you expose this type's variants?

    -- module UsMoney exposing (UsMoney, cents, dollars)
    type UsMoney
        = InCents Int
    
    cents : Int -> UsMoney
    cents =
        InCents
    
    dollars : Int -> UsMoney
    dollars = \dollarAmount ->
        (dollarAmount * 100) |> cents

    you lose nothing by exposing the variant UsMoney.InCents

    module UsMoney exposing (UsMoney(..), cents, dollars)
    ..same as before..

    As an added benefit you allow pattern matching.

    If you take away one thing from this package, it's to use descriptive wrapper types with just one variant often, even if you don't plan on hiding that variant. That alone will prevent most accidents and make things more clear.

  • Did you hide the variants because your type has phantom type parameters? → "phantom types - but what are the alternatives?"

  • Did you hide the variants because you want to internally preserve certain properties that a user could bypass? Try modeling it using choice types, like instead of

    -- module WebGL.Texture exposing (Magnify(..))
    type Magnify
        = MagnifyById Int
    
    linear : Magnify
    linear = MagnifyById 9727
    nearest : Magnify
    nearest = MagnifyById 9728

    why not

    -- module WebGL.Texture exposing (Magnify(..))
    type Magnify
        = Linear
        | Nearest
    
    magnifyToId : Magnify -> Int
    magnifyToId = \magnify ->
        case magnify of
            Linear -> 9727
            Nearest -> 9728

    This will likely work in more places than you think, even for e.g. allowed letters in an email.

  • Do you hide the variants because if you moved the type into the exposed modules there would be import cycles?

    -- module Expression exposing (Expression)
    type alias Expression =
        Expression.Internal.Expression
    
    -- module Expression.Internal exposing (Expression(..), LetIn(..))
    type Expression
        = ...
        | LetIn Expression.LetIn.LetIn
    
    type LetIn
        = ...
        | LetDestructuring { ..., destructured : Expression }
    
    -- module Expression.LetIn exposing (LetIn)
    type alias LetIn =
        Expression.Internal.LetIn

    Why not move the necessary types together into one module?

    -- module Expression exposing (Expression(..), LetIn(..))
    type Expression
        = ...
        | LetIn Expression.LetIn.LetIn
    
    type LetIn
        = ...
        | LetDestructuring { ..., destructured : Expression }
    
    -- module Expression.LetIn exposing (..., ...)
    {-| Helpers for [`Expression.LetIn`](Expression#LetIn)
    -}
    import Expression exposing (LetIn)

    you can of course add an alias back into Expression.LetIn but just linking to it seems enough.

    Here's another approach for module structures like

    -- module Decimal exposing (Decimal, round)
    import Integer.Internal exposing (Integer)
    
    type alias Decimal = Decimal.Internal.Decimal
    round : Decimal -> Integer
    -- module Integer exposing (Integer, divideBy)
    import Decimal.Internal exposing (Decimal)
    
    type alias Integer = Integer.Internal.Integer
    divideBy : Integer -> (Integer -> Decimal)
    -- module Integer.Internal exposing (Integer(..))
    type Integer = Integer ...
    -- module Decimal.Internal exposing (Decimal(..))
    type Decimal = Decimal ...

    I would strongly suggest re-organizing the modules so that for example Decimal gets all the functions that return a Decimal (here some form of the divideBy function) but if this is not viable or pretty, here's a trick: Wrapping the data into a record instead of variant

    -- module Decimal exposing (Decimal, round)
    import Integer.Internal exposing (Integer)
    type alias Decimal = { decimal : ... }
    type alias Integer = { integer : ... }
    round : Decimal -> Integer
    -- module Integer exposing (Integer, divideBy)
    import Decimal.Internal exposing (Decimal)
    type alias Decimal = { decimal : ... }
    type alias Integer = { integer : ... }
    divideBy : Integer -> (Integer -> Decimal)

    This way, you can define the type in multiple modules to break the cycle. As a bonus, you'll get an easy way to deconstruct using .integer & .decimal. Make sure to keep these definitions in sync. Maybe even write tests like

    ... |> Integer.divideBy ... |> Decimal.round

    Yet another technique is "duplicating internal API to the outside". This can mean 1:1 copy or a "user-facing view" of some aspect.

    -- module A exposing (A(..), doSomething)
    type A
        = X X
        | Y Y
    aToInternal : A -> A.Internal.A
    aFromInternal : A.Internal.A -> A
    
    doSomething : A -> A
    doSomething = \a ->
        a |> aToInternal |> A.Internal.doSomething |> aFromInternal
    -- module A.Internal exposing (A(..), doSomething)
    type A
        = X X
        | Y Y
    doSomething : A -> A

    I've written it this abstractly because this is as boilerplate-y as it looks and I've yet to see a package where earlier techniques didn't work. Maybe yours?

Mostly for packages:

  • Do you use opaque types to allow adding configuration in a future version without it counting as a major version bump? I feel your pain. I also dream for the day where adding variants as input or fields as output only requires a minor version bump. I don't think cases like this will be super frequent, though, so clearly telling your users your new version won't break their code is pretty good already.

  • Did you hide the variants because you want to be able to change details about the type (not what it represents but how it's stored) in the future without forcing a major version bump? I find cases like that to be really rare in practice, with type definitions only changing with a change of context. I think the best you can do is telling users that the upgrading to the version won't mean any breaking changes if they didn't access the safe internals.

not convinced?

I'm super interested in what you're brewing! Do you use them to get better performance, to cash some data or because there doesn't seem to be another way to ensure certain properties (like sorting in a Dict)? If you want to, text me @lue on slack as these are problems I like finding nicer fixes for.

thanks