Exceptional Value
Video transcript & code
In the last episode we grappled with how to deal with unrecognizable user input in the context of the Whole Value pattern.
We wound up with a conversion function that returned nil
as a flag for a bad value, instead of raising an exception.
def Duration(raw_value)
case raw_value
when Duration
raw_value
when /\A(\d+)\s+months\z/i
Months[$1.to_i]
when /\A(\d+)\s+weeks\z/i
Weeks[$1.to_i]
when /\A(\d+)\s+days\z/i
Days[$1.to_i]
else
nil
end
end
And, a Course
model that manages errors for its fields in a separate, parallel hierarchy to the fields themselves.
Course = Struct.new(:name, :duration) do
def duration=(new_duration)
unless self[:duration] = Duration(new_duration)
self[:duration] = new_duration
errors[:duration] = "Unrecognized duration"
end
end
def errors
(@errors ||= {})
end
end
We talked in that episode about why this is a smell, and about the coupling it introduces. I'm not going to reiterate that discussion today, and I'm also not going to reintroduce our example application. So if you haven't seen that episode yet, you might want to pause right here and then come back.
So, if this is a smell, what do we do to get rid of it?
The answer begins back at our conversion function.
In the previous episode we replaces an exception with a nil
return.
But nil
is a semantically impoverished type, and as a result we pushed more responsibilities onto the containing model object.
What if we returned something with richer meaning? But what might that be?
Well, what exactly do we need to represent here? We have some input which represents an exception to the input that we know how to convert to a valid duration.
But it's not exceptional in the sense that it represents a program error. After all, having users type in unrecognizable input is a normal part of an application's day. It's not exceptional in the sense that we should be raising an exception.
What if we were to return an Exceptional Value object?
Including the raw value that couldn't be parsed.
And a reason for the value being exceptional?
def Duration(raw_value)
case raw_value
when Duration
raw_value
when /\A(\d+)\s+months\z/i
Months[$1.to_i]
when /\A(\d+)\s+weeks\z/i
Weeks[$1.to_i]
when /\A(\d+)\s+days\z/i
Days[$1.to_i]
else
ExceptionalValue.new(raw_value, reason: "Unrecognized format")
end
end
Having sketched out the object we want, let's go implement this class.
We give it an initializer that can take a raw value, and a reason.
We make expose the reason
using a reader method.
We make it possible to ask the object if it is exceptional, which, of course, it is.
We'll see the reason for this method shortly.
And we also make the object convertible to a string, in which case we simply return the string representation of the raw, original value.
class ExceptionalValue
def initialize(raw_value, reason: "Unspecified")
@raw = raw_value
@reason = reason
end
attr_reader :reason
def exceptional?
true
end
def to_s
@raw.to_s
end
end
Now, how are we going to make use of this new type of object?
Well, first off, let's hop over to our form template.
Where previously we iterated through the course's errors
list looking for problem fields, now we just go through the course's fields asking if any are exceptional.
Then, down in the duration field markup, instead of asking the course if there's an error on that field name, we just ask the field value itself whether it is exceptional.
template :course_form do
<<~EOF
<h1>Add a course</h1>
<form action="/" method="POST">
<% course.to_h.select{|k,v| v.exceptional?}.each do |field, value| %>
<div class="toast toast-danger"><%= field %>: <%= value.reason %></div>
<% end %>
<div class="form-group">
<label class="form-label" for="name">Name</label>
<input class="form-input" type="text" name="name"
value="<%= course.name %>"/>
</div>
<div class="form-group<%= course.duration.exceptional? && ' has-danger' %>">
<label class="form-label" for="duration">Duration</label>
<input class="form-input" type="text" name="duration"
value="<%= course.duration %>"/>
</div>
<div class="form-group">
<input class="btn btn-primary" name="Create" type="submit"/>
</div>
</form>
EOF
end
Next we turn our attention to the course post handler.
Here again, we replace the query of the errors
list with a direct check of the field values, to see if any are exceptional.
post "/" do
course = Course.new
course.name = params.fetch("name")
course.duration = params.fetch("duration")
if course.values.any?(&:exceptional?)
erb :course_form, locals: {course: course}
else
course_list << course
redirect to("/")
end
end
There's a glaring problem with all of the code we've just introduced.
Course
fields may have one of several kinds of value.
When the course is brand new, the fields are nil
.
The course name is still represented as a primitive string.
Recognizable course durations are converted to Duration
child classes, like Days
.
And unrecognizable durations are converted to ExceptionalValue
objects.
But so far, we only one kind of these kinds of object responds to the exceptional?
predicate message.
require "./models"
course = Course.new
course.name # => nil
course.name = "Basketweaving 101"
course.name.class # => String
course.duration = "2 days"
course.duration # => Days[2]
course.duration = "48 hours"
course.duration
# => #<ExceptionalValue:0x0056505abe7408 @raw="48 hours", @reason="Unrecognized format">
For right now, let's address this deficiency the quick-and-dirty way.
We'll monkey-patch Object
to add the exceptional?
predicate. We'll make it return false.
class Object
def exceptional?
false
end
end
That way, ExceptionalValue
objects will return true, and every other kind of object will be considered non-exceptional.
Now we can go back to our Course
model, and get rid of all the code for tracking errors in fields.
Course = Struct.new(:name, :duration) do
def duration=(new_duration)
self[:duration] = Duration(new_duration)
end
end
Let's give this new version a whirl. We'll fire up our server, and go to the course listing.
Then we'll click "New Course". We'll enter a course name, and an unrecognizable duration.
We see the form re-rendered, and the problems reported.
When we fix our submission and re-submit, it's accepted.
So we've proven that we haven't broken anything. But behind the scenes, we've improved our design, by representing exceptional values as their own, rich data type. In the process, we got rid of the parallel hierarchy of errors, and enabled our field values to stand on their own.
This pattern isn't something I came up with. Just like the Whole Value pattern, it comes from a paper by Ward Cunningham called "The CHECKS Pattern Language of Information Integrity".
We'll be seeing more patterns from this paper in future episodes.
One code smell that's still present in this code is the monkey-patching of Object
.
This smell is indicative of a bigger problem that we have yet to address. But we'll get to that in an upcoming episode. Happy hacking!
Responses