CrystalNotes

A friend talked me into trying out the Crystal programming language, and then another friend talked me into starting a little project that Crystal would be good for, so here’s the traditional rambling thoughts on it. Written in September 2024, using Crystal 1.13. Also note that I’ve used almost no statically-typed language besides Rust for like 8 years, so I am very Rust-brained these days.

First impressions

  • Language started in 2014
  • Overarching idea: Strongly-typed Ruby (with lots of type inference)
  • This means Everything Is An Object, which always feels wrong to me these days, but like Ruby it’s a fairly good implementation of the idea. You can have standalone functions and whatever and it works fine.
  • Programs are just compiled to native executables through LLVM. Compilation isn’t instant, but it’s Fast Enough for me so far.
  • No repl that I’ve found, but also no main() function, writing bare statements in a toplevel file executes them from start to end. Sounds like a potential liability to me, with order of file imports changing side effects, but it sure is convenient to learn with.
  • The tutorial also has editable and executable online code blocks, which is an excellent way to play around
  • Has a good range of useful-but-less-common built-in types like sets, symbols/atoms, tuples and closures, so the creator is no fool
  • Reasonably modern package/build manager: shards. Uses yaml for the file format unfortunately, but oh well.
  • Decent project-blessed Debian packages and the usual (bad) curl ... | sudo sh style install script, which I eyeballed and is not at this time a rootkit. Its .deb is hosted on opensuse.org, which feels weird; someday I should actually learn anything about SuSE besides “RPM based, I tried it in 2005 and it didn’t work well on my PC at the time”
  • Tutorial is really minimal, be prepared to just read through the reference once you get through the bare essentials
  • The reference is pretty good though
  • Where the hell are for loops??? Oh they don’t exist, you use 10.each do |x| ... end like Ruby. Sure, why not.
  • Reasonable ecosystem of libs, seemingly mostly decentralized like Go but shards has at least the framework for different package sources, so if someone makes a centralized Crystal package lib and it gets popular it can just be added as another option. Standard lib is more of the maximalist style, has a bunch of handy script-y things in it.
  • Compiles to static-ish binaries with LLVM. So it’s not a scripting language per se, but… scripting-language-coded, I suppose.
  • Was sold to me as having very good FFI and macro metaprogramming, which sounds useful.

Object stuff

  • It splits apart new to allocate memory and initialize to initialize it like Objective C does, which is always nice to see. Usually unnecessary, but nice to see.
  • Class variables are all private, everything is done through accessors. There’s freely-available overloads for operators like =[] for x[y] = z, and more shortcuts (which turn out to be macros) for defining accessors.
  • Does the Ruby annotation thing where foo is local var, @foo is instance var, @@foo is class var, which feels weird but after using it for a while I actually kinda like it.
  • Oooooh all constants start with capital letters… and all types/classes start with capital letters… so all types are constants at runtime. Classy. …No, not that kind of class-y. Dammit that wasn’t supposed to be a pun! Go away!
  • Structs are value-typed classes, like in C#. IMO this is a worse design than just having explicit references, but fine for 2014 when the language was created.
  • There’s a feature called “splat assignment” which just seems like weaksauce pattern matching. Looks useful, but why not just have pattern matching? Splat assignment probably evolved out of Python’s * function argument swizzling operators.
  • Has some nice shortcut syntax for designing your own collections types, so {1 => :foo, 2 => :bar} makes the builtin Hash type, but you can write MyType{1 => :foo, 2 => :bar} and it will desugar to MyType.new(); MyType[1] = :foo; MyType[2] = :bar; Feels a bit like C# there, nice option to have.

Type system stuff

  • You (almost) never need to add type annotations to vars and function args/returns to make the program typecheck, but you always have the option to add them so it typechecks the way you want,, which is good
  • It looks like it does some form of global type inference, which sounds somewhat ad-hoc compared to OCaml-y HM but seems to work fine in practice (so far).
  • The type inference allows unions of types like Typescript does, such as Int | String.
  • Variables are declared by being assigned to, which to me feels a little unnecessarily sloppy but usually works okay in practice. The @ sigils help a bit.
  • Variables can be reassigned with a value of a different type, which is a little bold. So you can do a = 3; a = "hi" and it works. Not sure yet how hazardous this is in practice. I assume it types a as Int32 | String in that case? Yep; if you declare the type of a variable a : Int32 = 3; a = "hi" then it complains that the type of a is Int32 but you’re trying to use it as if it were Int32 | String. Writing a : Int32 | String = 3; a = "hi" works just fine.
  • This is also how uninitialized variables work. Uninitialized vars have type Nil, which has only one value nil, which then interacts with other types like normal. So an uninitialized variable that is later set to a String has type Nil | String.
  • So if you don’t want this to happen, you can always just give a variable a type. And if you don’t do that, but screw something up, it will get caught next time that variable is used with a function that has typed args.
  • That feels very much like Typescript’s type system. Now, TS is a little infamously unsound, which means you can write contradictions in it; invalid programs can typecheck successfully. In practice this is seldom too much of a problem in TS, afaik, but definitely can fuck you up sometimes. I have no idea whether Crystal is unsound or not, since its logic around types and objects and stuff seems different from TS’s approach, which is shaped by needing to interoperate with the abject sadness that is JS. Would be interesting to learn more about it!
  • In general subtyping (such as OO inheritance) and union types (like Int | String) are places where type systems get Hard, and are areas of active research. So having Crystal wandering around this design space is pretty neat from a language creator’s point of view.
  • There are also real generics, haven’t touched them much yet but they seem to do the job?
  • There’s the usual zoo of OO features like private/protected methods, covariance and contravariance, virtual and abstract types, etc. I’m happy to ignore them when possible.

Stdlib stuff

  • Woohoo, batteries! Fun change from Rust. There’s hash functions in there! JSON parser/writer! A smol HTTP server! Bignums! Tempfiles!
  • Oops, looks like its tempfile lib is written in library code in the style of mktemp(3), instead of calling the OS’s mkstemp(3) or equivalent. So it just finds a filename that doesn’t exist and then opens it separately, letting an attacker create a new file which they can read at that location between those two steps. Uh, have fun with your temporary files leading to exploitable race conditions guys.
  • Digging through the Crystal issue tracker for this problem shows multiple attempts to try to make this better done at different times by different people, none of which seem to have been Good Enough. It’s a pretty good example of the downsides of a batteries-included library, tbh. Trying not to break shit is hard work.
  • The stdlib’s OptionParser is hella better than a C/bash argparse-like API but it could be better still. I wish it were more declarative and a little more opinionated, like Rust’s clap or argh.
  • Oh I take it back, there’s no way I can find to just tell OptionParser “this command line flag is necessary”. WTF? It’s basically “wire your own state machine” like Python’s lame-ass argparse. Maybe I should write a new command line parser lib.
  • Oh, there’s already a bunch already written. A disadvantage of Crystal’s Go-style package management where everything is in its own repo, vs. Rust-style where there’s a blessed crates.io or equivalent: it’s hard to know where to go to find packages. There appears to be a decent (if commercial) curated list at https://crystal.libhunt.com/. commander and admiral seem close to what I want, though there’s plenty of others.
  • Annoying as this is, I love that there’s a good language out there that makes decisions different from my habitual Rust/Python/Elixir ecosystem. Tradeoffs are worth exploring.

Okay I hate writing this ’cause I’ve been having fun up until now, but the stdlib honestly needs some help. I had this code:

testdir = Dir.new(testdir_path)
LANGUAGES.each do |name, lang|
  Dir.cd(testdir_path)
  # Iterate through subdirs
  testdir.each_child do |subdir|
    puts "Processing stuff in #{subdir}"
    do_stuff(testdir_path / subdir)
  end
end

First off, Dir#each_child() returns a String, not a Path or a Dir or File object. Okay fine, paths are fucking cursed no matter what. But it turns out that the Dir object is an iterator, instead of what I expected, which was Dir#each_child creating an iterator. So for the first run of the outer loop it would iterate through the subdirectories of testdir_path, and do_stuff() in each one. Then for the next run of the outer loop it calls testdir.each_child() again… which is already at the end of the iterator, so it just bloody runs zero times. Want to iterate through the directory again? Gotta call testdir.rewind() first. Is this in the docs? Only if you look at the rewind() method and understand that this is a possibility; otherwise you just gotta figure it out the hard way like I did. Apparently this (like a lot of the rest of the Crystal stdlib) is inherited from Ruby, but that doesn’t mean it’s a good idea.

THIS is why you need an immutable-first language with move semantics and borrowing, dammit! People ask me what Rust is good for if you are happy to have a GC? Shit like this, that’s what.

Fine, Crystal isn’t that language, but still. But for as good as Crystal itself is, it deserves to have the stdlib that doesn’t result in me tripping across three separate footguns while writing what is, in the end, a 300-line “I didn’t want to write a shell script” program. That’s not great. Asking a good lang designer to also be a good stdlib designer is a pretty tall order, but fortunately lib improvements are the sort of work that can be done by a community more easily than core lang design. Someone with intimate knowledge of the Rust stdlib and all the horrible footguns its incredibly labyrinthine design tries to avoid, and the intestinal fortitude to rewrite major parts of a stdlib, please help Crystal out.

Other random bits

  • You can make functions with named args and call them like some_method 10, w: 1, y: 2, z: 3. Heh, more Objective C lineage – I don’t think Ruby does that? Oops it does; I think I last touched Ruby in like 2012. Either way, classy. Feels like how Elixir writes DSL’s out of functions, but in a good way. Probably not a coincidence, since they’re both Ruby-ish syntax.
  • Yep, you can use the “splat operator” in function args and it’s exactly like Python’s arg swizzle operators. Always a nice feature for a dynamic-ish language, and one of the fun things that is just really fucking annoying to do sensibly in a For Realsies Static language like Rust. Even when it’s statically typed and compiled to native code, being able to have the language say “yeah we just use lots of dynamic dispatch and/or reflection here, it’s fine” is pretty convenient at times.
  • Oh shit, there’s no sum types! Heck, no wonder this feels weird to write in. There’s enums, which is nice, but they are very explicitly limited to integers and intended for flags and stuff. If you really wanted Rust-style sum types you could fake them easily enough, but that always Feels Bad. No real pattern matching either, so you it’s more annoying to use the “tuple of symbol + value” style of sum types that’s ubiquitous in Elixir or Erlang.
  • Modules are first-class values, huh. And have some relation to classes. Again I am reminded of one of my more mind-bending moments while learning Ruby, which was reading something along the lines of “the Module class of Module is a subclass of the Class class of Class.” Good times.
  • The crystal binary comes with some handy tools built in, with the command crystal tool. There’s fairly mundane things like a formatter and a macro-expander, and also slightly more uncommon but interesting bits like something that prints out the full class tree for a program, or shows the implementations possible for an overloaded method call.
  • The require file import statement is mostly file-based, vs Rust or Elixir’s more abstract module tree knowing where it expects to find files. There’s still some default search paths that result in a particular file layout in a multi-file project, but it seems to be more of a suggestion than a rule. Files also all share the same namespace by default, if you want nested namespaces you just make the file foo/bar.cr contain your code inside module Foo::Bar ... end . Feels a little oldschool compared to more abstract systems, like the concept is “C includes done properly”, but it seems to work fine. Meshes decently with the scripting-language vibe, you can just kinda throw files together if you want to.
  • There’s some auto-casting of numerical types, but it’s very conservative compared to say C or Python 2. It only can occur in function args or class initialization as well, which is an interesting choice.
  • Error handling is fairly mundane exceptions, so far.
  • Crystal’s tools for dealing with Nil are kinda interesting. The type String? is a shortcut for String | Nil. Nil is falsey, but has a little syntactic sugar to it: if you have a value x of type String? you can write if x do_stuff_with(x) end and the x inside the if block is of type String. It’s smart enough to do the opposite too; if you write if !x do_stuff(x) else do_other_stuff(x) end then inside the “if” part x is type Nil, and inside the “else” part x is type String. So I guess it’s a case/pattern match on the type, really, but a very handy one. Very alien to my brain used to Rust’s Option’s; Crystal once again does something that feels like how you’d write stuff in a dynamic language, but makes it type safe.
  • The shortcuts for properties in class constructors are very convenient. Rust could learn some things there, tbh.

Things to look at later

  • Reflection
  • FFI
  • Error handling
  • Macros
  • Generics (in more depth)
  • Can you magically return/break out of iterator functions like you can in Ruby?
  • Threads/fibers?

Conclusions

I still think the world really needs a solid, immutable-first and functional-first scripting/glue language, and Crystal isn’t that. But it is a solid, well-considered OO language with a static type system that feels as low-friction as a dynamic one. So give Crystal a go next time you’re sick of writing your bajillion’th Python/Ruby/JS script. It steals lots of stuff from Ruby, but the stuff it adds is very solid so far.