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.
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.
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.
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.
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.
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.
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.
For some reason some people want to slap down types onto EVERY language. I call them the type-o-maniacs.
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.
More please! =)
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?
I think you're right, and if that's the case, aren't these libraries (Lowtype, Literal) more akin to Design by Contract mechanisms?
I tried once myself to implement something like lowtype, but without success.
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. 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 statically4. 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...
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.
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.
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.
Also looks awful - not as bad as RBS but awful still.