logoalt Hacker News

Lowtype: Elegant Types in Ruby

48 pointsby birdculturelast Friday at 4:50 PM27 commentsview on HN

Comments

inopinatustoday at 8:36 PM

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. If an object prepends a module to its singleton to become a proxy for something else, or uses refinements (which are lexical) to meet method expectations, or (bad style, looking at you Rails, but nevertheless) just evals whatever 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 (and which means any app relying on such a framework may either not pass, or may silently bypass, such type checking).

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

graypegglast Friday at 5:02 PM

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.

show 2 replies
rco8786today at 8:46 PM

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.

jaredcwhitetoday at 8:01 PM

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! =)

ryukopostinglast Friday at 5:50 PM

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.

show 5 replies
Liolast Sunday at 2:34 PM

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/

show 1 reply
jihadjihadtoday at 8:01 PM

So, it's like Python? If only Ruby had something like Pydantic, too.

ceritiumtoday at 6:51 PM

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.

show 1 reply
theoldgreybeardtoday at 6:47 PM

What's the advantage of using this over RBS?

shevy-javatoday at 7:10 PM

Looks a bit python inspired.

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

thiago_fmtoday at 6:11 PM

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.

show 2 replies
jeztoday at 7:30 PM

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...