Improving User Messages
Better Messages Please
The new product owner was determined to make an impression on the site as quickly as possible, so she produced a list of "quick wins" for us to work on. Number one on her list was the quality of our error messages.
Like pretty much every piece of software, our error messages suck. When users enter data, we validate it and tell them if there were any problems. We display messages like "1 error(s) were found". It's down to programmer laziness of course. And the wrong kind of laziness at that. But it seemed to be a simple enough thing to fix.
I moved the ticket into "in progress" and reached for Lingua::EN::Inflect, only to find something interesting: Lingua::EN::Inflect is now in maintenance mode and has been superceeded by a new module called Lingua::EN::Inflexion. The notice in Lingua::EN::Inflect says that the new module "offers a cleaner and more convenient interface, has many more features (including plural->singular inflexions), and is also much better tested". It's written by Damian Conway, so those claims seem likely to be accurate. I settled down to read the documentation.
Singulars and Plurals
The problem with the message is that it doesn't know whether it needs to use singular or plural versions of the words in the text. Lingua::EN::Inflexion makes it easy to start with a singular noun and get the plural version.
use Lingua::EN::Inflexion;
my @singulars = qw[cow pig sheep];
foreach (@singulars) {
say "$_ -> ", noun($_)->plural;
}
Which gives us:
cow -> cows
pig -> pigs
sheep -> sheep
We can also go in the other direction.
use Lingua::EN::Inflexion;
my @plurals = qw[cows pigs sheep];
foreach (@plurals) {
say "$_ -> ", noun($_)->singular;
}
This outputs:
cows -> cow
pigs -> pig
sheep -> sheep
You can also ask the object whether its invocant was singular or plural.
use Lingua::EN::Inflexion;
my @nouns = qw[cow pigs sheep];
foreach (@nouns) {
say "$_ is singular" if noun($_)->is_singular;
say "$_ is plural" if noun($_)->is_plural;
}
This will tell us:
cow is singular
pigs is plural
sheep is singular
sheep is plural
Notice that, unsurprisingly, "sheep" is both singular and plural.
You can also use the as_regex()
to get a regex that matches both the singular and plural versions of the noun.
use Lingua::EN::Inflexion;
my $string = 'Ermintrude is the best of all the cows';
if ($string =~ noun('cow')->as_regex) {
say $&;
}
$& will contain "cows", not "cow". And it's even cleverer than that.
use Lingua::EN::Inflexion;
my $string = 'Ermintrude is ye best of all ye kine';
if ($string =~ noun('cow')->as_regex) {
say $&;
}
Because "kine" is an obscure old plural for "cow".
If you print the value returned from noun('cow')->as_regex
, you'll see that it is (?^i:kine|cows|cow)
- so it's case insensitive too.
Verbs and Adjectives
Lingua::EN::Inflexion doesn't just work on nouns. You can inflect verbs and adjectives too using the verb()
and adj()
constructors.
my @verbs = qw[is has sits];
for (@verbs) {
say "The plural of $_ is " . verb($_)->plural;
}
Which produces:
The plural of is is are
The plural of has is have
The plural of sits is sit
And
my @adjectives = qw[my your his];
for (@adjectives) {
say "The plural of $_ is " . adj($_)->plural;
}
Which gives:
The plural of my is our
The plural of your is your
The plural of his is their
For more complex requirements, you can get the present participle, past tense and past participle of verbs.
my @verbs = qw[is has sits];
for (@verbs) {
say "The present participle of $_ is " . verb($_)->pres_part;
say "The past tense of $_ is " . verb($_)->past;
say "The past participle of $_ is " . verb($_)->past_part. "\n";
}
Which produces:
The present participle of is is being
The past tense of is is was
The past participle of is is been
The present participle of has is having
The past tense of has is had
The past participle of has is had
The present participle of sits is sitting
The past tense of sits is sat
The past participle of sits is sat
Building a Message
So now we have all of the pieces that we need to construct our message. We need to know how many errors were found. Let's assume that's in $count
. Then our code looks like this:
my $msg = "$count ";
if ($count == 1) {
$msg .= noun('error')->singular;
} else {
$msg .= noun('error')->plural'
}
$msg =. ' ';
if ($count == 1) {
$msg .= verb('was')->singular;
} else {
$msg .= verb('was')->plural'
}
$msg =. ' found';
Now you can start to see why so many systems make do with the terrible messages that we are trying to get rid of here. It's just too complicated to write code like this every time you want to display a message to the user.
You can try to make it a little simpler, I suppose.
my $cardinality = ($count == 1 ? 'singular' : 'plural');
my $msg = "$count "
. noun('error')->$cardinality
. ' '
. verb('was')->$cardinality
. ' was found';
But it's really not that much better. There has to be a better way. And, of course, that's really why I'm writing this article.
Inflecting Sentences
Lingua::EN::Inflexion exports four routines. We've seen three of them (noun()
, verb()
, and adj()
). The fourth one is called inflect()
and that's the one which solves our problem and gives us our "quick win".
The subroutine takes a single string argument, where the string contains some special markup defining how you want the string processed. This string is expanded into a new string which is then returned.
In the simplest case, you would use it like this:
use Lingua::EN::Inflexion;
for (0 .. 3) {
say inflect("<#:$_> <N:error> <V:was> found");
}
The output is:
0 errors were found
1 error was found
2 errors were found
3 errors were found
Simply by changing the number that is interpolated into the string, the noun and verb have both been changed appropriately.
We have used three of inflect()
's special mark-up tags here. <#:...>
sets and displays the number which will be used in the rest of the output and <N:...>
and <V:...>
can be used to insert nouns and verbs which will be inflected. There's a fourth tag, <A:...>
which can be used for adjectives, as you can see in this (slightly contrived) example.
use Lingua::EN::Inflexion;
for (1 .. 3) {
say inflect("The report had <#:$_> <N:authors> " .
"<A:our> recommendations are ... ");
}
Which produces the following output:
The report had 1 author my recommendations are ...
The report had 2 authors our recommendations are ...
The report had 3 authors our recommendations are ...
Improving the Output
This is already much better than the output than you get from many programs, but there are easy ways to make it better. We can start by displaying "No" rather than "0". This is achieved by adding an n
option to the <#:...>
tags. Options are added between the command character (the #
, N
, V
or A
) and the colon.
use Lingua::EN::Inflexion;
for (0 .. 3) {
say inflect("<#n:$_> <N:error> <V:was> found");
}
This gives us:
no errors were found
1 error was found
2 errors were found
3 errors were found
Some people prefer that zero items are displayed as singular rather than plural. We can accommodate that by using s
instead of n
.
use Lingua::EN::Inflexion;
for (0 .. 3) {
say inflect("<#s:$_> <N:error> <V:was> found");
}
The output then changes to:
no error was found
1 error was found
2 errors were found
3 errors were found
If you would rather have "a" or "an" when the count is one, then you can use a
(and you can stack options, so you can use it in addition to either n
or s
).
use Lingua::EN::Inflexion;
for (0 .. 3) {
say inflect("<#na:$_> <N:error> <V:was> found");
}
Which gives us:
no errors were found
an error was found
2 errors were found
3 errors were found
It's quite common to spell out the numbers when the count is small. If you use the w
option, then inflect()
will do that for numbers up to 10.
use Lingua::EN::Inflexion;
for (0 .. 2, 9 .. 11) {
say inflect("<#naw:$_> <N:error> <V:was> found");
}
The output is:
no errors were found
an error was found
two errors were found
nine errors were found
ten errors were found
11 errors were found
Finally, you can use f
to get fuzzy descriptions of the numbers.
use Lingua::EN::Inflexion;
for (0, 1, 2, 4, 7, 10) {
say inflect("<#f:$_> <N:error> <V:was> found");
}
Which gives us:
no errors were found
one error was found
a couple of errors were found
a few errors were found
several errors were found
many errors were found
Finally - A Real Quick Win
Users have become used to the "1 error(s) was found" style of message because it is ubiquitous. And that's because fixing the problem isn't exactly hard, it's just tedious. There's too much code to write to fix one small niggle in the user experience. Mostly, we think it's not worth the effort.
But the inflect()
subroutine fixes that. Now getting it right is as trivial as it should be. That's the right kind of laziness. Damian was obviously getting bored of writing all of that code every time he wanted a grammatically satisfying user message, so he decided to fix the problem by writing a solution that he (and, through the wonder of CPAN, everyone else) could use to easily produce vastly improved messages.
So our product manager got her quick win. And now yours can too.
And your users will get a much better user experience.