Lowtype: Elegant Types in Ruby
66 points
4 days ago
| 12 comments
| codeberg.org
| HN
inopinatus
3 hours ago
[-]
Any type system for Ruby objects that isn’t based on message/method response signature (i.e eigenclass), but instead relies on module ancestors (or worse, class), is fundamentally misaligned with the architecture of the language.

A remarkably high proportion of folks that self-identify as Ruby aficionados will make this error.

I’m not even talking about respond_to? / method_missing tricks. If an object prepends a module to its singleton to become a proxy for something else, or a library offers refinements (which are lexical) so its clients may declaratively align method expectations, or (bad style, looking at you Rails, but nevertheless) just evals whatever method definitions it likes after messing with the three implicit contexts, then it should still pass.

Leaning on class and mixin is just one of the ways in which Ruby object anatomy evolves, and although that’s a familiar default to many, there are other styles in common use, especially in framework/library code. Any app relying on such a framework may either not pass, or may silently bypass, such type checking. And I foresee a myriad of edge cases if one slings around closures as a habit (why, yes I do).

Symbolic message passing is the basis of object collaboration in Smalltalkish OO, and in Ruby class/mixin is merely one of the ways to get there. The conceptual gap means that what you get from oversimplification isn’t just a half-baked type system, it also becomes an incomplete straitjacket for style.

Edit to add: after reviewing the internals of this library, note that for a dash of irony, it is indeed prepending modules to class singletons to redefine methods with proxy wrappers. That is to say, it could not type-check itself.

reply
graypegg
4 days ago
[-]
It's not always a good thing, but I love Ruby's ability to define new runtime-valid syntax that looks pretty much native to Ruby itself.

    def method(thing: String | "default value")
the pipe operator seems to be defined here, as just a regular method: https://codeberg.org/Iow/type/src/commit/aaa079bf3dd2ac6b471... the type gets picked out by the module included in the class you want typechecked, which reads the default value from all methods (which is the "real" ruby syntax here, where `thing` is assigned a default value of the result of calling `String | "default value"`) and uses that for type checking.

I like that over-flexibility... it's regularly too clever and makes it difficult to follow the flow of an application, but I like it all the same.

reply
shevy-java
4 hours ago
[-]
> it's regularly too clever and makes it difficult to follow the flow of an application

Too much magic means nobody can understand the code anymore.

Most people who design DSLs don't understand this. It's a problem in ruby - ruby's syntax is so flexible that one ends up with kind of dialects in it.

reply
kelseyfrog
3 hours ago
[-]
I have something to learn here. How would you redesign the DSL to be less magical?
reply
brudgers
3 days ago
[-]
Though the ability to arbitrarily create first class syntax is why Rails is Rails, my favorite use case is FizzBuzz as a property of Integers. YMMV.
reply
shevy-java
4 hours ago
[-]
One can be creative here, but at the end of the day, a lot of what is generated, is just a waste of time. People love to tinker. Then they move on, often before having written any documentation. This is also a curse in ruby - people hate writing documentation. There is a reason ruby lost to python - over-tinkering and dislike of documentation are two smaller reasons.
reply
ryukoposting
4 days ago
[-]
Mixed feelings here. Type annotations are a thing Ruby lacks, that other languages have, that I like using in other languages. Ergo, I'd like to have them in Ruby, right?

My knee-jerk reaction is "yes I'd like that" but when I pause to think about how I actually write Ruby code... hmm. I tend to use Ruby when its flexible syntax and type system are helpful. How much would I actually benefit from something that restricts the flexibility of the type system?

Bear in mind, I'm not a "Ruby dev" per se. It's a well-loved tool in my mostly firmware-focused repetoire. I use it for little CLI tools, and toy game engines too (mri embeds into C really cleanly). Fun little things that most folks would use Python for, I just like Ruby better.

reply
pizza234
3 hours ago
[-]
This sentiment reflect the type of project worked on - small ones. As projects get bigger, more type information gets lost, and that's why it needs to be compensated, typically via automated (unit) testing.

After having worked with gradual typing, unless the application is very disciplined, IMO automated testing is not enough to document the code, as Ruby makes it very easy to use flexible data structures which very easily become messy.

reply
jonathaneunice
5 hours ago
[-]
I had exactly this reaction when gradual typing came to Python. "Do we really need this??"

But over time, I've grown to love it. Programming is communication—not just with the machine, but with other developers and/or future me. Communicating what types are expected, what types are delivered, and doing so in a natural, inline, graceful way? Feels a big win.

reply
shevy-java
4 hours ago
[-]
But you use types not to communicate with other people - you use them to give more hints to the python interpreter. Otherwise you could use comments. :)
reply
zem
3 hours ago
[-]
type annotations in python are essentially structured comments with special syntactic support and runtime introspection facilities (i.e. you can get at the annotations from within the code). they are explicitly not "types" as far as the interpreter is concerned, you can say e.g. `x: str = 42` and python will be fine with it. the value comes from tooling like type checkers and LSPs that work with the annotations, and from metaprogramming libraries like dataclasses and pydantic that introspect them at runtime and use them to create classes etc.
reply
skipants
4 hours ago
[-]
I think you're 110% on the nose. Types in Ruby really depends on the use-case. I don't think your use cases fit the bill and types would be annoying if anything.

I work on big Ruby on Rails monoliths and I curse every day that I don't have types to work with. It's almost a requirement for large refactors.

reply
shevy-java
4 hours ago
[-]
Completely agree with you.

For some reason some people want to slap down types onto EVERY language. I call them the type-o-maniacs.

reply
vidarh
2 days ago
[-]
Ruby has had "officiak" type annotations since 3.0.0 via RBS.

Exactly because of the concerns you described, RBS originally used only separate files for the type annotations, so it can be selectively and gradually applied. You can add Ruby signatures inline as comments as well, but frankly both options looks ugly, and so does many of the alternatives like Sorbet signatures.

reply
shevy-java
4 hours ago
[-]
In fairness: RBS sucks though. This one here is a bit better than RBS.
reply
jaredcwhite
3 hours ago
[-]
A side comment: I love that this is on Codeberg. I'm seeing more and more projects migrating there or originating there to begin with, and it's awesome. (And I've been doing that as well.)

More please! =)

reply
Lio
2 days ago
[-]
I love these new approaches to type checking such as this and Literal[1]. I think they really show how far we could go with runtime ruby syntax.

For both though I have questions:

A. How do I use this day to day to improve my tooling and developer experience?

B. If at some point in the future I decide to get rid of this how easy is it to eject?

I've seen too many adandoned dependencies over the years to trust anything I can't easily remove when it's time to upgrade.

These runtime typing efforts look nicer than Sorbet but, as far as I can see, you still have to have complete test coverage to trigger runtime checks if you want to spot correctness issues before you deploy into production.

Sorbet doesn't have that problem right now. Maybe something clever using Prism might be a way round that?

1. https://literal.fun/

reply
dmux
1 hour ago
[-]
>These runtime typing efforts look nicer than Sorbet but, as far as I can see, you still have to have complete test coverage to trigger runtime checks if you want to spot correctness issues before you deploy into production.

I think you're right, and if that's the case, aren't these libraries (Lowtype, Literal) more akin to Design by Contract mechanisms?

reply
rco8786
3 hours ago
[-]
This is super interesting. Is it possible to turn the runtime checks off in certain environments? I would love to have these type checks just happen during unit tests, for example, and then no-op in production so that performance is unimpacted.
reply
ceritium
4 hours ago
[-]
I love it, I liked what dry-initializer and dry-struct do, and I wanted something similar but simpler than RBS or Sorbet.

I tried once myself to implement something like lowtype, but without success.

reply
hakunin
2 hours ago
[-]
I built portrayal[1] (a much simpler replacement for those dry libs), and was also experimenting[2] with runtime-enforced types based on this lib.

My general thoughts is that declaring types in Ruby is unnecessarily complicated, because you're basically just running values through pieces of boolean logic, and nothing else. Might as well make that explicit, which is what my experiment did. I didn't however publish the types library, but the concept was proven.

[1]: https://github.com/maxim/portrayal

[2]: https://max.engineer/portrayal-guards-poc

reply
jihadjihad
3 hours ago
[-]
So, it's like Python? If only Ruby had something like Pydantic, too.
reply
theoldgreybeard
5 hours ago
[-]
What's the advantage of using this over RBS?
reply
jez
4 hours ago
[-]
It's an interesting approach. From my skim, the way it works:

1. Parse the files with a Ruby parser, collect all method definition nodes

2. Using location information in the parsed AST, and the source text of the that was parsed, splice the parameters into two lambda expressions, like this[1]:

     "-> (#{method_node.parameters.slice}) {}"
3. Evaluate the first lambda. This lets you reflect on `lambda.parameters`, which will tell you the parameter names and whether they're required at runtime, not just statically

4. In the body of the second lambda, use the `lambda.parameters` of the first lambda in combination with `binding.get_local_variable(param_name)`. This allows you to get the runtime value of the statically-parsed default parameters.

This is an interesting and ambitious architecture.

I had though in the past about how you might be able to get such a syntax to work in pure Ruby, but gave up because there is no built-in reflection API to get the parameter default values—the `Method#parameters` and `UnboundMethod#parameters` methods only give you the names of the parameters and whether they are optional or required, not their default values if they are optional.

This approach, being powered by `binding` and string splicing, suffers from problems where a name like `String` might mean `::String` in one context, or `OuterClass::String` in another context. For example:

    class MyClass
      include LowType
      class String; end
      def say_hello(greeting: String); end
    end

    MyClass.new.say_hello(greeting: "hello")
This program does not raise an exception when run, despite not passing a `MyClass::String` instance to `say_hello`. The current implementation evaluates the spliced method parameters in the context of a `binding` inside its internal plumbing, not a binding tied to the definition of the `say_hello` method.

An author could correct this by fully-qualifying the constant:

    class MyClass
      include LowType
      class String; end
      def say_hello(greeting: MyClass::String); end
    end

    MyClass.new.say_hello(greeting: "hello") # => ArgumentTypeError
and you could imagine a Rubocop linter rule saying "you must use absolutely qualified constant references like `::MyClass::String` in all type annotations" to prevent a problem like this from happening if there does not end up being a way to solve it in the implementation.

Anyways, overall:

- I'm very impressed by the ingenuity of the approach

- I'm glad to see more interest in types in Ruby, both for runtime type checking and syntax explorations for type annotations

[1] https://codeberg.org/Iow/type/src/branch/main/lib/definition...

reply
radiospiel
1 hour ago
[-]
> how you might be able to get such a syntax to work in pure Ruby, but gave up because there is no built-in reflection API to get the parameter default values

what I have done successfully here https://github.com/radiospiel/simple-service/blob/master/lib... is to install a TracePoint which immediately throws, and then call the method. The tracepoint then receives the value of the default arguments.

Not pretty, and I wouldn't run this in production critical parts of a system, but it works.

reply
thiago_fm
5 hours ago
[-]
They should integrate this to ruby-core and make it even better by changing the parser and making it faster in terms of performance, as optimized as it can.

But I have a hard time believing ruby-core will want to hear community feedback... people have been talking about this for ages... Ruby is omakase?

RBS and Sorbet suck. One is very limited, the other isn't part of ruby-core and makes you rewrite the function arguments again, similar to Java's annotations... Doesn't look like Ruby at all, or DRY, mostly like a workaround!

LowType is what it should have been -- hard to believe we are in 2025 and we still don't have a decent, programmer-friendly solution in ruby-core.

Meanwhile Python has it right since a long time. No wonder it is so stagnated with people going for other stacks.

Ruby is slowly becoming what Perl did, a very niche language.

reply
rco8786
3 hours ago
[-]
Ruby needs a Typescript. Leave ruby-core as it is. Let us write Ruby with type annotations, where the compiler does type checking, strips the annotations, and leaves us with plain runnable Ruby.
reply
shevy-java
4 hours ago
[-]
I agree with your analysis that RBS and Sorbet suck.

I disagree that this here should be part of ruby-core, largely because I don't think any of this type madness should infiltrate ruby.

Ruby does not necessarily follow "DRY" - that appears to have been coined by either DHH or the pickaxe guys. More than one way to do it, is kind of orthogonal to DRY too. Note: I do not disagree that DRY has value. What I am saying is that ruby's design does not necessarily follow DRY as a guiding principle.

> hard to believe we are in 2025 and we still don't have a decent, programmer-friendly solution in ruby-core.

I do not think the year has anything to do with it. If they suck - and types suck - then they should not be in ruby core. I understand you have another opinion, but that's the beauty - we have orthogonal opinions there. One says must be part of ruby core; the other says should not be part of ruby-core ever, no matter the year.

> Meanwhile Python has it right since a long time.

The question is: how many use it there?

> No wonder it is so stagnated with people going for other stacks.

Lack of types aren't the reason ruby declined. That is a wrong assumption here.

> Ruby is slowly becoming what Perl did, a very niche language.

That is true, but not due to lack of types. Python without types would still be at rank #1 at TIOBE for instance. Your analysis is simply wrong here.

reply
shevy-java
4 hours ago
[-]
Looks a bit python inspired.

Also looks awful - not as bad as RBS but awful still.

reply