Skip to content

String casing helpers contain totality, option-handling, Unicode, and round-trip defects #6734

Description

@gcanti

Summary

An audit of the public casing APIs in packages/effect/src/String.ts found:

  1. snakeToCamel can return undefined despite declaring string, and both
    snakeToCamel and snakeToPascal can throw on string inputs.
  2. noCase publicly exposes splitRegExp and stripRegExp, but ignores both
    options.
  3. noCase invokes its transform callback once for an empty input, treating
    the empty string as one word.
  4. capitalize and uncapitalize operate on a UTF-16 code unit rather than a
    Unicode character, making their runtime results disagree with their
    Capitalize<T> / Uncapitalize<T> return types.
  5. The documented snake_case / camelCase schema round-trip loses numeric
    word boundaries.

The audit also found two areas where behavior is not fixed by existing tests:

  • The general casing helpers use ASCII-only tokenization and differ
    substantially from Lodash for apostrophes, diacritics, non-Latin scripts,
    and emoji.
  • The older specialized converters do not canonicalize repeated or surrounding
    separators and do not recognize acronym or digit word boundaries.

For behavior not specified by existing Effect tests, this report uses Lodash
4.18.1 as prior art.

Environment

  • Branch: main
  • Commit: 6d2a942ed
  • Lodash reference: 4.18.1

Confirmed defects

1. snakeToCamel and snakeToPascal are not total

The implementation uses unchecked indexing:

export const snakeToCamel = (self: string): string => {
  let str = self[0]
  for (let i = 1; i < self.length; i++) {
    str += self[i] === "_" ? self[++i].toUpperCase() : self[i]
  }
  return str
}

Minimal reproduction:

import * as String from "effect/String"

String.snakeToCamel("")     // undefined
String.snakeToCamel("foo_") // throws TypeError
String.snakeToCamel("__")   // throws TypeError

String.snakeToPascal("")     // throws TypeError
String.snakeToPascal("foo_") // throws TypeError
String.snakeToPascal("__")   // throws TypeError

snakeToCamel("") violates its declared return type. The other inputs violate
the expectation that a function typed as (self: string) => string is total
over strings.

An exhaustive corpus of 4,681 short strings found 521 non-string or throwing
inputs for each function.

Following Lodash prior art, the proposed behavior is:

String.snakeToCamel("")         // ""
String.snakeToCamel("foo_")     // "foo"
String.snakeToCamel("_foo")     // "foo"
String.snakeToCamel("foo__bar") // "fooBar"

String.snakeToPascal("")         // ""
String.snakeToPascal("foo_")     // "Foo"
String.snakeToPascal("_foo")     // "Foo"
String.snakeToPascal("foo__bar") // "FooBar"

Lodash explicitly canonicalizes surrounding and repeated separators; for
example, _.camelCase("__FOO_BAR__") === "fooBar".

References:

2. noCase ignores splitRegExp and stripRegExp

The public API exposes:

splitRegExp?: RegExp | ReadonlyArray<RegExp>
stripRegExp?: RegExp | ReadonlyArray<RegExp>

However, the implementation only reads delimiter and transform, then
unconditionally passes the module-level SPLIT_REGEXP and STRIP_REGEXP to
normalizeCase.

Minimal reproduction:

import * as String from "effect/String"

String.noCase("helloWorld", { splitRegExp: [] })
// expected: "helloworld"
// actual:   "hello world"

String.noCase("hello-world", { stripRegExp: /_/g })
// expected: "hello-world"
// actual:   "hello world"

Neither option currently has regression coverage.

The RegExp | ReadonlyArray<RegExp> signatures also need to be implemented
consistently: normalizeCase currently accepts an array for splitting but only
one regex for stripping.

3. noCase transforms a phantom empty token

The final normalization step performs:

result.slice(start, end).split("\0").map(transform).join(delimiter)

For an empty input, "".split("\0") is [""], so the callback is invoked once
even though the input contains no words.

Minimal reproduction:

import * as String from "effect/String"

let calls = 0

const output = String.noCase("", {
  transform: () => {
    calls++
    return "called"
  }
})

console.log(output) // "called"
console.log(calls)  // 1

Proposed behavior:

output === ""
calls === 0

The same rule should apply to inputs containing only separators, such as
"---".

Lodash's compound casing implementation reduces an empty word list from an
empty accumulator and therefore does not manufacture an empty word.

4. capitalize and uncapitalize are Unicode type-unsound

Both functions transform self[0]. For astral characters, self[0] is only
the first UTF-16 surrogate rather than the first Unicode character.

Minimal reproduction using Deseret letters:

import * as String from "effect/String"

String.capitalize("𐐨foo")
// actual:         "𐐨foo"
// typed/expected: "𐐀foo"

String.uncapitalize("𐐀FOO")
// actual:         "𐐀FOO"
// typed/expected: "𐐨FOO"

TypeScript evaluates the corresponding intrinsic types as:

type A = Capitalize<"𐐨foo">     // "𐐀foo"
type B = Uncapitalize<"𐐀FOO">   // "𐐨FOO"

The runtime result therefore contradicts the declared literal return type.

Lodash's equivalent upperFirst and lowerFirst implementations split
Unicode symbols before transforming the first character.

References:

5. Numeric snake segments break the documented schema round-trip

SchemaTransformation.snakeToCamel states that the transformation is
round-trippable for standard snake case and camel case.

Numeric word segments do not round-trip:

import * as String from "effect/String"

const decoded = String.snakeToCamel("foo_2_bar")
// "foo2Bar"

const encoded = String.camelToSnake(decoded)
// "foo2_bar"

The underscore before 2 is lost.

This is also inconsistent with the newer general casing helpers, which
explicitly treat letter/digit boundaries as word boundaries.

Either numeric segments should round-trip, or the documentation should state
that snake-case segments beginning with digits are outside the supported
domain.

Behavior gaps using Lodash as prior art

These cases are not fixed by existing Effect tests. They may require a
compatibility decision because changing them could affect existing consumers.

ASCII-only tokenization in the general casing helpers

The shared strip expression is:

/[^A-Z0-9]+/gi

This affects:

  • noCase
  • pascalCase
  • camelCase
  • constantCase
  • configCase
  • kebabCase
  • snakeCase

Examples:

Input Effect camelCase Lodash camelCase
"don't stop" "donTStop" "dontStop"
"déjà vu" "dJVu" "dejaVu"
"日本語 テスト" "" "日本語テスト"
"Straße" "straE" "strasse"

Lodash removes apostrophes before tokenization, deburrs Latin-1 and
Latin Extended-A characters, removes combining marks, and uses Unicode-aware
word recognition.

References:

Changing the tokenizer would alter seven public APIs and should be reviewed
separately from the totality fixes.

Specialized converters do not canonicalize boundaries

Using Lodash as the fallback specification:

Function and input Effect Lodash prior art
snakeToCamel("foo__bar") "foo_bar" "fooBar"
snakeToKebab("foo_") "foo-" "foo"
kebabToSnake("--foo--") "__foo__" "foo"
camelToSnake("xmlHTTPResponse") "xml_h_t_t_p_response" "xml_http_response"
pascalToSnake("XMLHttpRequest") "x_m_l_http_request" "xml_http_request"
camelToSnake("field2Value") "field2_value" "field_2_value"

The older helpers may have been intended as literal, narrow conversions rather
than general normalizers. Their behavior should be fixed by tests before
changing the implementation.

configCase is not idempotent

There is no direct Lodash equivalent for configCase.

String.configCase("field2value")  // "FIELD2VALUE"
String.configCase("FIELD2VALUE") // "FIELD2_VALUE"

The first pass preserves the lowercase digit-letter group. Uppercasing creates
a digit-uppercase boundary that is recognized on the second pass.

The current documentation does not state whether configCase must be
idempotent. A test should explicitly settle this invariant.

Test coverage gaps

The current String.test.ts suite contains tests for every casing helper, but
most are happy-path examples.

  • Empty input is tested only for toUpperCase, toLowerCase, capitalize,
    and uncapitalize.
  • No specialized converter tests leading, trailing, or repeated separators.
  • No acronym cases are covered.
  • Specialized converters have no digit cases.
  • noCase tests delimiter, but not transform, splitRegExp, or
    stripRegExp.
  • No Unicode casing tests exist outside the locale wrappers.
  • Locale wrappers are tested only using ASCII and the default locale, despite
    locale-specific JSDoc examples.
  • There are no direct type tests for the literal-preserving casing APIs.
  • Schema transformation tests cover only the simple "a_b" <-> "aB" case.

Proposed regression coverage

Priority 0: totality

strictEqual(String.snakeToCamel(""), "")
strictEqual(String.snakeToCamel("foo_"), "foo")
strictEqual(String.snakeToCamel("_foo"), "foo")
strictEqual(String.snakeToCamel("foo__bar"), "fooBar")

strictEqual(String.snakeToPascal(""), "")
strictEqual(String.snakeToPascal("foo_"), "Foo")
strictEqual(String.snakeToPascal("_foo"), "Foo")
strictEqual(String.snakeToPascal("foo__bar"), "FooBar")

Priority 1: noCase options and empty input

  • Exercise splitRegExp as both a single regex and an array.
  • Exercise stripRegExp as both a single regex and an array.
  • Test both data-first and data-last forms.
  • Verify that transform is not called when there are no tokens.
  • Verify empty and separator-only inputs.

Priority 1: Unicode runtime/type agreement

  • Add runtime tests for astral case-mappable characters.
  • Add type tests showing that the runtime output agrees with
    Capitalize<T> / Uncapitalize<T>.

Priority 2: semantic compatibility

  • Acronyms such as XMLHttpRequest and xmlHTTPResponse.
  • Numeric boundaries such as field2Value, foo_2_bar, and v2_api.
  • Leading, trailing, and repeated separators.
  • Apostrophes and contractions.
  • Latin diacritics and combining marks.
  • Non-Latin scripts.
  • Explicit locale-sensitive examples from the JSDoc.
  • Decide whether configCase must be idempotent.

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions