An introduction to the Crystal standard library and its core modules

An introduction to the Crystal standard library and its core modules

Dec 7, 2022

#crystal#programming

Crystal is a modern, high-performance programming language that combines the expressiveness of Ruby with the efficiency of low-level languages like C. One of the key features of Crystal is its rich and comprehensive standard library, which provides a wide range of tools and modules for common tasks and operations. In this blog post, I’ll introduce the Crystal standard library and highlight some of its core modules and functions. Whether you’re new to Crystal or an experienced user, this article will give you a better understanding of the power and flexibility of the language.

One of the greatest strengths of Crystal lies in its standard library, or the modules, classes, and functions that come baked in to the language. From the beginning, Crystal has pulled inspiration from Ruby. This can be seen in the syntax most obviously, but it’s also very apparent in its standard library. Glancing through the API docs you’ll see a lot of familiar modules (assuming you’re familiar with Ruby that is). While there are way too many modules to go over individually, I want to highlight some of the ones that stand out the most to me when coming from other languages.

HTTP

One of Crystal’s biggest strengths is its ability to bridge the gap between dynamic languages like Ruby and Python, and typed languages like Rust and C when it comes to web development. The reason this is possible is because of its amazing HTTP module, which comes with a Client for making requests, and a Server for receiving them. As a matter of fact, the home page of Crystal’s website shows a very simple HTTP server in action, and it really could not be simpler.

# A very basic HTTP server
require "http/server"

server = HTTP::Server.new do |context|
  context.response.content_type = "text/plain"
  context.response.print "Hello world, got #{context.request.path}!"
end

puts "Listening on http://127.0.0.1:8080"
server.listen(8080)

Running that code will leave you with a very basic server running on port 8080 which will return “Hello world, got [some path]!” for every path you hit. Of course, using the built-in HTTP server isn’t the only way to use Crystal for a website. There are a myriad of options from Kemal and Grip which are both very Sinatra/Flask like, to Lucky which is closer to Rails (not at all in design, but it takes the batteries included approach).

Of course, the HTTP module would be nothing without

IO

Some may see this as one of the more boring classes, but I have used IO so much in my work with Crystal that I’d be remiss if I didn’t give it a shoutout here. IO makes it possible to deal with streams of data, whether those are coming from a TCP connection, a parser, or something else. As the name implies, IO deals with input/output and is used by other classes within the standard library such as File and Socket.

IO::Memory can be used as a sort of in-memory file descriptor, but what I’ve found to be one of the most useful (albeit niche) parts of IO is IO::ByteFormat which allows you to encode and decode integers to/from Bytes and IO. This is extremely useful when implementing things such as protocol buffers and RPC. And look how easy it is:

io = IO::Memory.new
io.write_bytes(0x1234_i16, IO::ByteFormat::LittleEndian)
io.to_slice # => Bytes[0x34, 0x12]

int16 = io.read_bytes(Int16, IO::ByteFormat::LittleEndian)
int16 # => 0x1234_i16

A bit more verbose than I’d prefer, but you can’t argue with results.

JSON

What would a web-centric language be without support for JSON. Now I’m not saying that Crystal is intentionally web-centric, but it is filling a hole that Ruby leaves by being slow as molasses, and Ruby is used heavily for web development.

Working with JSON in statically typed languages can be a massive pain, because JSON is, by its very nature, untyped and kind of unsafe to deal with. Before finding Crystal I loved the way Go handled JSON (de)serialization. As an example for those unfamiliar:

type User struct {
    Name        string `json:"full_name"`
    Age         int    `json:"age,omitempty"`
    Active      bool   `json:"-"`
    lastLoginAt string
}

As you can see Go uses “tags” to change how the JSON data is transformed when it’s marshaled into the User struct. In this example the JSON key full_name will become Name, age will become Age and an empty value will be discarded, Active will be removed entirely thanks to the "-" and lastLoginAt will be read in as is.

Now I’ll show a similar example using Crystal:

struct User
  include JSON::Serializable

  @[JSON::Field(key: "full_name")]
  getter name : String

  getter age : Int32?

  @[JSON::Field(ignore: true)]
  getter active : Bool = false

  @[JSON::Field(key: "lastLoginAt")]
  getter last_login_at : String
end

Things are a bit different here, partially because different assumptions have to be made. The first field, name, is doing the same thing; we’re taking the full_name JSON field and calling it name in this struct. age is defined as a nilable field, and will be nil by default if the age property doesn’t show up in the JSON. active is being ignored, so we set a default value for it to keep the compiler from yelling at us. Lastly we’re going to rename lastLoginAt to last_login_at to keep things in line with Crystal conventions.

Parsing an incoming JSON object as a User would then be as simple as:

user = User.from_json(json_string)

And assuming the JSON matches the schema, you’ll be left with a User object to work with.

There is also a YAML module which functions almost identically, and I’m working on a TOML shard right now which I intend to have the same API to keep things smooth.

Time

I’m going to end with Time, because time is another one of those things that’s typically a pain in the butt to work with, but Crystal makes it easy. For anyone coming from Ruby, none of this will be new to you, other than the fact that Crystal just has a Time module, no Date and no DateTime. This drastically simplifies things.

One thing that’s important to note is that Crystal does use an Int64 for representing time and the supported date range is 0001-01-01 00:00:00.0 to 9999-12-31 23:59:59.999_999_999 in any local time zone. That means we don’t have to worry about running out of dates for 7,676 years or so. I think most languages have started switching to a 64-bit time representation if possible, but I just thought it would be good to point that out.

The entire Time module is full of so much syntactic sugar you might leave with a toothache, but I find it to be extremely useful if you want to work with time in an idiomatic and easy to understand manner. For instance:

time = Time.utc(2016, 2, 15, 10, 20, 30)
time.year        # => 2016
time.month       # => 2
time.day         # => 15
time.hour        # => 10
time.minute      # => 20
time.second      # => 30
time.millisecond # => 0
time.nanosecond  # => 0
time.day_of_week # => Time::DayOfWeek::Monday
time.day_of_year # => 46
time.monday?     # => true
time.time_of_day # => 10:20:30

You can also do math with time.

Time.utc + 3.days
Time.utc - 14.years
# etc etc

Need to localize things to a specific timezone? No problem.

time = Time.local(2016, 2, 15, 10, 20, 30, location: Time::Location.load("Europe/Berlin"))
time.inspect # => "2016-02-15 10:20:30.0 +01:00 Europe/Berlin"

This isn’t unique to the Time module. There is syntactic sugar all over the standard library that’s there to make your life easier. Which leads me to my last section.

How much is too much?

If there’s anything I wanted to convey in this post, it’s that Crystal’s standard library is awesome. I barely scratched the surface of the useful classes and modules that exist within. But with great power… No scratch that. With large standard libraries, come the pain of forgetting about all of those useful tools when you need them most. Some, the ones you use most often, you’ll of course remember, but what about the ones you only find yourself needing once in a while? You’re probably going to forget they even exist.

Is this a problem with the language? Or even the standard library itself? I don’t think so. The human brain only has so much capacity for standard library documentation, and even with a smaller library, you’d probably still have trouble remembering the useful stuff when you need it. In the end, this is why we have documentation in the first place. Sometimes I’ll just pick a random class or module and read through the API documentation to learn about some of the useful tools I didn’t know exist. I’m just grateful that so much time and effort has been put into developer happiness when it comes to the standard library, and Crystal itself.

Copyright © 2024 Christopher Watson. All opinions are my own and not representative of any of my employers, past or present.

Like what you see? Support me on Ko-fi or GitHub Sponsors.