I'm currently writing the follow up to noelrappin.com/blog/2024/08/wh

I'm almost talking myself into trying a hybrid approach where you don't static type the arguments to methods but you do type return values.

The idea here is that if you are running a “be open in what you accept" setup where you are coercing values anyway, setting a type on the result of the coercion gives you some tool benefit without loosing flexibility…

/1

3
Share
Share on Mastodon
Share on Twitter
Share on Facebook
Share on Linkedin
Noel Rappin

I'm about 65% sure this is an artifact of how I have the example set up rather than something that would really work in practice.

(I suspect that you would lose flexibility, not in the original method, but in whatever code is doing the type coercion.)

1
3mo
Daniel Diekmeier

@noelrap I have no experience with types in Ruby, but in TypeScript, I do it the opposite way: Type the arguments, but almost always let the tooling infer the return type. I feel this ensures I always have good autocompletion and the return types are better/stricter than if I typed them myself.

I feel like relying on coercion would be more difficult once you have more custom types that don‘t easily convert into each other.

(But JavaScript also does not have the nice coercion capabilities.)

1
3mo
PointlessOne

@noelrap OK, I have some opinions.

Static typing does prevent simple errors, but in even a moderately complex case, you can’t count on it for all your data validation needs.

I think this is the key contention point. A type system totally can express valid data but this is just not how we're used to write Ruby/Rails code.

In Rails (AR) you're supposed to represent both exiting but invalid and valid object by the same type. For example, we have a User class. A user record is always a User instance regardless of the data validity. Valid and invalid user records should have different behaviour and conceptually they do but we still keep them in the same class.

A proper typed approach would separate the two. We’d have UnvalidatedUser and User. UnvalidatedUser would allow partial data, invalid data, etc. And it would not allow doing anything that requires a valid user. E.g. checkout wouldn't accept an UnvalidatedUser instance. So a typical Rails flow would first dump params into UnvalidatedUser, then run all the validations and if everything is fine we get a proper User out of it:

class UnvalidatedUser
def validate() -> Either(User, Array<ValidationError>)
end

I use Either here because RBS, unfortunately, doesn't encode exceptions but in a typical Ruby it might as well raise a ValidationError or return a User.

So instead of a typical

def create
user = User.new(user_params)
if user.valid?
user.save
else
render :new, locals: { user: }
end
end

We'd have something like this:

def create
unvalidated_user = UnvalidatedUser.new(user_params
user = unvalidated_user.validate
rescue ValidationError
render :new, locals: { user: unvalidated_user }
end

So the issue, arguably, is that we write code that is not properly structured for easy typing. In essence, we have a type that is actually a superposition of two types and we can not separate them at any given point in the code.

That said, I don't think your example is completely hopeless.

Under the post’s premise we want to make sure that the user has an address before we can checkout. The solution is rather simple:

class HandleShipping
def send_item_to_(address: Address) -> void)
end

The issue is that now all users have to have an address at all times. Which bring us back to the unrepresented UnvalidatedUser type.

1
3mo
Replies