Problem Statement
Object stringification in Crystal is currently handled via all objects having a #to_s
method. This works well in that you can always call obj.to_s
to get a string representation of it. However, is has some cons as well.
- It is not immediately clear that you need to implement
#to_s(io : IO) : Nil
and not#to_s : String
to customize that behavior - There is no way to know if an object has a meaningful string representation at all, mainly for custom user classes/structs since
obj.responds_to? :to_s
is alwaystrue
For example, say you are doing something with sprintf
, or anything that renders/prints data:
def render(content) : String
sprintf "(%s) - %s", UUID.random, content
end
Where you want the content in this case to be anything that has a meaningful string representation. They key word here being “meaningful”. The only type safety you can get around this is by either dropping the type restriction and call #to_s
on whatever is passed, or simply make it String
. The latter of which would require the user to manually call #to_s
on the object before passing it in, even if it overrides #to_s(io)
. Neither of these solutions prevent unintended stringification. E.g.
require "uuid"
class Foo; end
def render(content) : String
sprintf "(%s) - %s", UUID.random, content
end
render Foo.new # => (9f4a57c3-9a5f-4a2a-bcc6-a613b2b51b9a) - #<Foo:0x7f16e241eea0>
In that the Foo
object is rendered as #<Foo:0x7f16e241eea0>
which is an obvious bug that could very easily go unnoticed unless you happen to have a test case for it.
Proposal
A possible way to improve upon these issues is to have a dedicated Stringable
module that can be implemented within types to denote that they have a meaningful string representation. This would be easy enough for the majority of stdlib types, but most useful for custom user classes/structs. Ultimately this can allow for more type safety and reduce the amount of bugs due to unexpected stringification.
Continuing with our previous example, you could update the type restriction of #render
to be def render(content : Stringable) : String
. This would now raise a compile time error if you tried to pass something that doesn’t have a meaningful string representation. In regards to sprintf
itself, you could in theory update its signature to be def sprintf(format_string, *args : Stringable) : String
to obtain a similar guarantee. Where sprintf "%s - %s - %s", 123, "bob", UUID.random
would be fine, but sprintf "%s - %s - %s", 123, "bob", Foo.new
wouldn’t be unless it implements the module.
The other benefit would be better enforcing users implement the correct override #to_s(io) : Nil
versus #to_s : String
.
Considerations
The main implementation issue around this is that because all types inherently implement to_s(io : IO) : Nil
, you can’t just have the module be abstract def to_s(io : IO) : Nil
. Nor can we just drop the default implementation as that would (probably?) break existing code.
Because of this you’d have to do something like:
- Have the module implement
to_s(io : IO) : Nil
itself, but require you to define liketo_str(io : IO) : Nil
- Some macro logic to assert the method is overridden in the including type, or a child of it
- Some macro logic to assert the method’s definition isn’t an ancestor/default implementation
- ???
Future
Longer term, if so desired, the module could also be made required if we ever wanted to remove the global default to_s
method.
Summary
- Add a new module interface that denotes a type has a meaningful string representation
- No automatic/implicit casts of
Stringable
types toString
parameters - Backwards compatible, added to common stdlib types
- Prevents incorrect
#to_s
definition