Extension types?


#1

Newbie question. Is it possible to define elm types as extensions of others? For example, creating a 3D point as extension of 2D.

Say I define 2D point as follows:

type alias TwoDPoint = 
  { x : Int
  , y : Int
  }

Ideally, I’d define a 3D point as an extension of above (made up syntax):

type alias ThreeDPoint extends TwoDPoint = 
  { z : Int
  }

I could then create instances as follows:

twoD : TwoDPoint = { x=1, y=2}
threeD: ThreeDPoint = {x=1, y=2, z=3}

I know I can create a union type:

type alias TwoDPoint = 
  { x : Int
  , y : Int
  }

type alias ThreeDPoint =
  { x : Int 
  , y : Int
  , z : Int
  }

type Point
  = TwoD TwoDPoint
  | ThreeD ThreeDPoint

But that’s not really what I’m after since it means repeating the x, y definition in ThreeDPoint.

Assuming it’s not possible, what’s the Elm idiom for handling extension scenarios?

Thanks.


#2

You have to make the TwoDPoint “extensible” first:

type alias TwoDPoint extends  = 
    { extends
        | x : Int 
        , y : Int
    }

type alias ThreeDPoint  = 
    TwoDPoint { z : Int }

#3

Short answer

You can sort of do it with records but probably shouldn’t. Use either separate types or composition.

Long answer

You can sort of do this (records only) via extensible records. In practice these get painful to work with when used to model an inheritance hierarchy. I don’t recommend going down this approach.

I think it’s interesting to look at why you might want such a feature. I see two in your original post:

  1. Polymorphism - you want to write functions that can accept both 2d and 3d points
  2. DRY - you don’t want to repeat the definition of the x and y components

Composition

Composition is a good mechanism for achieving both of those goals. For example:

type Point2dPlus extra
  = Point2dPlus { x : Int, y : Int } extra

-- A 2d point composed with a z component is a 3d point
type alias Point3d = Point2dPlus { z : Int }

This allows us to define functions that only care about the 2d part like:

quadrant : Point2dPlus a -> Quadrant
quandrant (Point2dPlus { x, y } _) =
  -- do calculations with x and y
  -- we don't care what's in rest

We can require the rest portion to have a specific shape too such as:

add3D : Point3d -> Point3d  -> Point3d
add3D (Point2dPlus p1 rest1) (Point2dPlus p2 rest2 } =
  Point2dPlus { x = p1.x + p2.x, y = p1.y + p2.y } { z = rest1.z + rest2.z }

This approach is super flexible because it allows you to combo any type for the “rest” value. You can write functions that require a particular kind of “rest” value or not care because they only use the 2d portion. The fancy name for this sort of polymorphism is parametric polymorphism.

Separate types

In your particular example, I’d be tempted to just have two standalone types.

From what I know of 2d and 3d coordinate math, I think you will mostly want functions that act on only 2d or only 3d points so I don’t think polymorphism will be helpful here.

When DRYing up code we usually look for repeated characters in our text editor and try to find a way to avoid repeating them. The danger is that pieces of code that are coincidentally similar get coupled in a bad abstraction in the name of DRY. I think that may be the case here :slight_smile:


#4

@joelq @Warry thanks very much for the responses. I wasn’t aware of any of those mechanisms. The 2D/3D points situation is a minimalist example of the real problem I’m looking at (which is proprietary so can’t be shared). The real problem similarly has both polymorphism and DRY going on.

There are rather more fields in the types of the real problem, so DRY becomes more pertinent (rather more to copy). Composition looks like a good approach to it. My only observation would be that the type being extended has to be explicitly defined as extensible. My inclination is that flipping that responsibility to the extending type is more flexible, whilst maintaining the benefits of both type safety and polymorphism.

Anyway, great to know I have options. Many thanks both for replying.


#5

There is also the wrapping extension:
Strange when working with 3d points, but might be great for real life data. You get some data and decode it with a limited type alias, then you learn some more about that thing from another api and extend it into another type with more fields instead of having default values in decoder or have to copy all the fields…

type alias TwoD = 
  { x: Int
  , y: Int
  }

type alias ThreeD = 
  { twoD: TwoD
  , z: Int
  }

#6

You could also use separate types with common fields and use function signatures:

type alias Point2D = {x: Float, y: Float}
type alias Point3D = {x: Float, y: Float z: Float}
f: {a|x:Float} ->Float
f p =
2*p.x

f can be called on both Point2D and Point3D.


#7

It seems like you can accomplish that by doing this:

type alias Point2D = {x: Float, y: Float}

type alias PlusZ extends = {extends | z : Float}

type alias Point3D = PlusZ Point2D

#8

#9

@razze @Herteby: lovely. That’s pretty much exactly what I was looking for. Thanks very much.


#10

This is an interesting idea, but I am wondering does it work out well in practice?

Suppose I have this:

type alias Point2D = {x: Float, y: Float} 
type alias PlusZ extends = {extends | z : Float}
type alias Point3D = PlusZ Point2D

{-| Distance projected onto the XY plane. -}
distanceXY : Point2D -> Float
distanceXY point = Math.sqrt (point.x * point.x + point.y * point.y)

2dpoint : Point2D
2dpoint = { x = 1, y = 1 }

3dpoint : Point3D
3dpoint = { x = 1, y = 1, z = 1 }

distanceXY 2dpoint -- type checks ok

distanceXY 3dpoint -- fails to type check as distance is not over an extensible type.

The distance function is not over an extensible record. Distance needs to be:

distanceXY : { a | x : Float, y : Float } -> Float

So that there is an ‘a’ free to match against the remaining fields or empty record {}.

Is this good enough reason to rule out making the extending type the extensible one?


#11

Ah, that’s true. I haven’t used extendable records myself yet, it was just a trick I thought of.

I tried if perhaps distanceXY : {a | Point2D} -> Float would work, but the compiler didn’t agree :stuck_out_tongue:

Btw, I wonder if it would be terrible if Elm allowed extra fields on records like TypeScript does.
ie. that distanceXY : Point2D -> Float would also accept Point3D even though it has an extra field. I can’t immediately think of a case where that would cause problems :thinking:

Also, if a function wanted { field: String, optionalField: Maybe String } it would accept {field: String}and treat the missing field as Nothing.


#12

What if you had a function like this:

translate : Point2D -> Point2D

And you do translate { x = 1, y = 1, z = 1 }. Would you expect the function to return a Point2D or a Point3D? That is really the problem, because this is actually sub-typing, rather than extensible records. Type inference doesn’t work with sub-typing because as above, there are situations where the compiler can’t decide which type you actually mean.

translate : { a | x : Float, y : Float } -> { a | x : Float, y : Float }

Is not subtyping, because every time you use it, the compiler knows explicitly what a is. It can be the empty record {} for a Point2D, or the z : Float fragment for a Point3D. Since we use the same a in the return type, the compiler knows exactly what we want to return.

The differences between extensible records and subtyping are quite subtle, and every time I come accross it I find myself scratching my head and having to think things through again. For that reason, any time anyone asks about using extensible types I always think it is a very good idea for them to have a go. Just be warned that if you try and do subtyping in Elm, it just won’t work. Extensible records feel like they can do some of the things you can do with subtyping, but there are limits that are worth learning about by trying them out.


#13

I think I would expect it to strip away any extra fields and return a Point2D, so that functions always return what they say they do, but you have the extra flexibility when calling them. And when you don’t want that, you’d use translate : { a | x : Float, y : Float } -> { a | x : Float, y : Float }

I don’t really have enough experience to know how useful this would actually be, but seems like a potentially nice feature.


#14

It might be a fun experiment to try writing something where you always delcare every record type in a whole application as extensible:

type alias Point2d a = { a | x : Float, y : Float }
type alias Model a = { a | position : Point2d {}, otherStuff : String }

To actually use them you need to make them concrete by supplying the empty record type. So the program type would be:

main : Program () (Model {}) Msg

Can’t see this one catching on though…


#15

Interestingly, I think I’ve just run into the subtyping/extension-types difference. I’ve written code that’s like this:

type alias A =
    { woof : Int
    , etc : Bool
    }

type alias ASubset t =
    { t
        | woof : Int
    }

test : A -> ASubset t
test data =
    data

Elm doesn’t like this, but I really can’t see why not. (in fact, just before coming here, I filed a bug about it … which I’ll have to be sure to close if it turns out that there’s something I’m not seeing!). In a language where the type-names are important, I can see why there might be a problem. However, Elm uses structural typing and an ASubset t is always going to have the fields of an A; at compile time, I would expect Elm to be able to say that t is A. Structural typing is why this compiles fine:

type alias X =
    { hello : Int
    }

type alias Y =
    { hello : Int
    }

check : Y -> X
check v = v

Of course, if Y and X are defined differently, then you have a problem. But isn’t that structural-typing issue what extension types are supposed to solve?


#16

I think that’s exactly what it is saying, but it’s saying it as a type error! The function signature claims that it will work for any t but it doesn’t, it only works if t is A. I think what you’re expecting is that Elm reads it as "for some t" rather than "for any t". But that’s not the right interpretation of what type variables mean in Elm.

In other words, the caller can’t decide what t is, like the caller can decide what a and b are in List.map : (a -> b) -> List a -> List b


#17

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