DEV Community

Cover image for Doing Crystal #3: Types, types, types
Chris Watson
Chris Watson

Posted on • Updated on

Doing Crystal #3: Types, types, types

Welcome to the third post in my Doing Crystal series. In this post I'll be focusing on Crystal's type system, it's benefits, and the drawbacks. If you haven't read the other articles in this series you can find them here, and here for posts one and two respectively.

Static vs Dynamic Typing

Static typing has existed in programming for years, going back as far as FORTRAN which was first released back in 1957. Now in truth all programming languages are typed, even languages such as JavaScript and Ruby have types such as Integers, Arrays, and Strings. This is necessary because in the real world things have specific properties which are unique to that thing. Numbers and strings are inherently different and, even though you could technically add a number to a string by taking the string down to its binary representation and adding the number to that, in real world terms it just doesn't make sense. Hence, we have types.

The difference is in how the types are handled. With languages like JavaScript and Ruby types are handled dynamically. This allows you to do things like the following Ruby example:

def first_and_last(arr)
  if arr.length > 0
    return [arr.first, arr.last]
  end

  []
end

puts first_and_last(["Hello", "fellow", "developers"])
# => ["Hello", "developers"]

puts first_and_last(42)
# => NoMethodError (undefined method `length' for 42:Integer)
Enter fullscreen mode Exit fullscreen mode

As you can see, the method first_and_last is supposed to accept an Array and return the first and last elements as a new Array. It works fine when handed the type of data it expects, but when handed a number it throws a runtime error NoMethodError. Dynamic typing can be extremely handy, but it can also be detrimental as a large number of runtime errors (or errors that occur while your program is running as opposed to when it's compiled) occur when the program attempts to use a method that doesn't exist, or when a variable's type changes unexpectedly.

Now let's look at the same code from before, but in Crystal.

def first_and_last(arr : Array(U)) forall U
  if arr.size > 0
    return [arr.first, arr.last]
  end

  [] of U
end

puts first_and_last(["Hello", "fellow", "developers"])

puts first_and_last(42)
Enter fullscreen mode Exit fullscreen mode

For this one I didn't show an output. Why? Because the program won't compile. Let's go over the program line by line, and then I'll explain why the compilation fails.

def first_and_last(arr : Array(U)) forall U
Enter fullscreen mode Exit fullscreen mode

First we do a method definition. This is similar to the Ruby example, but there a a couple of important differences. First we have the weird arr : Array(U) syntax. This is setting the property name to arr and the type of arr to an Array of type U. U in this case is a placeholder type, and outside the scope of this tutorial, but suffice it to say it allows U to be anything. The forall U part at the end creates the U generic.

if arr.size > 0
  return [arr.first, arr.last]
end
Enter fullscreen mode Exit fullscreen mode

This is exactly the same as the Ruby example save the slight method name change for getting the size of an array. In Ruby it's length and in Crystal it's size.

[] of U
Enter fullscreen mode Exit fullscreen mode

In Crystal all things have a specific type. This prevents runtime errors and lowers memory usage since the program can allocate the resources it knows it needs. As such, all Arrays, Hashes, Sets, etc. have to be explicitly typed and that is what this line is doing. If the Array the method is handed doesn't have anything in it we just return an empty Array. Truthfully we probably should've returned the same array we were given, but I wanted to show an example of assigning an Array a type.

puts first_and_last(["Hello", "fellow", "developers"])
Enter fullscreen mode Exit fullscreen mode

This line will work as expected and return ["Hello", "developers"].

puts first_and_last(42)
Enter fullscreen mode Exit fullscreen mode

This is the line that breaks things. Our method expects an Array, but instead we handed it an Int32. Because of this breach of contract the compiler throws an exception.

no overload matches 'first_and_last' with type Int32
Overloads are:
 - first_and_last(arr : Array(U))

  first_and_last(42)
  ^~~~~~~~~~~~~~
Enter fullscreen mode Exit fullscreen mode

Yes, it's still an error, but this time it's happening before you release your code. Catching bugs early is a wonderful thing.

Type Declaration in Crystal

You've seen a little of how types are declared in Crystal, now let's look at some more examples. Types are almost always declared in the following way:

# var_name : Type
arr  : Array(Int32)         = [1, 2, 3]
str  : String               = "Hello, world!"
int  : Int32                = 42
hash : Hash(String, String) = {"user" => "watzon"}
Enter fullscreen mode Exit fullscreen mode

Like with Ruby, everything in Crystal is an Object and all Objects are valid Types, so custom classes, structs, etc. are also valid types. Now, there is another way to declare a type when it comes to Hashes and Arrays.

arr : Int32
arr = [] of Int32            # Assigning type declaration

hsh : Hash(String, String)
hsh = {} of String => String # Assigning type declaration
Enter fullscreen mode Exit fullscreen mode

With most types (Int, String, Class) you can just assign the object to a variable without explicitly declaring the type. This is called type inference and it's extremely handy. You can do the same with Arrays and Hashes as well, provided they contain data, but if they're empty as in the previous example you will have to explicitly declare the type of the Array or Hash.

Type Inference

Type inference is a handy feature that almost gives the appearance of dynamic typing... sometimes. Let's use our first example again.

def first_and_last(arr : Array(U)) forall U
  if arr.size > 0
    return [arr.first, arr.last]
  end

  [] of U
end
Enter fullscreen mode Exit fullscreen mode

Because we explicitly declare that the parameter arr is an Array we know that that parameter will have several helpful methods that allow us to act on the data stored in the Array. Array, however, is not the only class to contain many of those methods. There are other enumerable classes in Crystal that have #size, #first, and #last methods such as Set and Deque. Currently though, you could not use any of those classes in our first_and_last method. You could of course do this:

def first_and_last(arr : Indexable(U)) forall U
  if arr.size > 0
    return [arr.first, arr.last]
  end

  [] of U
end
Enter fullscreen mode Exit fullscreen mode

Now any class that includes the Indexable module can be passed into first_and_last, but there is an easier, albeit less explicit way to handle things.

def first_and_last(arr)
  if arr.size > 0
    return [arr.first, arr.last]
  end

  arr
end
Enter fullscreen mode Exit fullscreen mode

But wait, where did the types go? They're still there don't worry, but now instead of you having to explicitly declare the type of the parameter arr the compiler will infer the type based on what operations you perform on it. There are several classes that have #first, #last, and #size methods, and now all of them are valid inputs.

Union Types

One very powerful aspect of Crystal's type system is the ability to create type "unions". Here is an example of a union type.

arr = [] of Int32 | String
Enter fullscreen mode Exit fullscreen mode

The pipe | operator creates a union between two types, allowing you to use either both Int32 and String types in that array. How awesome is that? Union types can also be generated dynamically by the compiler.

arr = ["Age", 32]
Enter fullscreen mode Exit fullscreen mode

The variable arr in this case would be assigned the type Array(String, Int32) by the compiler. This also, of course, means that any operations performed on the data in the array have to check the type of the item before doing anything, unless they are doing something that is applicable to both types. For example:

arr = ["Age", 32]
arr.map { |a| a.chars }
Enter fullscreen mode Exit fullscreen mode

chars is a method that exists on the String class and returns an array of all the characters in the String. The method does not, however, exist on the Int32 class. Because of that this code will not compile. Instead you'd have to do something like the following:

arr = ["Age", 32]
arr.map { |a| a.chars if a.is_a?(String) }
Enter fullscreen mode Exit fullscreen mode

Things can definitely get messy when dealing with unions, so keep that in mind.

Conclusion

Crystal's type system is very powerful and I've really only scratched the surface. If you want to learn more about it I'd suggest looking at the Crystal reference.

Please don’t forget to hit one of the the Ego Booster buttons (personally I like the unicorn), and if you feel so inclined share this to social media. If you share to twitter be sure to tag me at @_watzon.

Some helpful links:
https://crystal-lang.org/
https://github.com/kostya/benchmarks
https://github.com/kostya/crystal-benchmarks-game
https://github.com/crystal-lang/crystal

Find me online:
https://medium.com/@watzon
https://twitter.com/_watzon
https://github.com/watzon
https://watzon.tech

Latest comments (2)

Collapse
 
megatux profile image
Cristian Molina

Nice explanations. Crystal looks very cool. Keep the great posts!

Collapse
 
watzon profile image
Chris Watson

Thanks for reading!