Summary
An audit of the public casing APIs in packages/effect/src/String.ts found:
snakeToCamel can return undefined despite declaring string, and both
snakeToCamel and snakeToPascal can throw on string inputs.
noCase publicly exposes splitRegExp and stripRegExp, but ignores both
options.
noCase invokes its transform callback once for an empty input, treating
the empty string as one word.
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.
- 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:
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.
Summary
An audit of the public casing APIs in
packages/effect/src/String.tsfound:snakeToCamelcan returnundefineddespite declaringstring, and bothsnakeToCamelandsnakeToPascalcan throw on string inputs.noCasepublicly exposessplitRegExpandstripRegExp, but ignores bothoptions.
noCaseinvokes itstransformcallback once for an empty input, treatingthe empty string as one word.
capitalizeanduncapitalizeoperate on a UTF-16 code unit rather than aUnicode character, making their runtime results disagree with their
Capitalize<T>/Uncapitalize<T>return types.snake_case/camelCaseschema round-trip loses numericword boundaries.
The audit also found two areas where behavior is not fixed by existing tests:
substantially from Lodash for apostrophes, diacritics, non-Latin scripts,
and emoji.
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
main6d2a942ed4.18.1Confirmed defects
1.
snakeToCamelandsnakeToPascalare not totalThe implementation uses unchecked indexing:
Minimal reproduction:
snakeToCamel("")violates its declared return type. The other inputs violatethe expectation that a function typed as
(self: string) => stringis totalover 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:
Lodash explicitly canonicalizes surrounding and repeated separators; for
example,
_.camelCase("__FOO_BAR__") === "fooBar".References:
packages/effect/src/String.ts, aroundsnakeToCamelandsnakeToPascalcamelCasedocumentation2.
noCaseignoressplitRegExpandstripRegExpThe public API exposes:
However, the implementation only reads
delimiterandtransform, thenunconditionally passes the module-level
SPLIT_REGEXPandSTRIP_REGEXPtonormalizeCase.Minimal reproduction:
Neither option currently has regression coverage.
The
RegExp | ReadonlyArray<RegExp>signatures also need to be implementedconsistently:
normalizeCasecurrently accepts an array for splitting but onlyone regex for stripping.
3.
noCasetransforms a phantom empty tokenThe final normalization step performs:
For an empty input,
"".split("\0")is[""], so the callback is invoked onceeven though the input contains no words.
Minimal reproduction:
Proposed behavior:
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.
capitalizeanduncapitalizeare Unicode type-unsoundBoth functions transform
self[0]. For astral characters,self[0]is onlythe first UTF-16 surrogate rather than the first Unicode character.
Minimal reproduction using Deseret letters:
TypeScript evaluates the corresponding intrinsic types as:
The runtime result therefore contradicts the declared literal return type.
Lodash's equivalent
upperFirstandlowerFirstimplementations splitUnicode symbols before transforming the first character.
References:
upperFirstdocumentationlowerFirstdocumentation5. Numeric snake segments break the documented schema round-trip
SchemaTransformation.snakeToCamelstates that the transformation isround-trippable for standard snake case and camel case.
Numeric word segments do not round-trip:
The underscore before
2is 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]+/giThis affects:
noCasepascalCasecamelCaseconstantCaseconfigCasekebabCasesnakeCaseExamples:
camelCasecamelCase"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:
deburrdocumentationwordsdocumentationChanging 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:
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.
configCaseis not idempotentThere is no direct Lodash equivalent for
configCase.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
configCasemust beidempotent. A test should explicitly settle this invariant.
Test coverage gaps
The current
String.test.tssuite contains tests for every casing helper, butmost are happy-path examples.
toUpperCase,toLowerCase,capitalize,and
uncapitalize.noCasetestsdelimiter, but nottransform,splitRegExp, orstripRegExp.locale-specific JSDoc examples.
"a_b" <-> "aB"case.Proposed regression coverage
Priority 0: totality
Priority 1:
noCaseoptions and empty inputsplitRegExpas both a single regex and an array.stripRegExpas both a single regex and an array.transformis not called when there are no tokens.Priority 1: Unicode runtime/type agreement
Capitalize<T>/Uncapitalize<T>.Priority 2: semantic compatibility
XMLHttpRequestandxmlHTTPResponse.field2Value,foo_2_bar, andv2_api.configCasemust be idempotent.