ArticlesRuby programmingEngineering
Reviewing my influences TypeScript, Sorbet, and Clojure Spec
Jake asked my thoughts on Sorbet's runtime type checking. At the same time I've been revisiting Clojure's runtime type checking alternative Spec. So I think this is a good time to flesh out some thoughts.
I primarily code in TypeScript and Sorbet Ruby, for both work and personal projects. TypeScript's type system is quite powerful for a type system. It's rich, has tons of features, nice syntactic sugar, and meshes well with the stated goal of being a bolt-on type system to JavaScript. We don't escape any of the pain of JavaScript, it's just made more bearable by the compile-time type-checking and IDE experience. And that's where TypeScript's help stops.
Sorbet has runtime type checking so it goes further than
TypeScript, all the way with us into production (by default).
These
runtime checks are awesome
because they don't lie. TypeScript can be a puzzle game: 'how do
I express this invariant in a way that the compiler will enforce
it' is a fun exercise but someone will do
x as any
when the time comes and that puzzle
becomes useless to what actually matters: production code.
Sorbet is not just Ruby: the runtime type-checking does add
additional overhead. Sorbet is a less feature-ful type system,
but ~everything it does provide is enforced in both static and
runtime scenarios.
TypeScript and Sorbet are juxtaposed on two axis:
Sorbet can't achieve a more powerful type system because the runtime type-checking needs to be grounded in reality (types must be class/module/value-based to make checking practical). TypeScript can't provide runtime checks because it is so expressive it cannot translate to a viable runtime checking analog (structured types are prohibitively expensive to check at runtime).
As much as I like TypeScript, Sorbet has the better trade-offs to me:
When I read Sorbet code, I know it says something about production behavior. With Typescript code, I know it says basically nothing.
TypeScript is more expressive, but when you can't confirm it
in production it has less meaning. Structured code comments
can lead to messes of complexity that don't provide value.
With Sorbet things have to be kept simple, within Sorbet's
limits or you must annotate where you've exceeded the limits
(e.g. T.untyped
) and it's clear you're going on
your own.
Adding runtime checks can be a breaking change, so working with
existing code that is not typed needs to be updated carefully.
Sorbet provides
on_failure
to do that change management, allowing an evolution:
def get_http_error_from_status_code(status_code); end
# We log when the validation fails and monitor the logs to gain
# production confidence the type annotations are correct.
sig do
params(status_code: Integer)
.returns(T.nilable(HttpError))
.on_failure(:log)
end
def get_http_error_from_status_code(status_code); end
# Confident, we can remove the logging
sig {params(status_code: Integer).returns(T.nilable(HttpError))}
def get_http_error_from_status_code(status_code); end
Contrast this with TypeScript where there is no change management story for breaking changes because the type system doesn't play a role in production. TypeScript is free to continually make breaking changes of the type system and end-developers write whatever contracts they want in a void.
You must either accept that your types are going to mean very little or shoulder a relatively heavy-weight change management process to 'earn' that value.
While you might consider this a ding against Sorbet, think about it this way: you're heavily invested in your type system and all new code has types. Writing new code has no extra overhead change management wise, you get it right the first time perhaps.
For Sorbet users, this means the types are 100% trustworthy when they are used. For TypeScript it means you're just one moment away from someone sneaking around the type system all the time. You're embracing the type system in both cases, but only with Sorbet does confidence compound for your investment.
I just said a lot of good things about Sorbet. Unlike TypeScript, it's not just glorified code comments, it does give us production confidence.
However, there's a bunch of places Sorbet does not give us confidence in production and most of them are due to performance.
Notably, lots of code that executes in the critical path has runtime checks disabled in the code-bases I work in. Sorbet allows this with:
sig {params(x: Integer).returns(Integer).checked(:tests)}
def method_underpinning_everything(x); end
In this case, checks only will run in tests. But sometimes it
goes further to no checks at all and sometimes even
sig
itself is prohibitive.
Sorbet also gives up on checking generic types in some places. Take these examples:
# Fails statically but no issue at runtime.
T.let([1, 2, 'string'], T::Array[Integer])
# This one doesn't even fail statically.
# (As of this writing at least)
T.let({a: 1, b: 'string'}, T::Hash[Symbol, Integer])
An array or hash with an unbound number of elements, keys, or
values is too expensive to check and lacks predictable
performance. Sorbet is lying to us in these cases, what we
really have at hand in production is
T::Array[T.untyped]
and
T::Hash[T.untyped, T.untyped]
and very few people
internalize this.
Trading off performance, Sorbet erodes that trust I liked so
much. In the checked
case it's not as bad because
it is explicit, but the generic case is something I have and
others have gotten burned by. But hey, still doing better than
TypeScript which doesn't compete at all.
It was these compromises in type safety that got me really excited about the Sorbet compiler when it was announced. The performance gains of the compilation over interpretation overcome the added cost of type-checking to make the performance vs. confidence gap much narrower.
Sorbet's runtime checks require types be represented in some way
at runtime. The checks performed are usually
is_a?()
-like checks but there are other data-like constructs like
T::Boolean
which is a union type of
TrueClass
and FalseClass
that get
represented at runtime quite transparently:
> T::Boolean
#=> #<T::Private::Types::TypeAlias:0x0123
@callable=#<Proc:0x0456>
@aliased_type=#<T::Private::Types::SimplePairUnion:0x0789
@raw_a=true,
@raw_b=false
>
>
One thing that's annoying about TypeScript is after you've
gotten your types working, it's kind of a reuse dead end. You
have to reach for spooky additional tooling like
io-ts
and throw out those types you just wrote or hand roll your
checks yet again.
Sorbet types, because they have a runtime presence, are not a dead-end. We can use them to parse untrusted inputs:
class Person < T::Struct
const :first_name, String
const :last_name, String
end
sig do
params(hash: T::Hash[T.untyped, T.untyped])
.returns(T.nilable(Person))
end
def try_to_parse_person(hash)
begin
Person.from_hash(hash)
rescue TypeError
nil
end
end
Definitely another win for runtime checks. We want types to be tools in our day-to-day work as well, not just code comments.
Runtime type-checking is a more situated problem than compile-time only. You have both stories to manage instead of one. Because of performance and how code executes, the type system offering runtime checking will be more constrained in what it offers and offers with confidence.
TypeScript and Sorbet are defined by their host languages, neither had a clean slate and met their ecosystems where they were. Given a clean slate, I aspire to the outcomes Sorbet achieves more than TypeScript.
Facilitating runtime checks is what is so great about Sorbet. While it is limited expressively, Sorbet sets engineers down the right track of solving problems, not puzzles.