I'm really puzzled by the decision to use a separate file for this. The stated justification ("it doesn't require changing Ruby code") doesn't make sense, and my personal experience with languages with external type specifications is strongly negative. It's an unbelievable pain to keep multiple interface files in sync over time.
`.h` files are not something to emulate! External interfaces should be generated by tools where needed.
FWIW, you can use inline syntax with Sorbet[0], one of the two typecheckers that will work with the RBS format (the other being Steep, which does not have inline syntax).
Here's a full example, complete with a typo, based on the example in the blog post: https://bit.ly/3hMEMSp
Here's a truncated excerpt to get the basic idea across:
# typed: true
class Merchant
extend T::Sig
sig {returns(String)}
attr_reader :name
sig {returns(T::Array[Employee])}
attr_reader :employees
sig {params(token: String, name: String).void}
def initialize(token, name)
@token = token
@name = name
end
end
Disclaimer, I used Sorbet while I was an employee at Stripe. I found it to be a terrific typechecker. It's also just absurdly fast (most of the time).
OK, but if we're going to have .rbs, why not just modify the ruby syntax to allow .rbs-style types inline? Especially becuase .rbs already looks like class and method definitions without the bodies. So... just add the bodies.
class Merchant
attr_reader token: String
attr_reader name: String
attr_reader employees: Array[Employee]
def initialize(token: String, name: String) -> void
# actual method body
end
def each_employee: () { (Employee) -> void } -> void
| () -> Enumerator[Employee, void]
# actual implementation body
end
end
It seems like they are trying to support existing competing work... but i'm not sure any ruby users actually want that. I prefer this .rbs to sorbet all around, and would prefer it inline.
The Ruby syntax is too complicated to allow for changes like this to be backwards-compatible.
For example, `attr_reader token: String` is valid ruby today – that's the same as `attr_reader(:token => String)` which somebody might be doing in the wild, since you can override `def self.attr_reader`.
Similarly, `def initialize(token: String` clashes with the definition of keyword arguments.
I am not able to spin that into "And besides it's better to force it to be in two files anyway!", I don't think it is, but I guess it's not so easy to do different.
If we could write tests in .rbs files it would more naturally fit into existing 2 file workflows.
Mind you, if we could write tests in .rbs then I guess .rbs could form the basis of a new ruby syntax without breaking compatibility with old code in .rb files.
Sorbet was written in C++ and is a great piece of work, Stripe did a great job with it. It does have some issues as soon as someone gets into the magic weeds with metaprogramming like Rails does.
Disclaimer: Working at Square, have friends at Stripe, enjoy both type checkers.
I believe one of their guiding principles was that they wanted all the syntax to be valid Ruby, because they did not want it to become a separate Ruby interpreter. So they were pretty limited in the syntax available to them.
I believe they don't want to just strip out the annotations because Sorbet also does run time type checking. So to get all the features they wanted, they had to either write a new interpreter or use valid Ruby.
One thing I never really figured out with Sorbet is how it would work if I wanted to distribute a gem with type checked code. A typed gem would necessarily have to depend on the sorbet gem. Wouldn't this mean library users have no choice but to opt into type checks always being run in this library? (Is this why sorbet-runtime exists?)
Yeah, the gem would depend on sorbet-runtime, and the library author could configure sorbet to not run any checks in production if desired (or to have any errors log instead of throw).
You can configure things like this globally and/or for each method call.
Eg;
# turn off all runtime checks
T::Configuration.default_checked_level = :never
# turn off runtime checks for one method
sig {returns(String).checked(:never)}
def foo; :wont-raise; end
Personally if I were authoring a gem I'd leave the runtime checks on except in hot paths, so my users get quick feedback when they pass the wrong thing.
In any case, the library author can get the benefits of static and runtime typing, and their users will get nice static typing if they use sorbet. Users also get nice runtime typing for the library if the author chooses to leave it on for them. The overhead is usually small.
You can sort that out easily by doing something like:
module T
module Sig
def sig *args
end
end
# You'd need to stub out a few more things here.
end
begin
require 'sorbet-runtime'
rescue LoadError
end
Basically as far as what I can tell from just having briefly looked at Sorbet, you could quite easily stub out the bare minimum to allow people to choose whether to pull in the full thing or not. It'd be nice if they provided a gem that did that.
Yeah I agree with this. They cite the typescript compiler, which in addition to supporting .d.ts files also supports compiling regular JS in additon to separate TS files in the same project. I think this would have been a better approach for backward compat as well, so that users could upgrade to versions szupporting static typing and incrementally change projects one file at a time (leaving existing code intact).
I'm not sure how tools that use RBS without inline syntax will handle these situations, but to be honest I expect the community to adopt Sorbet in practice anyway. It's very fast and battle-hardened in production at Stripe and several other large companies.
Edit: Like, seriously. Either the local var is populated by something coming in externally (which is then typable) or, unless your code is too complex / large, it should be easy to see everywhere it's used, and then why would you need that additional typing info?
One big use-case of types is the sanity-check that the value is what you think it is.
A classic example of where I might have an inline type annotation in Rust is when I'm doing a non-trivial chain of Future/Result combinators in the middle of a function. It doesn't take much code for your understanding to desync from reality. Annotating "Result<String, IOError>" inline both documents to others what this intermediate value is but also creates better, local errors as the chain is modified.
Complex stuff does generally get factored out into functions, but at the same time, it's nice when you're the one who decides when it makes sense to extract code rather than a limitation of the typing syntax. Those things don't always line up.
Because when you see the benefit of type annotations (I’m not saying that’s objective, just if you do go that route) you want to add type information to as much as possible. Leaving them off because you want to is one thing. Not being able to is an unnecessary limitation.
While that’s true, that’s not what I’m talking about. I’m talking about the communicative benefit of type annotations. If you get the benefit from seeing the types, you don’t want them to be inferred. You use them as a reading tool.
If something is untyped in Sorbet, you can give it a type with `T.let`. So if the return value of function `foo` is untyped, but you have a high degree of confidence that it will return a `String`, you can do `ret = T.let(foo, String)`
Right. TypeScript also doesn't require changing files and everything is opt in but you can add them inline.
If the author thinks that's the biggest benefit, I'm inclined to think the ruby community doesn't seem to have enough eyes these days in the core development.
`.h` files are not something to emulate! External interfaces should be generated by tools where needed.