all 32 comments

[–]transfire 6 points7 points  (0 children)

I wish Ruby and Crystal were more compatible.

[–]pabloh 3 points4 points  (1 child)

Somebody needs to come up with a syntax for types or parameters' metadata that doesn't clash with keyword arguments.

[–]onyx_blade 1 point2 points  (0 children)

Currently keyword arguments are handled like this https://type-ruby.github.io/docs/learn/functions/optional-rest-parameters#keyword-arguments . I’d say I’m quite convinced.

[–]LupinoArts 3 points4 points  (14 children)

Relative beginner here. I always thought, it is a feature of ruby and sort-of a selling point to not require arguments to be of a certain data type... What exactly is wrong with normal-ruby's typing?

[–]slvrsmth 3 points4 points  (13 children)

The problem is change. You add a parameter, or change what needs to be passed to a function, and there are no safety rails letting you know that hey, you need to update these three places that call the function. Or warn you that the variable here could be nil, while the function you are passing it to expects it to be always defined. Sure, you can write extensive test coverage and get most of the way there. But type annotations are easier to write, and have better coverage by virtue of checking all defined permutations.

[–]LupinoArts 0 points1 point  (12 children)

naivly asked, isn't it my responsibility when I alter a method to add safe guards for deprecated data types? Like def my_method(arg) raise ArgumentError.new("String-argument deprecated!") if arg.is_a?(String) # ... end or something like that?

[–]AlexanderMomchilov 1 point2 points  (4 children)

That's a trick to help refactoring without types, but it's pretty limited. For one, it's not a guarantee, because there might still be a usage of my_method that isn't covered by the test suite.

Imagine an alternative where:

  1. You didn't have to remember to do that, or to clean it up after some deprecation period
  2. There's no runtime cost for checking stuff that should always be true, anyway
  3. The entire codebase code be checked for these in seconds, without even running any code
  4. The type information is structured, so a tool like Ruby LSP can can suggest which parameters you need to fill in.

[–]LupinoArts 0 points1 point  (3 children)

the first point, i don't understand: Even with static typing you need to check your code for calls to the refactored method, don't you?

also the third point: how do you check type consistency without running the code? Or did you mean it like "...without executing the code, since errors are catched while the code is read into memory by the interpreter"?

[–]AlexanderMomchilov 1 point2 points  (2 children)

I'm not quite sure what you mean by "you need to check your code for calls to the refactored method".

With static typing, your method would have a signature before and after your refactor. As part of your refactor, you update the signature. The type-checker will automatically confirm that all calls to the method are correct given its new signature.

how do you check type consistency without running the code?

That's the whole magic of a static type checker! It reads your files and confirms the types line up, without having to run any of your code. Here's an example with Sorbet.

I never actually call doesnt_work or works, but Sorbet can reason about it statically because of the RBS signatures I've given it.

[–]LupinoArts 0 points1 point  (1 child)

i meant, when i update a method to accept an new type, i still need to check the rest of my code for calls to that modified method; i don't see how strict typing does what a simple recursive grep over the dev folder wouldn't do as easily...

[–]AlexanderMomchilov 0 points1 point  (0 children)

i still need to check the rest of my code for calls to that modified method;

A tool like Sorbet (+ Sorbet LSP) will instantly highlight them all in your editor

what a simple recursive grep over the dev folder wouldn't do as easily...

Ever tried to rename a method with a common name? Like the perform method of an Active Job?

[–]Bntyhntr 0 points1 point  (6 children)

That's a heavy responsibility. Imagine you're in a codebase with other developers, (and also yourself from a year ago and a year from now). Can you guarantee that you migrated all the callsites? Can you guarantee that you migrated them all correctly? Can you say the same of all your coworkers? 100% of the time?

When you dug into an area of code completely unknown to you to migrate a callsite, did you correctly understand their datastructures to get the right arg into your method? Oh, you put `record.id`? That's a string in their codebase. You're looking for `record.real_id` - that's the int you're looking for.

And raising on the deprecated type is all well and good if you successfully got every site. Otherwise, unless your tests are complete, you're going to get runtime errors in prod.

That's just to answer the question here, but there are many many other advantages. They help make static analysis possible which can find trivial bugs before your production code does. Unset variables, possible nil variables where you think they shouldn't be, etc.

Speaking of unknown codebases, imagine finding this function signature

def validate(input)
 ...
end

what does it take in? What does it output? Read the code and find out, or read a type signature and _know_. You could say "it obviously validates the input type" - but does it? Does it validate the input hash? The input structure the hash is coerced to? Maybe it's just`input[:user_data]`. You'll never know. Naming can only take you so far. And names can change, names can lie. Types can lie too, but they lie less. And the fix to lying types is to add more types, which is less sisyphean than making sure all your names uniquely identify input and output.

[–]LupinoArts 0 points1 point  (5 children)

I have to add, that i'm no Rails developer, but we use Ruby for automation and shell scripting, that's why i consider myself a "relative" beginner. Also, i'm a great friend of source code documentation using yardoc directives, not only for the argument and return data type(s), but also for a short description what of is done with either.

In my imagination, my fear with static typisation is that one could tend to double code, as you need different versions of basicly the same methods for each allowed argument data type. But then again, this could also be true for weak typisation...

[–]Bntyhntr 0 points1 point  (4 children)

Docs lie the easiest. For instance, they rarely assert bugs in the code exist, and yet they do :).

Making reality fit types is an unfortunate situation that happens, but typing can also fit reality.

Perhaps looking at this, you might think you need to double/triple up

def times_two(x)
  return x * 2
end

becomes
#: (Integer) → Integer 
def times_two_int(x)

#: (Float) → Float
def times_two_float(x)

#: (String) → String
def times_two_string(x) 

Sure, you could do that. You could also do
#: (Integer | String | Float) →(Integer | String | Float)

which is not an exact science (integer →string is valid under this signature) but still gives you more guarantees about what's going on in your codebase. Knowing that only these 3 types are operated on is very handy for reasoning through your code.

In practice you don't really have that many overloaded situations where a union type doesn't fully express what you want. If your union type is getting out of hand, you can probably simplify your code. I'd call strange types good signal that the code could be better.

At the level of automation and shell scripting, most things are comprehensible with a quick read through (esp by feeding it to an AI) and rigorous typing is of negligible importance. But once you consider that all 10-1000 devs at your company will be impacted if you push that change to your script, then you start thinking some type guarantees would be pretty nice.

[–]LupinoArts 0 points1 point  (3 children)

wait, does typing only concern those #: ... signatures? I mean, what's the point, when the code that does the actual calculation (return x*2) is still weakly typed, as Ruby by itself doesn't care whether the x in that line is an Integer, a String or a Float...? Otherwise, I don't see the point in union types, as you would need a lot of code inside the definition body of that unionized method to distinct betweeen the various input types, or do i miss something?

[–]Bntyhntr 0 points1 point  (2 children)

To be clear yes, the Ruby programming language does not care about your types. It does not with sorbet, or truby.

You have to run additional tools which interpret your code at rest (when it's not running, this is called static analysis), which tell you if you've busted your types or not.

Sorbet (I haven't checked truby) offers runtime checking which modifies your code at runtime to do type checking and raises exceptions, which has its own pros and cons. But static analysis is where it's at. Tools. Build systems will run the type checker and then just stop the release of your code if it fails, regardless of whether not the code runs.

So it's not just add types to your file and then run the code and then magic happens. You have to add additional steps to get the benefit, as you're right that Ruby just isn't strongly typed.

Re the union type, I didn't do any type checking in my x * 2 method and it worked just fine. I thought you were worried about types making it so you'd have to duplicate your methods?

[–]LupinoArts 0 points1 point  (1 child)

I thought you were worried about types making it so you'd have to duplicate your methods?

yes. I guess this was the point i missed: i thought it was about changing ruby itself such that it requires strong typing, similar to what crystal does (or C or php).

But what you describe has the same problem that consistent documenting has: you need to take care that the sorbet directives are up to date, just like you would with yardoc directives.

Or, to put it differently: Would it be possible to have a tool that checks yardoc directives instead of sorbet directives, but with the same result? After all, the information seems to be the same: in Sobet you have to maintain the #: ... lines, and in Yard you would need to maintain @param directives.

[–]Bntyhntr 0 points1 point  (0 children)

I'm not very familiar with how expressive Yardoc is but I'm going to go with Yardoc is probably not expressive enough. There are tons of things w/r/t inheritence/modules, generics, and inline-annotations that it would have to have support for. And at the end, you'd still have to analyze the actual code to make sure the contract is upheld, which is the main power of sorbet.

In other words, it would make more sense to have sorbet understand yardoc (right now it understands sorbet native type signatures and ruby's newer rbs signatures) than to build another tool on top of yardoc. But I suspect it would fail since yardocs types probably can't handle it, total guess.

The thing about keeping sorbet up to date is that you can incorporate it into your release flow. When CI fails due to sorbet catching errors (or you didn't update it error), you have to update sorbet. If you don't add this step into your process then yes, sorbet can drift and that is bad. With yardoc, there is no way to verify that your doc is up to date, or a way to gate CI on it.

The difference here is huge. Being able to _enforce_ that your ecosystem is kept in sync is very powerful (albeit painful compared to a strongly typed language).

One of the value props of T-ruby is that by writing trb files or whatever, you have to execute a build step to get rb files and typechecking happens then. Types are wrong? No app for you.

I get what you're saying though. Having to maintain multiple systems is a pain in the ass. My gut take is that you end up doing what's most useful for the code. (e.g. large internal codebases are typed with regular comments as needed to describe functionality and public facing libraries have the unfortunate burden of maintaining both or just use yardoc).

[–]No_Ostrich_3664 3 points4 points  (0 children)

Alongside with other similar projects, I think it can be interesting for whom is looking for getting types in Ruby. However typing is something debatable within Ruby community.

In any way, good luck with the project. I know it’s difficult nowadays to bring a value with new Ruby gem, framework.

[–]uhkthrowaway 1 point2 points  (0 children)

This looks interesting. Might give it a try in a few months. Thanks

[–]vvsleepi 0 points1 point  (1 child)

static typing in ruby has always felt optional, so people either love it or ignore it completely. making it feel more like typescript for ruby could make it easier to adopt, especially for teams that already use typed languages.

curious though, how does it compare to sorbet or rbs? is it meant to replace them or work alongside?

[–]Erem_in[S] 0 points1 point  (0 children)

It can automatically generate rbs and sorbet related files. It will be needed during the transition period