length: count runes, not bytes
CI / test (pull_request) Successful in 34s

MinLength/MaxLength measured len(value), so a string of 8 accented
characters (9+ bytes) failed MaxLength(8) despite the error message
promising "characters". Count with utf8.RuneCountInString so the check
matches the wording and the common-sense intent.

Still not grapheme-cluster aware (combining marks, emoji ZWJ sequences
count as several runes), which is out of scope for a stdlib-only helper.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-07 16:27:22 +01:00
co-authored by Claude Sonnet 5
parent 4483105a78
commit 4e2e05c1b9
2 changed files with 22 additions and 6 deletions
+6 -4
View File
@@ -1,15 +1,17 @@
package validate package validate
import "unicode/utf8"
var ( var (
ErrMustBeLonger = NewError("must contain at least %d characters") ErrMustBeLonger = NewError("must contain at least %d characters")
ErrMustBeShorter = NewError("must contain no more than %d characters") ErrMustBeShorter = NewError("must contain no more than %d characters")
) )
// MaxLength validates that a string is no longer than a given maximum. // MaxLength validates that a string is no longer than a given maximum.
// Length is measured in bytes (len), not runes. // Length is counted in runes, so multi-byte characters count as one.
func MaxLength(l int) func(string) error { func MaxLength(l int) func(string) error {
return func(value string) error { return func(value string) error {
if len(value) > l { if utf8.RuneCountInString(value) > l {
return ErrMustBeShorter.With(l) return ErrMustBeShorter.With(l)
} }
return nil return nil
@@ -17,10 +19,10 @@ func MaxLength(l int) func(string) error {
} }
// MinLength validates that a string is at least a given minimum length. // MinLength validates that a string is at least a given minimum length.
// Length is measured in bytes (len), not runes. // Length is counted in runes, so multi-byte characters count as one.
func MinLength(l int) func(string) error { func MinLength(l int) func(string) error {
return func(value string) error { return func(value string) error {
if len(value) < l { if utf8.RuneCountInString(value) < l {
return ErrMustBeLonger.With(l) return ErrMustBeLonger.With(l)
} }
return nil return nil
+16 -2
View File
@@ -20,7 +20,14 @@ func ExampleMinLength() {
func TestMaxLength(t *testing.T) { func TestMaxLength(t *testing.T) {
testCases := map[int]map[string]error{ testCases := map[int]map[string]error{
8: {"abcd": nil, "abcdefgh": nil, "abcd efg": nil, "abcdefghi": ErrMustBeShorter.With(8)}, 8: {
"abcd": nil,
"abcdefgh": nil,
"abcd efg": nil,
"abcdéfgh": nil, // 8 runes, 9 bytes
"abcdefghi": ErrMustBeShorter.With(8),
"abcdéfghi": ErrMustBeShorter.With(8), // 9 runes, 10 bytes
},
} }
for setup, values := range testCases { for setup, values := range testCases {
@@ -41,7 +48,14 @@ func TestMaxLength(t *testing.T) {
func TestMinLength(t *testing.T) { func TestMinLength(t *testing.T) {
testCases := map[int]map[string]error{ testCases := map[int]map[string]error{
8: {"abcd": ErrMustBeLonger.With(8), "abcdefgh": nil, "abcd efg": nil, "abcdefghi": nil}, 8: {
"abcd": ErrMustBeLonger.With(8),
"abcdéfg": ErrMustBeLonger.With(8), // 7 runes, 8 bytes
"abcdefgh": nil,
"abcdéfgh": nil, // 8 runes, 9 bytes
"abcd efg": nil,
"abcdefghi": nil,
},
} }
for setup, values := range testCases { for setup, values := range testCases {