Syntax Proposal : remove type aliases

Hi,

I have an opinion, which I dont think it all that uncommon, but maybe held to an extreme extent, which is that type aliases are unhelpful and even hurtful to codebases. Here are my primary examples:

type alias UserId = 
    String

type alias Users =
    List User

type alias Validator a b =
    a -> Result Error b

For me, when I see…

getUser : String -> Cmd Msg
getUser userId =

I can see that theres a thing called “userId”, and its of type String. But when I see…

getUser : UserId -> Cmd Msg
getUser userId =

Now its a mystery as to what type userId really is, and to find out, I need to take the manual step of seeing the definition of UserId. Between the two examples, readers of my code have simply lost information by using the type alias UserId.

And for what gain? As far as I can tell, none. Theres no type safety in UserId, its just an alias. An email address is a valid UserId. An entire work of Shakespeare is a valid UserId.

Type safety is great. I think its great to control the creation and use of certain kinds of values. But I dont need type aliases for that. I can do these…

type UserId 
    = UserId String

getUser : UserId -> Cmd Msg
getUser (UserId userId) =

-- ..

getUser : { userId : String } -> Cmd Msg
getUser { userId } =

Whenever you want to give an alias to a type, you could just make a new type instead. Things that are so categorically different that they need distinct names, are just different categories to begin with, and therefore should live in your code as different types.

I would say that type aliases should never be used at all, if it were not for their usefulness in defining records.

type alias Model =
    { url : String
    , seed : Random.Seed
    -- ..
    }

So, I propose the following changes to the Elm programming language…

0 Add a new syntax for naming records…

record alias Model =
    { url : String
    , seed : Random.Seed
    -- ..
    }

…that is much like today’s type alias syntax, except that it only works for records, and…

1 Remove Type aliases


What do you all think? Am I missing something?

4 Likes

I kinda agree that in most cases, new types for added safety should be used instead of type aliases. Your UserID example is a good one. And I also agree that aliasing records is 95% of the genuine use case for type aliases. Notice that I didn’t say 100% because I do think that sometimes, they assist readability (for exemple in the case of complex function types).

However, I still don’t think they should be removed the way you proposed. In fact, I’d argue that the only pain point resulting from the existence of type alias that you bring up would be solved with proper IDE tooling, where hovering over a type in signature that is in fact an alias would document what the original is. The recent VSCode plugin with elm language server provides this feature afaik.

2 Likes

I wouldn’t want to get rid of type alias but it’d be nice if type alias UserId = String created a new type that doesn’t typecheck with plain Strings. You don’t even need IDE integration, just a linter that warns you not to alias primitive types imho.

4 Likes

In my use case, type alias helps when the type is long and inconvenient to type (and read), and this use case is not limited to records. If you have a long function like PreiviousParameters -> ActorParameters -> Decision, a type alias ActorFunction = (PreiviousParameters -> ActorParameters -> Decision) could be helpful when defining function annotations that are easier to read like CriticParameters -> ActorFunction -> Decision. (this is not a real life example, but just to showcase how type alias would help when the type description is too long)

I think most of the mistakes that you mention involves only one variable. In this case, we can just disallow type alias on single type like type alias UserId = String.

Another possibility is to introduce different variable name rules for type alias such as it must start with a #. But this involves major change of the language, which is not quite desirable.

Say you have a model that contains just one element: the user id.

type alias Model =
   String

But now this is no longer valid. From what @Chadtech proposed, I could use a record instead:

type alias Model =
    {userId:String}

The Elm-Analyse guidelines say single element records should be avoided.

So instead I would be forced to use

type Model =
    UserId String

Now from a beginner standpoint this would scare me.

Edit:
Actually I like this a lot. Maybe removing aliases completely is too strong. If we add it to Elm-Analyse, as a new rule, it would just be a bad code smell instead.

1 Like

In this case we can just use String, as in https://guide.elm-lang.org/ we can have a model of Int type, and I did not see anything wrong with that.

There are cases we need opaque types and there are cases that we need structural types. Opaque types are especially useful for packages where type serve as a purpose for api, but overuse of it encourages put types into everything and encourages new types over composition. As in the case @Chadtech proposed: type alias Users = List User, we can make a opaque type for list of users, but it is often not necessary. And this tendency is quite strong when the structural type description is long, people merely want to shorten it, but forced opaque type alias would create unnecessary types. Too many types would require some sort of polymorphic solution like inheritance or typeclasses, which are much more complex.
With type alias to sugar long structural types and encourage compositions, it is still quite helpful as a language construct IMO.

Grepping through my code, here are some use-cases to consider:

  1. Exposing an internal type:

    module Mapbox.Layer exposing (Layer)
    
    import Internal
    
    {-| Represents a layer.
    -}
    type alias Layer msg =
        Internal.Layer msg
    

    This is useful if you need to share a type between modules inside your package, but do not want to expose the implementation outside the package.

  2. Documenting a complex type. This has been mentioned above, especially for callbacks this can be helpful. I just want to point out, that a type alias allows you to document that parameter separately in the API docs, without overloading the reader. Here’s a particularly extreme example:

            {-| Maps a `(Float, Float)` **domain** to a
        `(out, out)` **range** (this will be either `(Float, Float)` or `(Time.Posix, Time.Posix)`.)
        Continuous scales support the following operations:
          - [`convert : ContinuousScale inp -> inp -> Float`](#convert)
          - [`invert : ContinuousScale inp -> Float -> inp`](#invert)
          - [`domain : ContinuousScale inp -> (inp, inp)`](#domain)
          - [`range : ContinuousScale inp -> (Float, Float)`](#range)
          - [`rangeExtent : ContinuousScale inp -> (Float, Float)`](#rangeExtent) (which is in this case just an alias for `range`)
          - [`ticks : ContinuousScale inp -> Int -> List inp`](#ticks)
          - [`tickFormat : ContinuousScale inp -> Int -> inp -> String`](#tickFormat)
          - [`clamp : ContinuousScale inp -> ContinuousScale inp`](#clamp)
          - [`nice : Int -> ContinuousScale inp -> ContinuousScale inp`](#nice)
        -}
        type alias ContinuousScale inp =
            Scale
                { domain : ( inp, inp )
                , range : ( Float, Float )
                , convert : ( inp, inp ) -> ( Float, Float ) -> inp -> Float
                , invert : ( inp, inp ) -> ( Float, Float ) -> Float -> inp
                , ticks : ( inp, inp ) -> Int -> List inp
                , tickFormat : ( inp, inp ) -> Int -> inp -> String
                , nice : ( inp, inp ) -> Int -> ( inp, inp )
                , rangeExtent : ( inp, inp ) -> ( Float, Float ) -> ( Float, Float )
                }
    
5 Likes

I will (once again) mention my old post: A type proposal

What bothers me the most is the inconsitency in constructor generation (type alias for Records, and custom types), hence no constuctor for Tuples.

My point is that custom types might become the only ones to generate constructor functions, and eventually treat values as either Records or Tuples, the propo:

-- regular aliases are unchanged (and could rapidly be unused)
type alias MaybeFloat = Maybe Float

-- record aliases do not generate constructors anymore
type alias NoConstructor =
  { name : String
  , email : String
  }

-- union types are the only ones with constructors

type MyType a
  = Const
  | Value a
  | Record { foo : String }
  | Tuple (a, a)

-- The true changes lay in there:
-- GENERATED CONSTRUCTORS:
Const  :           MyType a
Value  : a      -> MyType a
Record : String -> MyType a
Tuple  : a -> a -> MyType a

Why not even limit Custom types to one value (and use Tuples to emulate current behaviour). Read my post for even more ideas, especially to also have function that accept the actual record or the actual tuple.

I think it’s very associated with what you’re saying, WDYT?

Please please please don’t do this.

  1. Union types can’t be used as keys in Dicts and Sets
    If you have an WhateverId chances are that you have somewhere a Dict WhateverId Whatever.
    Until we have efficient Dict and Set that can use union types as keys we need type aliases

type alias Radians = Float

type alias Seconds = Float

If you replace these with a union type, you say goodbye to ever using operators.
I like using operators.

  1. type aliases are very useful to annotate function that have long types.
    I’m not talking about number of arguments, I’m talking functions whose arguments are functions of composite types.
    If you’ve ever written production code, types can get long pretty quickly.
        incomingCriterionShapes : (MakeArrowArgs -> ElementsMapData.IncomingCriterion -> Dict ElementId ElementsMapData.Element) -> ElementsMapData.IncomingCriterion -> List Shape
  1. You can’t re-export types.
module MyApiModule exposing (Model)

import SomeInternalStuffRequiredToWorkAroundCircularDependencies

type alias Model = SomeInternalStuffRequiredToWorkAroundCircularDependencies.Model
3 Likes

Alright. I guess using type alias to re-export an internal type, or to conceal a type within a module, like you do in your examples @xarvh @gampleman is pretty compelling. Those are good points. Thank you.

But I think thats the exact kind of example where I feel it doesnt benefit. Readers arent going to know what an ActorFunction is right away, so they have to go to the definition which is just as long as the long type signature you are avoiding. The only consequence is that readers have to look up the definition; not that the actual definition is any shorter.

Since you’ve mentioned it, while I liked using elm-analyse, I never liked that rule in particular. I love one field records because…

  • One field records have to be constructed, which means the name of the field appears at the call site of the function that uses that record. You get a name where there was no name before.
-- GOOD
    EditView.init { index = 4 }
    --> : EditView.Model

-- LESS GOOD
    EditView.init 4
    --> : EditView.Model, but what was 4 anyway?
  • Its much easier to refactor from a one field record to a two field record than it is to refactor from a value to a two field record.
1 Like

You can hide some elm-analyse guidelines with a config file. They’re not gospel :slight_smile:

1 Like

It is the same when giving a name to a record type instead of putting the whole record definition right in the type signature. So I think it can be useful.

Naming variables is an act to map programming structures to natural language and common knowledge to reduce cognitive load of both the writer and reader. The same applies to types. The reason that we use type alias for records instead of writing the record definitions each time it appears is to use a string token that people understands intuitively, like type alias Point = {x: Float, y: Float}. Sure inevitably people would have to take an extra step to search for definitions, but with good names it is often unnecessary. Furthermore, within a certain type of project, people will build and agree on a mutual sub-vocabulary, e.g. you wouldn’t know what a reducer is until you use Redux. These vocabulary are used very often and description of it would take a long time. And by using type alias, it can reinforce the sub-vocabulary to enhance future communication.

My major argument was that this kind of sub-vocabulary does not necessary to limited be Nouns only: record alias, so I used function as an example.

Also worth noting that the Elm package website does not create links to the definition of a type alias used in this way. I often find myself scrolling or using the browser search functionality to try and locate these definitions - sometimes it can be very awkward, especially if the definition resides in a different package or module.

It can be very helpful during development, where the same function signature must be used many times to define it as a type alias. I am now leaning towards expanding inline all such definitions prior to publishing a package though, in order to make the package more easily usable. Hard to know which approach is better…

1 Like

I’d agree with this point in the spirit of easy-to-read documentation, but one would argue that this in an issue with the generated docs and/or the package website. One that would be somewhat easy to resolve.

Again, there’s nothing wrong with inlining complex types for readability and ease of adoption for newcomers, but outright removing type aliases would quite frankly be the wrong move for this.
It all comes down to naming. And so it is more of an effort on fostering great API design and naming from library authors.

When publishing a library, just ask yourself “Does this type benefits from being aliased or inlined?”, “If the former, what would be a good name for it?”, etc… From there, gather feedback from users and refine your API.

This topic was automatically closed 10 days after the last reply. New replies are no longer allowed.