ArticlesRuby programmingEngineering
Comparing Ruby and JavaScript with some observations and ranting mixed in for good measure.
It's been almost two years since I joined Stripe and started working with Ruby day to day. I have been doing JavaScript for a decade. There are things I like and dislike about both languages, some interesting observations, and a tad bit of venting I'd like to share.
Stripe Ruby is primarily written in conjunction with Sorbet. So much in fact that Stripe Ruby code not written with Sorbet is considered bad code. I've been able to witness and experience Sorbet's evolution of features, tooling, and company adoption. Despite its bugs, I've grown fond of it and probably would not write Ruby without it, even outside Stripe.
At Stripe we have four flavors of JavaScript in production: vanilla, CoffeeScript, Flow, and TypeScript. The majority is written in Flow. Before Stripe I had not used a tack-on-type-system for JavaScript and for front-end projects I've only worked with Flow due to where in existing code I was working. I definitely have not felt pressure to use Flow outside of Stripe.
There are a lot of ways to say JavaScript and Ruby are different languages. The one that resonates with me with the most conciseness follows from these two snippets:
return_value = ruby_object.method
js_function = js_object.property
return_value = js_function()
In Ruby, everything is an object and objects receive and respond to messages. In JavaScript, objects have properties, those properties have values, and those values can be functions.
Ruby's consistency in modeling is pleasant to me conceptually but not being able to decouple method from value gives me fewer guarantees. In JavaScript, properties defined with getter/setters suffer from this same problem. For example:
foo = obj.bar
If you are
writing good JavaScript code (e.g. no
getters), you can know this is a constant time lookup of
bar
on obj
. In Ruby I am calling a
method that can do anything, including maybe never finish
running.
For concrete disadvantages of Ruby's approach, we can look to Sorbet. One of the most common frictions developers face when starting with Sorbet is understanding "flow sensitivity."
class MyClass
sig {returns(T.any(Integer, NilClass))}
def maybe_int
if rand > 0.5
1
else
nil
end
end
sig {returns(Integer)}
def my_method
if maybe_int == nil
return 0
end
maybe_int
end
end
Sorbet won't allow this
because maybe_int
can return a different value on
each invocation. To narrow this we need to use a
local variable to deal in values. Flow sensitivity is covered in more detail in the
Sorbet docs.
This example isn't meant to illustrate Ruby is wrong, just that methods are powerful and maps/properties are dumb. In most cases methods should be referentially transparent but Sorbet doesn't model this, which introduces typing overhead JavaScript doesn't have to deal with as much because properties are inert indirection between methods and values (in good code).
How I develop something is highly influenced by what it is. If I do follow a standard procedure in development, it's buried in my subconscious. However, variance of work aside, I see a difference when comparing JavaScript and Ruby.
In JavaScript, I write and get the code working, then make Flow happy, then make the tests happy. In Ruby, I write short chunks of code, get those chunks Sorbet-happy, repeat, and then finally make tests happy. Not too different, but why not the same?
My first thought was I'm just way more familiar with JavaScript. I have been grinding around JavaScript's plethora of quirks so long they are baked into my brain interpreter. There's nothing Flow can tell me that I didn't already know about the code I just wrote. I know my code works, and getting Flow to agree with me is in service of long term maintainability. However, I've done enough Ruby to have a pretty decent brain interpreter for it too.
I don't have a clear answer, only miscellaneous threads.
Sorbet type checks at runtime (unless code is opted out for performance reasons). I have very low confidence in types which aren't exercised in production since people will always find ways to lie to the type system to move faster. Also, in code where types are tacked on, for typed code that's interacting with untyped code being able to say for the past X days/months in production the untyped code honored the typed contract is really powerful.
Thanks to runtime checks I'm not fighting in distinct compile-time and run-time phases, I can focus on getting the code working by running it and Sorbet ensures my assumptions as I go.
When I'm writing Ruby, I have the advantage of writing in a more closed system. I'm writing code that interacts with other typed code that's roughly consistently written. Achieving optimal code is much more clear cut as non-business logic complexity and structure has been mostly fleshed out at Stripe.
When I'm writing front-end JavaScript, I've gotta make the browser happy. Web APIs are intentionally designed to be inconsistent (wait, they're not you say?) and in the face of all this complexity (saying nothing of browser differences) I'm more inclined to try grinding out a working solution than type check my code to success. All that one-off, most likely dead end experimentation doesn't need types until it works.
I can see types playing a bigger, more helpful role in server-side JavaScript development where one could construct a similarly closed system.
It's interesting to me that with both JavaScript and Ruby the statement "The language has many features, about only 10% should be used" I agree with. Despite Ruby certainly leaning towards object orientation a lot more, they both support all the programming styles. As such, I manage to get by with my classic data and functions approach just about as well in Ruby as in JavaScript.
Ruby blocks are special and don't have a JavaScript (or most other languages) equivalent. Blocks are essentially anonymous functions (first example) which have access to outer scope flow control constructs (second example).
def my_method(x, y, &blk) # Explicit block arguments like &blk here
yield x + y # tend to be non-idiomatic in plain Ruby.
# ^ De-sugars to: # In order to Sorbet type blocks, we need
# blk.call(x + y) # the explicit arg so I always write it.
end
my_method(1, 2) do |sum|
puts sum # => prints 3
end
def sum_even_numbers(list)
sum = 0
list.each do |num| # Like JavaScript's Array::forEach
if !num.even?
next # Like JavaScript's continue keyword
end
sum += num
end
sum
end
While cool to look at, blocks are hard to wrap my head around
even now. I only use them to allow callers of my methods to use
the familiar do ... end
syntax, never really using
them for or intending they be used with all the flow control
constructs. Ruby offers lambdas which are the real anonymous
functions and I reach for those more often.
Switching between Ruby and JavaScript daily, I've gotten tired of variable declaration keywords in JavaScript:
var x = 0
let y = 0
const z = 0
In Ruby you only need:
x = 0
What x
is in the Ruby context is ambiguous in some
contexts and cannot be made constant/final. However I use a lot
of variables and most are constant so I feel the pain of JS
boilerplate a lot more than I see value in the long term
readability.
Where Ruby variables get messy is in allowing this:
def my_method(x)
if x != 4
y = 0
end
y
end
my_method(4) # => returns nil
Ruby local variables suffer from the same issues as JavaScript's
var
hoisting. At least the JavaScript equivalent
sets off alarm bells since var
is basically
obsolete:
function myMethod(x) {
if (x !== 4) {
var y = 0
}
return y
}
myMethod(4) // => returns undefined
Speaking of less boilerplate, Ruby is much better than JavaScript because most code is expressions instead of statements. A big advantage is explicit returns:
def my_method
4
end
Most good code returns values so making the common case easy is great.
Even better than the implicit return, conditionals are also expressions:
x =
if y
1
else
2
end
In JavaScript (excluding ternaries), you would need to write:
let x
if (y) {
x = 1
} else {
x = 2
}
With conditional expressions, we can discount the hoisting issue from the previous section:
def my_method(x)
y = if x != 4
0
end
y
end
my_method(4) # => returns nil
Ruby case/when (unlike JavaScript's switch statement) and begin/rescue/ensure (try/catch/finally) expressions are also much nicer.
In JavaScript, for !!x
eight values plus a browser quirk
evaluate to false. In Ruby, only !!false
and
!!nil
evaluate to false. Ruby blows JavaScript out
of the water in being actually understandable.
The same goes for type coercion, Ruby could do a lot of things more implicitly but doesn't. The mental baggage of working in Ruby is much less.
In JavaScript strings are immutable, in Ruby they are mutable.
"Woah, JavaScript is better at something?" - You
Ruby has a special pragma to make string literals frozen by default and there are plans to eventually make that the default. In practice, string mutability hasn't been a sharp edge because most developers agree mutability is bad and making copies of strings is easy and common.
As languages, Ruby is better than JavaScript. Of course, JavaScript never got by on its language merits so this isn't too surprising. To be clear, Ruby has quicks of its own. Really bad ones, like this one:
def my_method
4
end
Object.new.my_method # => 4
[].my_method # => 4
1.my_method # => 4
"I see you defined a top-level method, but methods need an object..Geez, where should I put this? What's a sane default? Oh, I know: everywhere."
But the difference between that quirk and the ones above is that JavaScript imposes a constant and pervasive mental overhead to basically everything you do. Ruby sucks in smaller doses.
This article is a bit less focused than most of my writing. It's fair to say writing this out had the main benefit of just getting it out of my head in an attempted coherent fashion. This is one of those articles that could be a lot longer, but this is enough for me for now.
I hope you either learned something or this kicked up some introspection of your own. Thanks for reading!