Will Richardson · 20d · discuss
This isn’t really that surprising, but sometimes you’ve just gotta point out the obvious stuff, you know? Anyway, Crystal’s global type inference can be easily manipulated into doing arbitrary computations at compile time. Of course you can do whatever you want with macros, but this doesn’t rely on a single syntax macro, and only lightly uses macro methods for convenience. The usage of macro methods can be removed, which I’ll explain at the end. The rules of the game are that we can’t use the macro keyword, and we can only use {{ }} for macro methods sparingly. For a slight extra challenge, we can’t use new either. Not that it would be very useful—it only does something at run time—but the goal is to write code that will do as little as possible at runtime. Let’s start with Crystal type inference. This function returns a String: def get_message : String return "wow this is such a useful function!" end You can tell by the : String after the method name. Crystal’s main trick is that you can actually omit the type annotations in most places, so this function still returns a String: def get_message return "omg no type annotations!" end In this case, the return type is calculated based off the type of the value that is returned. That’s simple for literals, but it also extends to variables: def get_message(msg : String) return msg end Since msg is annotated as a String, the return type is inferred to also be a String. Where this gets more complicated is if we omit the type annotation on msg: def get_message(msg) return msg end The compiler can’t infer the type of msg or the return type of the function since the whole snippet is missing any type annotations. This function is effectively a simple generic function on its one argument. In a language without global type inference—like Rust—you’d write it like this: fn get_message<T>(msg: T) -> T { return msg; } This doesn’t have any useful type information, but we’ve told the compiler that the return type must match the type of the first argument exactly. If we call it with get_message("Wow nice function!".to_string()) then the return type will be String. Exactly how the generics are handled at runtime depends on a few factors and varies between languages. In a lot of languages—and the model that I think is easiest to understand—you effectively get an autogenerated method with the concrete type substituted in for T. In Crystal, every un-annotated argument acts like a generic type parameter. I can call get_message("omg types!") and a specialisation for String will be generated, or I can call get_message(1234) and a specialisation for Int32 is generated. So if we call get_message with different arguments, the return type will be different: typeof(get_message(123)) # => Int32 typeof(get_message("Hello!")) # => String This isn’t just a method that can return two different types; at compile time we know that the first call will only ever return an Int32, and the second call will only return a String. If the decision to return one type or the other was made at run time, the compile-time type would be Int32 | String. Crystal’s macro system allows you to run arbitrary compile-time code during method expansion. To help ourselves debug, we can make a print method that calls a macro to print the type of its argument: def print(arg : T) forall T {% puts T %} end It doesn’t do anything at runtime, but it lets us see the result of a type-system “computation”. Let’s start with numbers. There’s nothing stopping us from creating a new type that has a number as a type parameter: struct Num(T) end It doesn’t look like much because there’s nothing to say “T must be a number!” We just have to do that ourselves. Using our print “function” from before, we can get numbers: print Num(5).new print Num(9).new Somewhat uninterestingly, this prints Num(5) and then Num(9). Sadly this is breaking our “no new” rule that we agreed on earlier, so we’re going to have to introduce class types. If the return type of get_message is inferred to be String, then what does this return? def get_string String end That’s right, String.class! It’s basically Class<String> from Java land, but spelled weird. The important thing is that we can control the inferred return type without needing .new. This does mean we need to tweak print a little bit. Instead of extracting the type of the parameter into a type variable, we’ll extract the type of the class of the parameter into a type variable. Type type class type parameter infer type type. It makes more sense when you see it: def print(arg : T.class) forall T {% puts T %} end Now print doesn’t accept an argument of type T, it accepts a Class of type T.class. In Java that would be written T print<T>(Class<T> cls). The important thing is that I can call print without actually creating a new instance: print Num(5) print Num(9) That’ll print the same thing as before, but we’ve dropped both new calls. So after all that trouble, we can store numbers in the type system, mostly because Crystal lets us do it already. Well, just watch this, we’re going to add some methods to our type that we can call at compile time. Of all the weird hacks here, I think this is the coolest. We can just write perfectly normal Crystal code with types, overloading, and everything and it’ll all “run” at compile time. Here’s how we can add two numbers together: struct Num(T) def self.+(r : Num(R).class) forall R {% begin %} Num({{ T + R }}) {% end %} end end Num(5) + Num(6) # => Num(11) That begin/end is just to tell the compiler to treat the whole thing as one unit to parse, otherwise it gets confused. This is easy because adding numbers together is something Crystal supports in its type system, as part of the macro language. T and R are type parameters, but they’re actually both constant numbers known at compile time, and we can add them together. So Num(T).+ operates on a Num(T).class and accepts another Num(R).class. Given those parameters, the return type is inferred by the single expression in the method body, which is based on T + R. A separate version of this method will be generated for every used combination of T and R. Each generated method will have the return type inferred from the generated body, which will be known at compile time before the program is run. It’s probably easier to think about this with normal Crystal code: "foo" + "bar" 1 + 2 3 + 4.5 There are three different + methods that will have to be instantiated here. One that operates on two String objects, one that operates on two Int32 values, and one that operates on an Int32 and a Float32. Based on their parameters, they each return a different type (String, Int32, or Float32) all known at compile time. This isn’t the only way to implement numbers—it’s definitely the most convenient—but I’ll get to the other way later. Crystal also makes it really easy to get lists into the type system. There is basically first-class support for it: struct List(*T) end It’s that simple! To make a list, you just build the type: List(Num(1), Num(2), Num(3)) The fact that generic type parameters are written with round brackets really makes this whole endeavour less bizarre-looking. Imagine that in Java-like syntax for a moment: List<Num<1>, Num<2>, Num<3>>. So weird! Just like with Num, we can define methods where the inferred return type gives us useful information: struct List(*T) def self.[](index : Num(I).class) forall I {{ T[I].instance }} end end Again, we’re leaning heavily on Crystal’s ability to introspect on types in the macro system. All we have to do is translate from the types used as parameters to the result we want. We can continue exposing the builtin macro methods to the type resolution system as “methods” like this, just by defining methods on classes. To avoid using the macro system, we could just use linked lists. Type-system linked lists, obviously. class Cons(H, T) end The list has a head (H) and a tail (T). The tail is itself just another Cons list, or Nil if that’s the end of the list. We then just need to define a few helpers to create, concatenate, and destructure lists: def newlist(item : I.class) : Cons(I, Nil).class forall I Cons(I, Nil) end def concat(item : I.class, rest : Cons(H, T).class) : Cons(I, Cons(H, T)).class forall I, H, T Cons(I, Cons(H, T)) end def head(list : Cons(H, T).class) : H.class forall H, T H end def rest(list : Cons(H, T).class) : T.class forall H, T T end Numbers and lists are fine, but eventually we’ll want structured data. We could use NamedTuple, which is the most versatile tool for Crystal type system hacks. It would look something like this: struct Struct(T) end data = Struct(NamedTuple( a: Num(1), b: Num(2) )) Or you could define your own types and use generics as parameters: # N is name # E is extension struct Language(N, E) end When passed as an argument to a function, you can destructure it just like the Cons above. Conditionals are a bit tricky to manage, but conceptually aren’t that complicated. We just define two types—True and False—and then make a method with different overloads depending on the parameter: struct True end struct False end def conditional(c : True.class) # some code if our condition is true end def conditional(c : False.class) # some code if our condition is false end This works because the type system will work out the type of the argument being passed to conditional, and since the type of the argument can only be one of True or False, only one method is actually used by the program. Unused methods aren’t type checked, and so they won’t be “evaluated” as part of our type system program. To implement comparisons between numbers, you just make a macro method: struct Num(T) def self.<(r : Num(R).class) forall R {{ T < R ? True : False }} end end Using a macro method does feel a little bit like cheating, which is a good time to mention that other way of implementing numbers. It works like this: struct One end struct Zero end struct Byte(A, B, C, D, E, F, G, H) end We represent 42 as Byte(Zero, Zero, One, Zero, One, Zero, One, Zero). Arithmetic operations can be done with function overloading. This gets a bit verbose, but as a quick example here’s how you implement AND between two bits: def and(l : One.class, r : One.class) : One.class One end def and(l, r) : Zero.class Zero end Call and with two One objects, and the return type is One.class. Call it with anything else, and it’s a Zero. Repeat that for all the bits in the byte, and you’re most of the way to a CPU. I did try and write a compiler that turns Python code into Crystal types that can be evaluated during the build stage, but the computation model is different enough that it was going to be a bunch of work. It would be better for a purely functional, side-effect-free language. Since the return type of each method is stored, calling the same method with the same types of arguments will always produce the same result. As I said at the beginning, it’s not exactly surprising that you can do so much with the type system. I was more surprised at how easy it was. So next time you’re getting frustrated at a slow build, just remember that it basically has to run your code in order to check your types. Maybe add some type annotations. Permalink • September 25, 2026 • Send feedback via mastodon or email.