Crystal: Feature request: add non-substitutable type aliases

Created on 13 Jan 2017  路  7Comments  路  Source: crystal-lang/crystal

(Following up on the discussion in #3864.)

It would be useful to have a version of alias that creates type aliases that cannot be substituted for one another. For example (calling this new feature typedef for the sake of discussion), with this feature we'd get a compiler error for a type mismatch for the following:

typedef meters = Int32
typedef feet = Int32

def do_something_assuming_meters(measurement : meters)
  # code that does something that would go badly in the wrong units
end

x : feet = 10

do_something_assuming_meters(x)

since while x is an Int32, it's considered to be of type feet here, which would be incompatible with meters.

This is useful for having the type checker help you avoid errors in a lot of different applications, like attaching units to numbers as shown here, or my original usecase in #3864, which was distinguishing between protein and DNA sequences represented as strings.

feature discussion topictype-system

Most helpful comment

I'd actually like to remove type from the language :-)

Before explaining why, I want to say that this is already possible by using structs, or simply record. For example:

record Feet, value : Int32
record Meters, value : Int32

feet = Feet.new(10)
meters = Meters.new(20)

def do_something_assuming_meters(measurements : Meters)
end

do_something_assuming_meters(meters) # OK
do_something_assuming_meters(feet)   # Error

LLVM will optimize this to simply use Int32, so it's as efficient as using Int32, but more type-safe.

The problem with type is this. Let's assume this:

type Feet = Int32

First, how do you construct such type? Doing:

feet : Feet = 10

is not a valid option, because the language right now doesn't have implicit type casting, and using type declarations like the above is not common at all.

Maybe we can use this syntax:

feet = Feet.new(10)

which is already closer to my record solution above.

Next, what's the type of this:

feet + 1

Or this:

feet + feet

if we have type Feet = Int32, where there's Int32+#(Int32) : Int32, how does the compiler know that the + method now suddenly needs to return Int32? Maybe we can use the rule "if a method on a type returns the underlying type, automatically cast it to the wrapping type". But for example Int32 has a sign method that returns -1, 0 or 1 according to the number's sign. So feet.sign would now return Feet, which is definitely something you don't want to happen.

The part feet + feet is trickier: is it always valid to pass a Feet value where an argument expects Int32? For example there's Number#significant(digits, base = 10) method, can I pass Feet as arguments? (that method doesn't even have type restrictions)

What's the solution to the above? Explicitly defining methods on the Feet type. We can define the + method like this:

record Feet, value : Int32 do
  # Maybe we'd like to just add other feet, never integers
  def +(other : Feet)
    Feet.new(value + other.value)
  end
end

Note that I wouldn't even consider using method_missing because it has the same problem I described above.

So 馃憥 from me on adding a type concept outside libs (I'd actually like to remove type from libs too and just use alias, chances of passing a wrong Void* pointer type are very low, and if you do that you'll immediately gets an error, so type adds very little type safety and has already the problems I described above)

All 7 comments

I don't think @asterite is particularly keen on this feature, but I quite like it. I think it provides some extra type safety essentially for free. I think that type is a good name for it, as it's defining a new type.

I don't know if this is a good idea... so how this works?

typedef Feet = Int32
x : Feet = 10 # here should raise an error that 10 is not a Feet, it's Int32

We have this in lib because we can ensure these value can only be use between the library's functions, right? (like HANDLE in Windows API, we should not modify or try to understand what the real value is, but it is meaningful in Window API world)

I think it can be done by macro if you really need this.

macro type_new(new_type, from old_type)
  struct {{new_type}}
    def initialize(@%x : {{old_type}})
    end

    macro method_missing(call)
      (@%x.\{{call}}).try do |%y|
        if %y.is_a? {{old_type}}
          {{new_type}}.new %y
        else
          %y
        end
      end
    end
  end
end

type_new Foo, from: Int32
type_new Bar, from: Int32
foo = Foo.new 0
bar = Bar.new 0

def only_accept_foo(x : Foo)
end

only_accept_foo foo
only_accept_foo bar # => no overload matches 'only_accept_foo' with type Bar

I'd actually like to remove type from the language :-)

Before explaining why, I want to say that this is already possible by using structs, or simply record. For example:

record Feet, value : Int32
record Meters, value : Int32

feet = Feet.new(10)
meters = Meters.new(20)

def do_something_assuming_meters(measurements : Meters)
end

do_something_assuming_meters(meters) # OK
do_something_assuming_meters(feet)   # Error

LLVM will optimize this to simply use Int32, so it's as efficient as using Int32, but more type-safe.

The problem with type is this. Let's assume this:

type Feet = Int32

First, how do you construct such type? Doing:

feet : Feet = 10

is not a valid option, because the language right now doesn't have implicit type casting, and using type declarations like the above is not common at all.

Maybe we can use this syntax:

feet = Feet.new(10)

which is already closer to my record solution above.

Next, what's the type of this:

feet + 1

Or this:

feet + feet

if we have type Feet = Int32, where there's Int32+#(Int32) : Int32, how does the compiler know that the + method now suddenly needs to return Int32? Maybe we can use the rule "if a method on a type returns the underlying type, automatically cast it to the wrapping type". But for example Int32 has a sign method that returns -1, 0 or 1 according to the number's sign. So feet.sign would now return Feet, which is definitely something you don't want to happen.

The part feet + feet is trickier: is it always valid to pass a Feet value where an argument expects Int32? For example there's Number#significant(digits, base = 10) method, can I pass Feet as arguments? (that method doesn't even have type restrictions)

What's the solution to the above? Explicitly defining methods on the Feet type. We can define the + method like this:

record Feet, value : Int32 do
  # Maybe we'd like to just add other feet, never integers
  def +(other : Feet)
    Feet.new(value + other.value)
  end
end

Note that I wouldn't even consider using method_missing because it has the same problem I described above.

So 馃憥 from me on adding a type concept outside libs (I'd actually like to remove type from libs too and just use alias, chances of passing a wrong Void* pointer type are very low, and if you do that you'll immediately gets an error, so type adds very little type safety and has already the problems I described above)

Great explanation. No type outside of lib, then. I think I still like type over a simple alias, but I agree it's more a comfort feeling than actual safety.

Good points! For your examples like feet + feet, or feet = 10 I was envisioning you would have to explicitly cast with something like feet.as(Int32) + feet.as(Int32), or feet = 10.as(Feet) (which I apparently left out of my example's initialization).

But as you say, that's just the same as the record solution; I'm completely satisfied with using records for this and happy to have the feature request closed!

Great! Closing this issue then.

Was this page helpful?
0 / 5 - 0 ratings

Related issues

ArthurZ picture ArthurZ  路  3Comments

Sija picture Sija  路  3Comments

asterite picture asterite  路  3Comments

costajob picture costajob  路  3Comments

oprypin picture oprypin  路  3Comments