[go: up one dir, main page]

Noel Rappin Writes Here

What About Static Typing in Ruby?

Posted on August 17, 2024


I’ve tried writing this literally a half-dozen times. And it always feels like it slips out of control and gets too abstract to be useful.

So, let’s start with something concrete. And we’re going to wind up splitting this into multiple parts. Probably two, but honestly, at this point who knows?

This all got started because I was discussing the use of runtime checking using Sorbet. The other person gave me a code snippet and asked how I would manage it without type checking. We kind of got distracted and I never really answered, but then I spent literally the next month trying to answer the question in my head. It’s echoey in there.

So, Here’s A Code Snippet

This isn’t exactly what I got sent, but it’s the same structure and I’ve just given it some meaningful names and some extra arguments to make it easier to discuss.

Imagine this code in a series of services that all call each other — you can imagine that other important logic, like constructors, are being elided for clarity. (This is not based on any real code, and the original example was just abstract.)

class CheckoutService
  def checkout(user, items, amount, status)
    # do some things
    ManagePayment.new.manage_payment(user, items, amount, status)
  end
end

class ManagePayment
  def manage_payment(user, items, amount, status)
    # make the user pay
    HandleShipping.new.handle_shipping(user, status)
  end
end

class HandleShipping
  def handle_shipping(user, status)
    send_item_to(user.address)
  end
end

The constraint is that CheckoutService#checkout needs to fail if the user doesn’t have an address, even though address isn’t called until we get to HandleShipping#handle_shipping. That constraint is important, because without it, my answer would be “the checkout method shouldn’t care, that’s the handle_shipping methods problem.”

So, how would I manage that without static type checking?

We’ll get there, but first I want to be very specific about what type checking looks like here and what it does and doesn’t do.

Here’s what I think this looks like if we add type checking — it’s the RBS setup for this code , at least I think it is… I may have gotten the syntax a bit wrong, but this is the gist. Obviously you could use Sorbet or try the new rbs-inline gem or whatever. For our purposes here, they are all basically equivalent.

class CheckoutService
  def checkout: (
    user: User,
    items: Array[Item],
    amount: Number,
    status: Symbol
  ) -> void
end

class ManagePayment
  def manage_payment: (
    user: User,
    items: Array[Item],
    amount: Number,
    status: Symbol
  ) -> void
end

class HandleShipping
  def handle_shipping: (user: User, status: Symbol) -> void
end

This assigns a type to each argument of our three methods, it also says that they are all prevented from being nil, and it says they don’t have a return value. (Every method in Ruby has a return value, what this says is that you shouldn’t use it.)

What do we gain?

Here’s what I see as the potential value of those type checks.

  • When we write the code, assuming that our code editors are set up, we get feedback if we try to pass a value to one of these methods that is not of the correct type. It’s actually a little bit stricter than that, we have to be able to prove to the tool that the type is correct.
  • Optionally, we could use tooling to test the code for type correctness after it is written. In Ruby practice, this is likely to be redundant with the editor checks.
  • In general, our editor has more information about the code and can have better tooling for things like “go to method”.
  • As a result of the static typing, we might have less complex code, because the code does not have to contend with various kinds of edge cases — we don’t have to handle nil inputs, because the type checker prevents nil.
  • We also are probably writing fewer tests — in fact, we can’t write tests that take nil input because the tooling will complain.
  • A reader of the code potentially has more information about the code’s intent. This can be overstated — user is likely to be a user, but for things like “is status a symbol or string”, this can be valuable.

I don’t want to suggest this stuff is not valuable. I do want to suggest that in my experience I find the value limited.

Value and Code Size

Let me back up a bit and talk about one reason why this debate can be frustrating. A lot of this section also applies to the private method discussion

Code techniques have different cost/benefit tradeoffs based on the size and complexity of code.

I think we all intuitively see that this is true when it comes to organizations — the process you need for 1000 engineers is overkill if you are a team of 10. But it’s also true for code complexity.

A quick tour — imagine the narration from the Powers of Ten movie here… Team size here is meant to be a stand in for complexity of logic, but code that has to be extra precise (like because it touches money) moves up the ladder, and so does code that changes unusually often or code that has an unusual amount of developers that rely on it.

  • Sample code, team of 0. Most code you see in tutorials or samples is very simple.
  • Team of 1. I’ve alluded to using different techniques when it’s just me then when I’m with a whole team.
  • Team of 10 or so. This is, in some ways, the Rails sweet spot.
  • Team of 100 or so. At this point most teams get very nervous about people wandering around in unknown code.
  • Team of 1000 or so. Just a fundamentally different set of costs and benefits than team of 10.

And so on, there are, I think, real code bases at 10,000 and 100,000 developers, and there are definitely open source code bases with millions of developer users.

This concept maps onto the private/public debate pretty neatly — you think that private methods are worth it on a team of thousands? Probably true. I think private methods are overkill on a team of ten? Definitely true, because the errors that private methods are there to prevent are less common in a team of ten.

Anybody who has taught test-driven development or object-oriented design has run up against a version of this issue, which is that TDD and OOD both look bad in sample code snippets. They seem overly complex, time wasting, and convoluted. You really don’t see the benefit until you move up the complexity latter a bit.

The flip side is that static typing shows off very well in sample code, and in low-complexity code. There’s usually a clear syntax to show, and a clear example of a simple error that static typing will prevent.

At larger amounts of complexity, those simple errors are less important, but I other features of static typing become more important.

In a related story, my journey with TypeScript went from “amazing, this caught that I didn’t use return” to “I beg you, TypeScript, stop complaining about this very obvious code” in a relatively short time. Why?

Static Typing And Complexity

Static typing does prevent simple errors, but in even a moderately complex case, you can’t count on it for all your data validation needs.

In this example:

  • Static typing can’t tell me if the user object is valid, only that it exists.
  • Static typing can’t tell me if the items are real items that can be sold, or are in inventory, only that they are of Item type.
  • The amount could be negative, or not match the item cost, or something.

And so on.. you get the idea. The point is that the more complex the code, the more likely you are to need other data validation. And any other data validation is almost by definition going to be a superset of the static typing, so the separate value of static typing drops.

I think part of what makes this discussion complicated is the value proposition really does change in both directions as the code gets complex — the quick and cheap data validation is useful in simpler code, but the verbosity gets in the way. As the code gets more complex, the validation part is likely to be superseded, but the verbosity has more of a point both in communication and in minimizing the mistakes that a developer can make in an unfamiliar codebase.

The cost of static typing

There’s a tendency to see static typing as having zero cost.

Look — I feel tremendously pedantic when I talk about this part, and not in the good way. I realize that a lot of developers don’t see these as costs. That said, there’s a reason why I got frustrated with Java / Elm / TypeScript, despite early successes with all of them. These costs might be worth paying, but they are still costs.

  • There’s a little extra code to write, just in the type settings. How much of a pain this is depends on the tool you are using. I find Sorbet a little distracting, but overall, static languages are better at using type inference and minimizing declarations than they used to be.
  • Statically typed code can be more complex, since you sometimes need to add additional casting or whatever so that you can convince the compiler or static tooling things that you already know are true. (It can also be more complex because the type system gets complex, /waves to TypeScript.)
  • Declaring types forces you into early decisions. You have to make firm decisions about the structure of your code to get anything done. This can happen before you really understand the problem.
  • Statically typed code tends to be harder to extend then dynamically typed code, basically for the same reason that it’s easier to have small bugs in dynamically typed code.
  • I have a half-baked theory that static typing facilitates some design laziness, where you don’t refactor to smaller methods or data objects, and you are more likely to have nested ifs and stuff. It’s just a theory, though.

Those last few, taken together, are part of where I tend to get frustrated. In order to write any method, I need to make decisions about the structure of my data that I may not be ready to make and which are kind of a pain to undo.

In this example, we might need to be able to checkout to a Company in addition to a User. In a static type system, we’d need to do… something, create an interface, a union type, an overridden method. My experience with “everything is an interface” implementations of type systems are… not good. The number of interfaces tends to proliferate, they never quite have the attributes you need, and it all ends up being confusing.

In a dynamic type system, we just need to have the Company class define the same methods. That clearly has some risk, but it’s also clearly easier for that specific use case.

TL;DR

I’m going to stop here for now, with this question, which is kind of the balance point.

In this example, are you more worried that somebody might pass a string to the user parameter or worried that somebody can’t pass a Company there?

I’m usually more worried about not being able to extend the method — I think I’ll be able to catch the string error pretty easily, and by keeping the code easy to extend we keep the cost of change lower.

Next time, I’ll talk about what I do to in Ruby to prevent bad type errors.



Comments

Please enable JavaScript to view the comments powered by Disqus. comments powered by Disqus