Skip to content

lue-bird/elm-no-record-type-alias-constructor-function

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

57 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

trick: no record type alias constructor function

forms example in guide.elm-lang.org:

type alias Model =
    { name : String
    , password : String
    , passwordAgain : String
    }

init : Model
init =
    Model "" "" ""

↑ Every directly aliased record type gets its default constructor function.

You can trick the compiler into not creating a Model record constructor function:

import RecordWithoutConstructorFunction exposing (RecordWithoutConstructorFunction)

type alias Model =
    RecordWithoutConstructorFunction
        { name : String
        , password : String
        , passwordAgain : String
        }

init : Model
init =
    -- Model "" "" ""  <- error
    { name = "", password = "", passwordAgain = "" }

where

type alias RecordWithoutConstructorFunction record =
    record

tips

why

Fields in a record don't have a "natural order".

{ age = 42, name = "Balsa" }
== { name = "Balsa", age = 42 }
--> True

So it shouldn't matter whether you write

type alias User =
    { name : String, age : Int }
or  { age : Int, name : String }

as well.

User "Balsa" 42

however relies on a specific field order in the type and is more difficult to understand/read. These constructors also open up the possibility for bugs to sneak in without the compiler warning you:

type alias User =
    { status : String
    , name : String 
    }

decodeUser : Decoder User
decodeUser =
    map2 User
        (field "name" string)
        (field "status" string)

Did you spot the mistake? ↑ a similar example

To avoid these kinds of bugs, just forbid type alias constructors:

type alias User =
    RecordWithoutConstructorFunction ...

Problems don't end there.

implicit magic

"There are worse things than being explicit" – Evan

Most record type aliases are not intended to work with positional arguments! Model is the perfect example.

Even if you think it's ok currently, no one reminds you when you add new fields.

there are better alternatives

It's so easy to create an explicit constructor

xy : Float -> Float -> { x : Float, y : Float }

As argued, unnamed arguments shouldn't be the default.

Additionally, your record will be more descriptive and type-safe as a type

type Cat
    = Cat { mood : Mood, birthTime : Time.Posix }

to make wrapping, unwrapping and combining easier, you can try typed-value.

only works in very limited scenarios

type alias Record =
    { someField : () }

type alias Indirect =
    Record
  • Record has a constructor function
  • Indirect doesn't have a constructor function
type alias Extended record =
    { record | someField : () }

type alias Constructed =
    Extended {}
  • Constructed, Extended don't have constructor functions

name clash with variant

example adapted from Elm.Syntax.Exposing

type TopLevelExpose
    = ...
    | TypeExpose TypeExpose

type alias TypeExpose =
    { name : String
    , open : Maybe Range
    }

NAME CLASH - multiple defined TypeExpose type constructors.

How can I know which one you want? Rename one of them!

Either rename type alias TypeExpose to TypeExposeData/... or

type alias TypeExpose =
    RecordWithoutConstructorFunction ...

and get rid of the compile-time error

misunderstood as special magic constructors

experience report by John Pavlick: LETWW, Part 2: "Regular expressions are quite confusing and difficult to use." (read it):

My prior lack of understanding was due to a mental disconnect between the two true sentences, "record type aliases come with implicit constructors" and "all constructors are functions"

[...] After marinating for over a decade in a type system where type names are "special" and have to be invoked only in certain special-case contexts - I couldn't see [...]:

Type names, just like everything else in Elm, are Not Special. They're constructors for a value [= functions].

succeed/constant are misused

I'd consider succeed/constant/... with a constant value in record field value Decoders/Generators/... unidiomatic.

projectDecoder : Decoder Project
projectDecoder =
    map3
        (\name scale selected ->
            { name = name
            , scale = scale
            , selected = selected
            }
        )
        (field "name" string)
        (field "scale" float)
        (succeed NothingSelected) -- weird

Constants should much rather be introduced explicitly in a translation step:

projectDecoder : Decoder Project
projectDecoder =
    map2
        (\name scale ->
            { name = name
            , scale = scale
            , selected = NothingSelected
            }
        )
        (field "name" string)
        (field "scale" float)

For record Codecs (from MartinSStewart's elm-serialize in this example) where we don't need to encode every field value:

serializeProject : Codec String Project
serializeProject =
    record Project
        |> field .name string
        |> field .scale float
        |> field .selected
            (succeed NothingSelected)
        |> finishRecord

succeed is a weird concept for codecs because some dummy value must be encoded which will never be read.

It does not exist in elm-serialize, but it does exist in miniBill's elm-codec (, prozacchiwawa's elm-json-codec, ...):

Create a Codec that produces null as JSON and always decodes as the same value.

Do you really want this behavior? If not, you'll need

serializeProject : Codec String Project
serializeProject =
    record
        (\name scale ->
            { name = name
            , scale = scale
            , selected = NothingSelected
            }
        )
        |> field .name string
        |> field .scale float
        |> finishRecord

Why not consistently use this record constructing method?

This will also be used often for versioning

enum ProjectVersion0 [ ... ]
    |> andThen
        (\version ->
            case version of
                ProjectVersion0 ->
                    record
                        (\name -> { name = name, scale = 1 })
                        |> field .name string
                        |> finishRecord
                    
                ...
        )

Again: Why not consistently use this record constructing method?

suggestions?

contributing.

why a whole package

RecordWithoutConstructorFunction.elm can simply be copied to your project.

However, if you want

  • no separate RecordWithoutConstructorFunctions hanging around
  • a single place for up to date public documentation
  • a common recognizable name
  • safety that RecordWithoutConstructorFunction will never be aliased to a different type

consider

elm install lue-bird/elm-no-record-type-alias-constructor-function

fields constructor too verbose?

decodeUser =
    map2 (\name status -> { name = name, status = status })
        (field "name" string)
        (field "status" string)

is rather verbose.

There are languages that introduce extra sugar:

field addition

succeed {}
    |> field &name "name" string
    |> field &status "status" string

would be simple and neat. elm dropped this for simplicity.

field "punning"

This is present in purescript and other languages

map2 (\name status -> { name, status })
    (field "name" string)
    (field "status" string)

Jeroen's made a convincing argument on negative consequences for descriptiveness in contexts of functions growing larger.

positional by fields

map2 { name, status }
    (field "name" string)
    (field "status" string)

with either

{ x, y }         : Int -> Int -> { x : Int, y : Int }
{ x =, y = }     : Int -> Int -> { x : Int, y : Int }
{ x = _, y = _ } : Int -> Int -> { x : Int, y : Int }
{ \x y }         : Int -> Int -> { x : Int, y : Int }

The last one was proposed in a very old discussion

It's concise but quite limited in what it can do while not fixing many problems:

  • problems with succeed/constant misuse remain

    • → confusing { x, y = 0, z } syntax necessary
  • less intuitive?

    • recognizing it as a function
    • unlike punning in other languages
  • less explicit than record field punning a.k.a "doesn't scale well" (arguments are taken as they come, can't be combined, ...)

type alias positional 1-field records

Explored in "Safe and explicit records constructors exploration".

Instead of

Point : Int -> Int -> Point

Point : { x : Int } -> { y : Int } -> Point
  • problems with "doesn't scale: can't expose extensible, extended indirect" remain (if not fixed somehow)
  • defined field ordering should explicitly not matter on by-nature-non-positional records
  • could again seem magical and unintuitive

improve safety

Codec.group (\{ x } { y } -> { x = x, y = y })
    |> Codec.part ( .x, \x -> { x = x })
        Codec.int
    |> Codec.part ( .y, \y -> { y = y })
        Codec.int

or better, to always avoid shadowing errors:

Codec.group (\p0 p1 -> { x = .x p0, y = .y p1 })
    |> Codec.part ( .x, \x -> { x = x })
        Codec.int
    |> Codec.part ( .y, \y -> { y = y })
        Codec.int

Making this less verbose with not-necessarily-language-sugared-but-code-generable field stuff:

Codec.group (\p0 p1 -> { x = .x p0, y = .y p1 })
    |> Codec.part Record.x Codec.int
    |> Codec.part Record.y Codec.int

with

import Record exposing (x, y)

x : Part x { x : x } { record_ | x : x }
x =
    part
        { access = .x
        , named = \x -> { x = x }
        , alter = \alter r -> { r | x = r.x |> alter }
        , description = "x"
        }

I bet ideas like this won't be widely adopted (in packages) because setting up tools alongside might be seen as too much work (for beginners).