I recently implemented a Brainfuck interpreter in the Crystal programming language1 and I’d like to share my honest opinion about working in the language. When looking at a new programming language I am interested in these things in no particular order
What it does differently
It’s cross platform story
How to generate documentation and tests
Its package manager
Where its community resides
Its debuggability
Before I get into the points let’s look at the briefest of histories on Crystal. Crystal is a programing language that was started in mid 2011 at Manas Technology Solutions2. Manas is an Argentinian consulting company that worked primarily in Ruby on Rails. Crystal was born out of a desire to have a Ruby like language that was performant. Its original tagline was “Fast as C, Slick as Ruby”. It had its 1.0 release in March of 20213 after 10 years of development and as of April 2023 it is on version 1.8.1. It’s at a critical stage in its life cycle where its stable enough for mass adoption, and old enough to have developed a decent community. So let’s see how it stacks up as a programming language.
What does Crystal do Differently? 🤔
Starting off with #1, this is usually the hardest point to prove to skeptical programmers. While an experienced programmer can learn new languages quickly, it’s still a burden, so there has to be a large enough benefit to warrant the switch. The context also matters, for personal hobby projects the barrier of entry is much lower than if it were to be used as part of the tech stack for a company. One benefit of Crystal is its aim to be a batteries included language. It’s a great idea, just provide lots of functionality in the standard library. This has lots of benefits. A more cohesive ecosystem where most people use the stdlib to perform most tasks, battle tested libraries, reducing dependency hell, etc. But it also has some disadvantages. A large stdlib makes it more difficult for your language to be cross platform, more on this later. The velocity of updates in a package in the standard library is also much slower than if it existed outside of it. Kenneth Reitz’s famous quote sums this up…
The standard library is where modules go to die
For a language like Python with a gigantic community, they can handle the large standard library, but for smaller languages it can be a challenge. Racket has an impressive standard library compared to the size of its community though4, so it’s definitely possible to do even when you’re small. Crystal’s standard library has lots of useful features for a developer in the 2020’s. TCP sockets, JSON/XML/YAML parsing, UUID, OAuth modules and many more. If they can keep making updates to the standard library at a consistent rate, this rich system of modules will be a huge benefit to Crystal developers.
A very unique advantage of Crystal is that is tries to stick to Ruby where it can. From the syntax to the program behavior, it will be very familiar to a Rubyist. Indeed, one of the guiding ideas in the creation of the language was “What if we just made a statically typed fast Ruby?”. Because of this, documentation exists just to onboard Ruby developers like Crystal for Rubyists5 and many Crystal libraries try to mimic the behavior of popular Ruby libraries like Crystal’s testing framework Spec vs Ruby’s Rspec. Even better there are snippets of Ruby code that will run unmodified with the Crystal compiler due to its type inference abilities, though that isn’t a goal of the language. Ruby is also a very expressive language, and due to its heritage, Crystal is as well. This means there’s lots of syntactic sugar, and small conveniences that make repetitive tasks easier. While this does make learning a language more difficult in the short term, it pays off in the years a programmer usually spends learning and investing in a language. Zig is a great example of the opposite of this idea. It focuses more on clarity of code and avoiding hidden control flow at the expense of expressiveness, and occasional verbosity. But it’s trying to be a better C so can’t have a large footprint. Like many things it’s a tradeoff.
According to the Stack Overflow Developers Survey6 Ruby ranks 16/42 in popular technologies used by professional programmers, and Rails is 14/25 in the Web Frameworks category. That’s still a lot of people but it is declining. By targeting Ruby programmers who are leaving the language for performance reasons, Crystal occupies a unique niche amongst programming languages.
Another way Crystal tries to differentiate itself is with its type system. This was honestly the most compelling argument for me. I remember when I first learned about type inference, one of my first thoughts was, why can’t we just create a statically typed language that does type inference for everything. The compiler would still have to figure out the types at compile time anyway, and I’d save myself a few keystrokes.
Turns out this is what Crystal does. It does aggressive type inferencing, and only asks you to declare types when it can’t figure it out. You can also decide to put the types in anyway, especially if you want to restrict a function or variable to only using a certain type. If you don’t add types to a function, the compiler will make alternately typed versions of your function during compile time as you call the function with different types, so long as the operations inside the function support the new type. For example, this code works for both strings and numbers since both strings and numbers understand ‘+’
def double(val)
val + val
end
val_1 = double("A String ")
val_2 = double(2)
puts "Value one is: '#{val_1}'"
puts "Value two is: '#{val_2}'"
The output of the code looks like this…
You can always restrict a function to a single type or a union type like Integer which is a union of all integer types, if you want more specificity. You can also restrict just the return type, which in our new example will prevent double from being called with a string as the returned value is a concatenated string not an int.
def double(val) : Int
val + val
end
val_1 = double("A String ")
val_2 = double(2)
puts "Value one is: '#{val_1}'"
puts "Value two is: '#{val_2}'"
Crystal does not have a global type inference engine so there are situations where even if the type could be inferred by the compiler it must be annotated. This comes down mostly to type analysis being too costly to perform in every possible case. This can get a little confusing when you are a beginner to the language, as sometimes you will run into those edge cases and be unsure why it’s needed, but Crystal does it’s best to limit those situations. Crystal does have longer compile times than I’m used to in other languages, and I believe that having to aggressively type inference everything contributes to that, but for the programs at the size I normally write which are under 5kloc I’ve been able to live with the annoyance.
So, Crystal’s type inference engine allows you to avoid writing types. You can restrict functions and variables to certain types if you need to, to increase clarity, and to improve documentation. But if you are still in the prototyping phase why not let the compiler infer everything for you? This has worked well for me.
Finally, Crystal has nullable types. On the website it says…
All types are non-nilable in Crystal, and nilable variables are represented as a union between the type and nil. As a consequence, the compiler will automatically check for null references in compile time, helping prevent the dreadful billion-dollar mistake.
I’ve never worked with nill/nullable types before, so it was an adjustment. It forces you to handle cases where a value can be nil right then and there, which while annoying, I appreciate. This just makes a language safer, which is more important today than it has ever been. An example of this is when I handle the ‘,’ operator in my Brainfuck interpreter. gets
handles user input in Crystal, but this could be a nil value because the user could type Ctrl +d/c instead of a number. This means I’d be trying to call input.chomp
(which removes the new line in user input) on a null value which would fail. Crystal won’t compile until I perform a check on input with input.nil?
It’s able to deduce that if we make it to the else branch in my code, input can’t be nil so chomp
will be safe to perform.
when ','
loop do
print "Enter a number between 0 and 255: "
input = gets
# check for Ctrl + d/c
if input.nil?
exit
else
# check that user entered a number
if input.chomp =~ /^(25[0-5]|2[0-4]\d|1\d{2}|[1-9]\d|\d)$/
num = input.chomp.to_u8
@mem[@sp] = num
break
else
puts "Please Enter a number between 0 and 255. #{input.chomp} is not acceptable"
end
end
end
I’m just glad that I can offload that part of my brain that would have to think about that to the compiler.
The cross platform story 📕
In regard to Crystal's cross platform capabilities the story for Crystal is that it’s pretty good.
It has support for macOS and a variety of Linux distributions, with Windows in preview. Windows not being fully supported this far into the projects 1.0 lifecycle initially put me off to the language. But looking at the most recent release7 we can see that improvements to the Windows side keep coming. The Github project8 tracking the Windows support is showing a smaller number of cards each release, and all the major milestones have been reached9. My Brainfuck project is written entirely in Crystal 1.7.3 as a command line program, uses the shards build system, and leverages Raylib 4.5.0 bindings and I've run into no issues. The developers are currently doing a funding push10 to get Windows support to Tier 1 as well. Hopefully that means the Windows preview period will be ending shortly, but no firm date has been set.
The Documentation 📚
Documentation and tests are both important parts of a project. Are they perfect in Crystal? No. But Crystal makes it easy, and a feature that is easy for a programmer to reach for is always going to be the one that ends up actually used.
As you can see Crystal uses markdown in its comments to create documentation. It treats every comment that doesn’t have a newline between the comment and the code as part of that piece of codes documentation. Once generated, the docs look like this.
Not the flashiest, but at least we have some syntax highlighting, and code blocks.
The test library that comes built in with Crystal is called Spec11. Since Crystal shares heritage with Ruby it's inspired by Ruby's test framework Rspec.
Not much more to say other than it works like you expect, and can be run through the shards build system.
As an aside, I love how easy Crystal makes it to colorize output in the terminal. Leveraging its batteries included nature, it has a colorize module in its standard library, and by calling the colorize method on a string with the desired color, you can draw attention to specific outputs in your program to the user. I use this feature in my Brainfuck interpreter to draw attention to invalid command line arguments.
parser.invalid_option do |option_flag|
STDERR.puts "#{"ERROR:".colorize(:red)} #{option_flag} is not a valid option."
STDERR.puts parser
exit(1)
For documentation around learning the language these are the ones I’ve used so far.
I’ve mostly stuck with the free resources provided by the Crystal website, but I’ve seen blog posts on various aspects of Crystal that could be helpful as well. Since I’m used to learning languages with small community, I can usually find my way through a language reference. If you are very new to programming, you might find the small number of resources difficult to learn, but thankfully Crystal has a great community that is patient with questions. The Crystal book is a recent addition to my learning, and from what I’ve read so far has been a great resource, especially if you need more structure when learning, but might be cost prohibitive for some.
Package Managers 📦
#4 on my list is about package managers. Continuing with the gem theme Crystal uses shards12 to manage its dependencies and it supports any git repository no matter where it's hosted. It also has support for Fossil and Mercurial. This is a solution I've seen more and more with new languages. It's a lot of work to maintain the infrastructure needed for a new programming language, and offloading this feature makes perfect sense. Getting a package manager early in a language's life avoids the C++ situation where you have 9 package managers.
bpt
cpm
conan
poac
pacm
spack
buckaroo
hunter
vcpkg
This is not C++s fault as it is a very old language and these ideas weren’t common at the time, but better to avoid it all together if you can.
There are definitely problems that can arise from leveraging code hosting services for your ecosystem, especially if they decide this is against the TOS, or if one of the websites are blocked in your country, so I’d still like to see a central repository in the future. But a benefit now is that the package manager is flexible, and packages are distributed across multiple platforms, so if one goes down the entire ecosystem isn’t disrupted. This also gives programmers the flexibility to decide where they host their code which is always good.
Shards has most of the tools you would expect it to have for managing a project.
shards build
: Builds an executableshards check
: Verifies dependencies are installedshards init
: Generates a newshard.yml
shards install
: Resolves and installs dependenciesshards list
: Lists installed dependenciesshards prune
: Removes unused dependenciesshards update
: Resolves and updates dependenciesshards version
: Shows version of a shard
On top of all that, you can create a project with the Crystal compiler using…
crystal init app/lib my_app
Which unifies how this is done across the ecosystem. This means no matter who created the project you know how it will be structured, and how it can be built. Once created, apps can be built with shards build
and executed with shards run
. One nitpick on this point. When I’m in my_app
, I can’t do crystal build/run
to build or run my project, but I can do shards build/run.
But typing crystal docs
builds my project documentation, while shards docs
does not.
Also shards spec
does not run my test code, but crystal spec
does. In my opinion, when working with an app you created you should be able to manage it entirely with shards build/spec/docs/run
all from the root my_app/ directory and it should just work™.
The Community 🏠
I don’t know how controversial this is but #5, is very important to me. I’ve used a lot of old programming languages and I am not a fan of asking questions on freenode/libera chat/mailing list. I’m not saying to use Discord and I understand why people don’t. I get the Foss argument believe me, I was a card carrying member of the FSF for years and a 3-time Libre planet attender. But those forms of communication are intimidating, confusing, and put off a lot of new, young, and inexperienced programmers who might be interested in your language. I’ve used Programming languages whose community exclusively exists on Discord and who leverage Discord forums for a more structured searchable question asking experience, but I really think a good standalone forum beats that. Using Racket as an example again, they transitioned from mailing lists to a Discourse forum13 and use Discord, and it has worked very well for them. I'm happy to say to that Crystal also has a Discourse forum14, unofficial Discord, and Gitter all on their community page15 and I've had positive experiences in all of them.
Debuggability 🐛
Debuggability is one area where I had a hard time in Crystal. It compiles using LLVM so you can debug using LLDB but getting that set up is not as easy as I thought it would be on Windows. If you are on Linux or Windows LLVM does not come pre-compiled on those platforms. If you are a beginner programmer, it will be frustrating to stop everything you are doing and get that set up when you run into your first major bug. Crystal does have an interpreter16 but, at least if you download the Windows preview, it does not come preinstalled.
Compiling from source to get the interpreter is never fun, and the interpreter is behind a flag. Because of this and the hell of a time I had trying to get LLVM on my Windows computer (why is it such a terrible platform to develop on?) I am stuck print debugging 😭
A feature I recently learned about (which is also not installed by default in the Windows preview) is the playground. Since I have Crystal on WSL as well, I can fire it up there and go to my browser at http://127.0.0.1:8080/ to test out some Crystal snippets. As a beginner to the language this very cool! It shows the code on one side and the types and output on the other. I'd like to point out the formatter button on the bottom of the page (very handy!) and the show output button when you are just interested in the output of the objects and not the type information.
So, Crystal gets some points for having multiple options for debugging and playing around with code snippets, but I couldn’t get them working for me so your mileage may vary. The best debugger I have personally worked with is probably the one for Pharo. I think every language should aspire to get as close of an experience as they can to that. Regardless I hope this portion of the language continues to get better.
Closing Thoughts
Overall, my experience with Crystal has been a positive one. The language is expressive, it feels dynamic even though it is statically typed, and the compiler works very hard to catch my mistakes. Anecdotally, I was able to pick up the language faster than normal, but if you’ve been around here for a while, you know I look at lots of languages so that helps. I’ve got a few more projects lined up for the language, so I’m not done with it yet, and I’m sure I have a lot more to learn.
If you use the Crystal language or believe in its mission, I encourage you to consider a donation. Creating a new programming language or large open source project is hard work, especially without a Google, Mozilla, or Microsoft backing you. See Andrew Kelly’s (creator of Zig) post Why I'm donating $150/month (10% of my income) to the musl libc project17 to see why.
If you can’t give financially, but are eager to contribute to an open source project, Crystal is also a good choice. Not only does it have a friendly community, but its compiler is also written in itself. This makes it self-hosted and is awesome if you want to contribute to the language without learning C.
Lastly, a bit of unsolicited advice to younger programmers. It’s essential for a programmer to have a go to project when learning a new programming language. This project is one that is larger than a Fibonacci example, and ideally touches various aspects of the standard library. You should know it well enough that the difficulty is in the implementation in the new programming language, not the project itself. The size should be just large enough that it takes less than a day to get something up and working (but not necessarily the whole thing), lest you get frustrated and bored. For me that project has been a Brainfuck interpreter. For other's it's a Prime Sieve18 If you’d like to see my version written in Crystal, you can see it here.19
Call To Action 📣
If you made it this far thanks for reading! If you are new welcome! I like to talk about technology, niche programming languages, AI, and low-level coding. I’ve recently started a Twitter and would love for you to check it out. I also have a Mastodon if that is more your jam. If you liked the article, consider liking and subscribing. And if you haven’t why not check out another article of mine! Thank you for your valuable time.