So I've been diving into Crystal lately. It's nice. This isn't a post about why I think it's nice, it's a post about a few of the features of Crystal so let’s get straight down to it.
alias
alias
is a very common feature that we can see across many languages but it can be used to build upon other features in Crystal. As the name suggests you use alias
to give a type a different name.
alias TableOfContents = Hash(String, Int32)
In the example above we give the type Hash(String, Int32)
a custom alias of TableOfContents
which allows us to refer to this type as TableOfContents
def lookup(chapter : String, tableOfContents : TableOfContents) : Int32
tableOfContents.fetch(chapter)
end
So instead of using Hash(String, String)
in the method signature above we can use TableOfContents
. So why is this useful? Well for one thing it allows us to reduce complex type names into smaller names. The example above isn't exactly verbose but longer types can be more readable.
Now of course size isn't everything so another advantage of alias
is that we can add meaning to types within our problem domain. You're less likely to wonder what this hash of string to string is or should contain when we give a name like TableOfContents
. In fact, we could enrich the signature above more by giving specific types an alias.
alias ChapterTitle = String
alias PageNumber = Int32
alias TableOfContents = Hash(ChapterTitle, PageNumber)
From this we can infer that for a given chapter title the table of contents is capable of finding the page number that the chapter starts on.
union
Unlike many languages the return type of methods can actually be more than one type. Crystal uses type inference so declaring types is not mandatory. Take a look at this,
def doAThing(isAThing : Bool)
if isAThing
"I did a thing"
else
42
end
end
What type is this? It could be an Int32
it could be a String
. In other statically typed languages the type most likely resolves to some common base class like Object
or Any
which makes sense but is actually a tiny bit pants1.
In Crystal this type will be inferred as String|Int32
. This is a union and is essentially a type of its own that could be either a String
or a Int32
. We can explicitly give this method a return type which means the compiler can verify our assumption
def doAThing(isAThing : Bool) : String|Bool
You may have noticed I just wrote the wrong return type and attempting to compile the code results in a helpful compilation error message
Error in ./src/something.cr:14: instantiating 'doAThing(Bool)'
doAThing(false)
^
in ./src/something.cr:6: type must be (String | Bool), not (String | Int32)
def doAThing(t : Bool) : String|Bool
^
Union types give us the rigidity and predictability of types while, at the same time, allowing a certain amount of flexibility as if we were working in a purely dynamic language.
case
So we've got these nice union
types how do we deal with them in code? Well similar to other languages Crystal has methods like is_a?(type_name)
and responds_to?(method_name)
that allows us to inspect the current type of the variable but it also supports a kind of pattern matching for types using case
.
def readFile(filename : String) : String|Error
# read file return a string of its contents
rescue an_error
an_error
end
maybeContents = readFile("somefile.txt")
case maybeContents
when String
puts maybeContents
when Error
puts "We had a wee error doing that"
end
In each when
of the case
the type of maybeContents
will be the specific type that was matched against. So maybeContents
will start as a String|Error
but be available as a String
in the first when
and an Error
in the second when
.
1: "pants" means bad.