Part 8 of 10 5 min read

Types are ceremony

TypeScript, I Don't Hate You Anymore

Type annotations used to be ceremony humans paid for the compiler, but now the agent pays it and the compiler becomes its fastest feedback loop.

Years ago I wrote an essay about why we taught Ruby first, and in it I quoted _why describing Ruby as "method calls striped, undressed of their parenthesis, chaining them together raw." I still love that line.

That was the whole pitch. Ruby valued the programmer over the machine. No semicolons, no parentheses you didn't need, no declaring what something was before you were allowed to use it. If it quacks, call quack.

Type annotations were the opposite. Ceremony for the compiler. Map<String, List<Customer>> customersByRegion = new HashMap<String, List<Customer>>(); was me, a human, filling out paperwork so a machine would feel comfortable. When TypeScript took over the frontend, I rolled my eyes. JavaScript wearing a tie.

I was right about the cost. I was wrong about who would end up paying it.

The agent pays the ceremony now

What I hated about types was typing them. Every annotation was a tax on my fingers and my attention, paid now, for a benefit that showed up later, maybe.

I'm not the one at the keyboard anymore. The agent writes the types. It doesn't sigh at a return type or find Promise<Result<User, ValidationError>> ugly. The cost column went to roughly zero.

And the benefit column got bigger, because the benefit was never for the person writing the code. It was for whoever had to change it later without breaking it. That's an agent too.

Ruby optimized for the human over the machine. Fine. But the one doing the work now is closer to a machine than a human, and what it needs most is something that tells it, immediately and precisely, when it's wrong. That's what a compiler is.

Same change, two codebases

Here's a task I hand off constantly. The User model has a name. We're splitting it into first_name and last_name.

In Rails. The agent writes the migration, updates the model, and goes looking for callers:

bash
rg "\.name\b" app/

140 hits. Most are product.name, plan.name, team.name. Some are user.name. Some are record.name where record might be a user, depending on what got passed in. The agent reasons through them, fixes the ones it's confident about, and runs the specs. They pass.

Three days later:

NoMethodError: undefined method `name' for an instance of User
  app/views/invoices/_line_items.pdf.erb:12

In production. From a PDF partial no spec rendered, reached through a delegate :name, to: :owner in another model that grep never connected to users.

In TypeScript. The agent edits the type:

ts
type User = {
  id: string;
  firstName: string;
  lastName: string;
  email: string;
};

Then runs:

bash
npx tsc --noEmit

And gets a to-do list:

src/emails/welcome.ts:14:22 - error TS2339: Property 'name' does not exist on type 'User'.
src/billing/invoice.ts:41:30 - error TS2339: Property 'name' does not exist on type 'User'.
src/api/serializers/user.ts:8:5 - error TS2353: Object literal may only specify known properties, and 'name' does not exist in type 'User'.
...
Found 23 errors in 11 files.

Every broken call site, with file and line. Not product.name, not plan.name, only the ones that belong to User, including the ones reached through three layers of function calls. The agent fixes them, runs tsc again, repeats until zero. Then the tests run on code that already compiles, so any failure is about behavior, not a missed rename.

RubyTypeScript
When you find outTest run if you're lucky, production if you're notSeconds after the edit
How you find outGrep plus judgment, then a stack traceA list of every broken site, file and line
What it coversLines the specs happen to executeEvery line in the project
How confident you are when it's green"Probably fine""Nothing references name on a User"
What the agent does nextGuesses where else to lookWalks down the list

That last row is the one that matters. An agent in a loop is only as good as its check step. Grep gives it a haystack. tsc gives it the needles.

I've shipped that NoMethodError myself, by hand, long before agents. The difference now is I'm asking for changes like this across dozens of files, in parallel, while I review something else. The agent is usually smart enough. What I need is an environment that tells it it's wrong before a customer does.

Types are context that can't lie

A comment saying # returns a hash of user attributes can drift. A signature saying a function returns UserAttributes is checked on every build.

An agent opens a file cold, with no memory of the last session. Most of what goes wrong is missing context: it didn't know the thing was nullable, didn't know the hash had string keys. Types put that knowledge right where it's already looking, instead of six method calls away.

This is a big part of why I reach for TypeScript or Go for things that aren't a Rails app, which I got into in language strengths over familiarity. My comfort with a language matters less than it used to. Whether the compiler can catch the agent's mistakes matters a lot.

What dynamic typing is still great for

I'm not pretending Ruby lost something it never had.

Dynamic typing is still better for exploring. When you don't know the shape of the data yet, a console and a hash beat designing types up front. It's better for DSLs, which is half of why Rails feels the way it does: has_many, validates, before_action, routes that read like English. Metaprogramming that would be a nightmare to type in TypeScript is a few lines in Ruby. For a small app, one agent, and a solid system spec suite, runtime feedback is often good enough, and the whole thing ships faster.

And types have a ceiling. tsc passing tells you the shapes line up. It doesn't tell you the invoice total is correct. You still need tests. You just need them for behavior instead of typos.

Rails is still home. I've started borrowing the idea there, lightly: RBS or Sorbet on the classes everything depends on, typed params and JSON schemas at the edges, database constraints doing work I used to trust to model validations. None of it gets close to tsc out of the box. Ruby's type tooling is a bolt-on, and the agent notices.

_why was right that Ruby is the language of our thoughts. The agent doesn't need a language of thoughts. It needs a language that argues back. That's part of what I mean by Rethink Everything.

TypeScript, I'm sorry. You were right. I just didn't want to be the one doing the typing.

Next up: More e2e tests, fewer unit tests.

The whole series

  1. 1Code is written for humans to readStop Writing and Reading Code
  2. 2Never rewrite from scratchRewrites Might Be Better Than Massive Refactors
  3. 3Use the language your team already knowsLanguage Strengths Over Language Familiarity
  4. 4Build it once for the web and wrap itGo Native Everywhere
  5. 5Keep the monolith majesticI Hated Microservices. Agents Love Them.
  6. 6Pay someone else to run your serversOwn Your Infrastructure
  7. 7Don't repeat yourselfDuplicate Code So Your Agents Stop Colliding
  8. 8Types are ceremonyTypeScript, I Don't Hate You Anymore
  9. 9Respect the testing pyramidMore E2E Tests, Fewer Unit Tests
  10. 10Refactor for readabilityStop Refactoring for Humans. Refactor for the Agent.