About UnionType

Why doesn’t work?

module Test
    class Point
        def initialize(@arr : Array(Int32 | Float64))
        end
    end

    def self.union_type_brainf8ck
        a: Int32 | Float64
        b: Int32 | Float64

        a = 2.0
        b = 2.0

        point = Point.new [a, b]
        p! point

    end

end
Error: instance variable '@arr_init' of V::Point must be Array(Float64 | Int32), not Array(Float64)

Apparently type declarations don’t pin the entire union type, only the member that was assigned first.

a : Int32 | Float64
b : Int32 | Float64

a = 0.0
b = 0

typeof(a) # => Float64
typeof(b) # => Int32

I would say this is unexpected. But maybe there’s a reason for that…?

Anyways, I’m not sure if type declarations with unions are very useful.

For this use case, instead of tying down the variables, I would recommend to create the array with the appropriate type: [a, b] of Int32 | Float64.

Is there maybe a syntactic ambiguity hiding in there?

Because the typeof() could be interpreted as:

  • give me the type of whatever is stored in this variable
  • give me the type of the variable

I think those are 2 different things to ask, right?
Is there a way in crystal to ask the second question?

When I implemented this I did it as a type restriction. That is, the variable can only hold those types, but it’s compile. Time can be narrower than that.

That said, I think someone should change that to be a type declaration instead.

1 Like

typeof() returns the compile time type of an expression (i.e. what can be stored in a variable). Object#class returns the runtime type of a value.
So if you have a variable that can be either int or float, you might get this:

x = [0, 0.0].first
typeof(x) # =>  Float64 | Int32
x.class # => Int32
1 Like

Yet another example of TypeDeclaration making things unnecessarily complex when a = 2.0.as(Int32 | Float64) would do.

Also I don’t think a: Int32 | Float64 should compile without a space before the colon, since it doesn’t as a def argument.

1 Like

Definitely :+1:

I think there’s value in saying “I want this local variable to be of this type, regardless of what I assign to it later on”.

4 Likes

Ref: Do not use `TypeDeclaration`s on local variables by HertzDevil · Pull Request #12364 · crystal-lang/crystal · GitHub

Okay, I wanted to fix this by making it work like this:

a : Int32 | String = 0
typeof(a) # => Int32 | String

But then I thought… what happens if we later assign a new value to a?

a : Int32 | String = 0
typeof(a) # => Int32 | String

a = "hello"
typeof(a) # => ?

a.size # Will this work??

The main issue is that if we fix the type of a to always be Int32 | String then the type flow won’t work.

I think that’s the reason I chose to make a : T mean “a can be anything that fits into T, but the actual compile-time type can be a subset of T”.

So now I’m thinking that this is correct semantic.

2 Likes

There is a variant of this issue where uninitialized variable types also aren’t fixed but pointers to them are:

x = uninitialized Char | Float64
x = 1.0
typeof(x) # => Float64

pointerof(x).value = '😂'
x                            # => 1.0000000000285358
x.unsafe_as(UInt64).to_s(16) # => "3ff000000001f602"
pointerof(x).value           # => '😂'

Yes, i consider current solution is perfect, it did it job (limit possible type) very well.

The newbie probably need some time to understood it.

I created a PR for deprecating space before colon in a type declaration: Warn on missing space before colon in type declaration/restriction by straight-shoota · Pull Request #12740 · crystal-lang/crystal · GitHub

1 Like