Sorbet is a gradual type checker, which is a blessing and a curse. In large codebases, complete rewrites are expensive and dangerous. A complete halt on new features to pay down technical debt? Oof. “Ah, but if we just rewrite in <my favorite language>, we’ll be fine!” Maybe… This risk pays off only in extreme circumstances. Gradual type systems solve this. Types can be adopted incrementally, team-by-team or file-by-file, according to the velocity of individual teams. In the face of large user asks, quarterly planning, and re-orgs, migrations to gradual type systems are resilient to shifting priorities. To deliver on this promise, gradual type systems make a compromise: type checks can be “turned off” at any time and at any level of granularity. In Sorbet, we can opt out of type checking at a call site, a method definition, a class, or even an entire file. This is the fundamental tension in a gradual type system: when a program typechecks, it’s not clear how confident we can be in the typechecker’s assessment of our code. But it’s precisely this feature that lets us plow forward adding types while simultaneously adding features. The defining characteristic of a gradual type system is to be able to turn it off at any granularity. So what specifically makes Sorbet gradual? The answer: If this sounds counterintuitive, that’s because it is. Let’s see why with an example: Follow what this example is doing: we start with At first blush, it looks like we do not want this property in our type system. It lets us make wildly inaccurate claims! But let’s withhold judgement for a second, and see what this property enables: Look at the line marked Since In a sense, “being optimistic” is really just a way of the programmer telling Sorbet, “I believe this code is correct.” And realistically, we might be convinced of our code’s correctness for a number of reasons: But as many of us have experienced, our faith is frequently misplaced when it comes to believing that some code is correct. As we add type annotations, Sorbet helps uncover and check hidden assumptions, providing even more evidence in favor of our code’s correctness. The next question becomes: what if our annotations are wrong? In our Because Enter the runtime system. At runtime, the This means we can leverage our existing test coverage and our production assertion monitoring to build confidence that our type annotations are correct. In the early days of adopting Sorbet in a codebase this is super valuable because most things are going to be By now we have a pretty good understanding of When a codebase is adopting a gradual type checker we see an adoption curve where For context, at Stripe we’re somewhere about halfway between (2) and (3). We’ve already enabled There are a number of tools for managing These tools are the bread and butter for rolling out types in a gradual type system. Mostly that means they’re tools that the Developer Productivity team uses, but understanding that they’re there can help give context when troubleshooting. See Troubleshooting for more information. Gradual type systems make it tractable to mix feature development with adopting a typed language. The guarantees they provide are necessarily weaker than languages without a type like Armed with this knowledge, here are some resources to check out next: How to opt into higher strictness levels and add type annotations so that Sorbet can report more errors. Learn more about what benefits the runtime system provides, and how to work with it.What is a gradual type system?
T.untyped: A double-edged sword
T.untyped.T.untyped is a type in Sorbet’s type system, but it’s unlike any type found in say, Go or Java. T.untyped has two special properties:
T.untyped.T.untyped can be asserted to be any other type!# T.let asserts that an expression has a type:
x = T.let(0, Integer)
# Anything can be T.untyped:
y = T.let(x, T.untyped)
# and T.untyped can be anything!
z = T.let(y, String)
T.reveal_type(x) # => Integer
T.reveal_type(y) # => T.untyped
T.reveal_type(z) # => String
# ... z = 0, but its type is String!
x = 0, then set y = x, then z = y, so in the end z = 0. But at the same time, Sorbet thinks that z has type String!# --- File we haven't typed yet ---
class Person
attr_accessor :name
end
# --- File we're currently typing ---
sig { params(person: Person).returns(Integer) }
def name_length(person)
name = person.name # (*)
T.reveal_type(name) # => T.untyped
len = name.length
T.reveal_type(len) # => T.untyped
len
end
(*). Sorbet knows about the Person class, and knows that it has a method #name, but implicitly assumes this method’s return type is T.untyped. Why? This is what Sorbet assumes for any methods that don’t have a corresponding sig annotation.T.untyped could be any type, when name.length runs, Sorbet optimistically assumes that name will have some type which has a #length method. Then it also optimistically assumes that the thing #length returns (in this case, len) could be an Integer. Thus, Sorbet declares that this method type checks.
Checking our assertions at runtime
name_length example above, we added a type annotation to a previously untyped method. What if our annotation was wrong? For example, what if we accidentally typed the return as a String:class Person
attr_accessor :name
end
# The annotation for .returns is wrong!
sig { params(person: Person).returns(String) }
def name_length(person)
person.name.length
end
person = Person.new
person.name = 'Jenny Rosen'
name_length(person)
Person#name returns T.untyped, Sorbet does not have enough information statically to check that #length returns an Integer. But if we were to run this code, we’d know at the moment our method returns that our result didn’t match our declaration.sig above a method def wraps our method into a new method which does three things:
sig's declared paramssig's declared returnT.untyped!The lifecycle of T.untyped
T.untyped:
T.untyped shows up everywhere then slowly gets phased out. There are three main phases of T.untyped in a codebase:
typed: true in the vast majority of files in our main codebase, but most methods and even a few core abstractions are not typed yet.T.untyped in a codebase:
typed: true, typed: strict, and typed: strong).
T.untyped is allowed.sig and T.let annotations.
T.unsafe and T.cast.
T.reveal_type and our editors.
T.untyped is half the battle.What’s next?
T.untyped, because T.untyped allows us to “opt out” of type checking. But it’s precisely this property which enables incremental adoption through a large codebase.