How do I extend the datatype system

Hi.

I want to define my own data types that could be used with Crystal primitive types interchangeably
for instance I want to be able to do the following

types.cr file

struct Epoch; 
     def to_time; end
     def other_func; end
end
alias E = Epoch

models.cr file

struct Model
    property x : Epoch = 1589831432
end

m = Model.new
puts m.x.to_time
puts m.x -> should give me 1589831432

so basically what I want is that is the new Epoch datatype to be initialized with Int64 directly
and then I can have other functions inside my Epoch struct that can do other conversions
like convert it to UTC time, etc
How can I achieve something like this ?

Please note , this example is not about timestamps only , my intention is having a datatype that looks like
Int32 or Int64 or whatever but have custom functions where I can do my own conversions

struct Epoch
  def initialize(time : Int32)
    @time = time
  end
end

p Epoch.new(123456789)

Is this what you want?

Or really just use https://crystal-lang.org/api/master/Time.html#unix(seconds:Int):Time-class-method then do what you need to do with the resulting Time object. Thus, avoiding the need for a custom type in the first place.

Hi, Thank you for answering @wrq @Blacksmoke16
I updated my post again, my intention and my use case is I want my own custom datatypes that can be easily interchangeable with built in data types
so am not looking for a solution for time conversion in specific
I want to have my own datatype where I can assign to it an Int64 for instance and still have different operations on it. am I clear about this?

AFAIK there isn’t a way to get the API you want, i.e. property x : Epoch = 1589831432. It would have to be like what @wrq suggested. property x : Epoch = Epoch.new 1589831432.

Could possibly be a new compiler feature? That is given x : Epoch = 1234, have that be expanded to x : Epoch = Epoch.new 1234. But thats just an idea…

I understand what you’d like, but I don’t believe that it is possible under the current Crystal compiler.

The compiler is always going to assume 1234 is an Int32, the part of the program where the compiler infers the type of a literal isn’t exposed to the programmer.

That would be a very cool feature, but I think it’d make a lot of code potentially unsafe as well, as there’d be complexity in code that even just looks like:

a = 1234

There’d be know way to look at that line and know if a will be inferred as Int32 or Epoch, or anything else.

Edit: This may be approachable with macros, I’m not sure. I don’t know much about Crystal macros.

Such a kind of autocasting with user-defined types is not possible. It could be technically doable, but it doesn’t look very good comparing usefulness to WTT??? moments for readers of such code.

It would be interesting to know more details about your use case. The example only features a property’s default value assignment which could easily just wrap the literal in a call to Epoch.new. Since you’re declaring the type of the property anyways, there’s no real benefit for autocasting here. Except for saving a few key strokes, but that should be negligible.
Do you have any other examples in mind where you would want to use this?

where I work we used to have simple custom schema syntax for defining models
i.e

name = "" (S)
age = 30 (I)
ip = "" (IP) 

The syntax is simple but needs extra parser to parser it and make objects out of it
I simply wanted to demonstrate that using crystal only, we don’t have to have extra layer of parsing
I can write models in an quick and very fast way, including these assignments (default values)
where these models don’t have to look like am doing lots of code to write them

 struct myModel         
       property toml : Toml = %([database]\nserver = "192.168.1.1")
 end

the thing is when it comes to primitives like Int32 or Strings, you don’t have to explicitly call Int32.new or so , which in this case will be less verbose
so I thought I could have same thing for any custom data type I want which will make my models more verbose and simple
also in my original example if the Epoch data type is an int type, it can be directly compared to integers or so without extra hassle , in the same time I could just extend the methods of the int data type with few more functions that do extra work that fits my needs
for instance in python you can extend any data type and use the new type where the original one can be used, so I thought I could do so

so in brief I was looking for something like

class Epoch < Int32
       def my_custom_function; end
end

But what’s the use case where you would want to compare an arbitrary integer with an Epoch? While the data type of the underlying value is the same, the meaning behind them is different. It just doesn’t make sense to me to have it extend Int32 as you would now essentially be able to do like:

arr = [1, 2, 3]

pp arr[Epoch.new(1234)]

Since def [](index : Int) accepts any Int. This doesn’t make much sense.

What would be an example of what these custom functions would be doing? I just fail to see how this would be any better than just using already built in types like Time which would get you methods better suited for dealing with instances in time than an arbitrary integer.

E.x.

class Foo
  property x : Time = Time.unix 1589831432
end

@Hamdy maybe you can tell what language you come from and how it’s done in that language?

Thanks so much for the attention to this post , I appreciate that!

my use case is simple, I wanted to have subset of the language to use it to define model schema with less code possible.

my background is python and we used to have simple toml format for our schemas
something like

model_name = "person"
name = "myname" (S)  # (S) means string
age = 30 (I)  # I means Integer
created_at = 1234566 (T)  # T is time stamp / epoch

the previous schema definition is simple and into the point, simple toml, I don’t use have to use complex programming logic
but we needed a special parser for this to parse and create proper objects

I wanted to achieve same simplicity of schema using crystal by defining my own data types subset

alias I = Int32?
alias S = String
alias F = Float64?
alias T = TimeStamp?

and I wanted my models/schemas to be defined in terms of these subset

struct Logs
    property url : URL = "blah blah"
    property time : T = 1234567
    property logs : S = "blah blah"
end

so I was thinking in my use case for a time / epoch it’s just an integer , so I wanted to define my type T / TimeStamp to act like integer directly
I can compare it with another timestamp , so I can use the operators <. >. … directly
and in the same time I don’t have to do something like property time : T = T.new 1234567

I understand it’s kind of syntactic sugar, but I was wondering if there’s simple way to have it
In python you could do anythin with any datatype forinstance

In [16]: class TestClass(int): 
    ...:     def __new__(cls, *args, **kwargs): 
    ...:         return  super(TestClass, cls).__new__(cls, 5) 
    ...:                                                                                                                                                                                                    

In [18]: tc = TestClass()                                                                                                                                                                                   

In [19]: tc == 5                                                                                                                                                                                            
Out[19]: True

In [20]: tc == 6                                                                                                                                                                                            
Out[20]: False

To be clear you would also get this using Time.

Yea, there’s not really a clean way to do it. You could define some macro so you don’t have to type the T.new , but I would advise not doing that and just deal with adding T.new, or Time.unix.

Is it worth it? IMO you’re sacrificing readability to save a few extra keystrokes. Probably not a worthy tradeoff. Also checkout Top Level Namespace - Crystal 1.11.0-dev. Generally it’s a good idea to keep structs immutable. I’d also suggest reading Structs - Crystal.

Yea, Crystal is a bit different given its compiled and statically typed nature. It’s harder to do dynamic stuff like this.

This sounds like a really bad idea sacrificing type safety. Why would you want to have different types when you intent to use them interchangably? It seems like an anti-feature.

Some of your ideas are actually possible, though. For example, if you define Epoch<=>(other : Int) and Int#<=>(other : Epoch), you can compare Int and Epoch values.
And you can define overloads on enclosing types like Model to accept Int as value for a Epoch property:


struct Model
  property x : Epoch = 1589831432
  def x=(x : Int)
    self.x = Epoch.new(x)
  end
end

Model.new.x = 1

I don’t follow the different stages of your examples how the custom TOML format relates to the rest of this. However, I’m sure you can setup a deserialization binding that automatically maps TOML literals to whatever Crystal data type you like.
I have no experience with TOML, so I’ll use an example with JSON but it should be similar with TOML:

require "json"

struct Model
  include JSON::Serializable

  @[JSON::Field(converter: Time::EpochConverter)]
  property created_at : Time
end

Model.from_json %({"created_at": 1234566})  # => Model(@created_at=1970-01-15 06:56:06.0 UTC)
1 Like

What he wants is to subclass Int32 or Time or String so they can be passed as such to every method that expects such types, but also add a few other methods to them specifically for their use case.

I think that’s possible in Ruby, also in Python. Impossible in Crystal because of the static nature.

He could also just reopen Time and add whatever methods he wants.

struct Time
  def my_method
    self + 10.seconds
  end
end

I agree that this shouldn’t be a language feature. In addition to the solutions presented here, this can also be cleaned up with a simple macro like

macro field(assign)
  property {{assign.var}} = {{assign.type}}.new({{assign.value}})
end

record Epoch, seconds : Int64

struct Model
  field created_at : Epoch = 123456
end

Model.new # => Model(@created_at=Epoch(@seconds=123456))

The macro could also try to go to some lengths trying to figure out the literal’s datatype and defining an additional setter overload for it like @straight-shoota demonstrated.

Do I see a benefit for this over just doing

struct Model
  property created_at = Epoch.new 123456
end

Absolutely not.

1 Like

Crystal uses duck typing, too. In gneral you can use any type that implements all methods that are called on it. For unrestricted arguments, this is exactly the same as in Ruby or Python. It obviously won’t work with type restrictions, though, because type restrictions are there to enforce a specific type. And IMO that’s often a good thing, because it’s less error-prone when the using code can be sure that it works with a String for example and not something that quacks like one but might show a different behaviour.