Skip to content

Capitalize the first Unicode code point in String.capitalize - #9750

Merged
tim-smart merged 10 commits into
Effect-TS:mainfrom
armancharan:fix/string-capitalize-code-point
Oct 22, 2026
Merged

tim-smart merged 10 commits into
Effect-TS:mainfrom
armancharan:fix/string-capitalize-code-point

Conversation

@armancharan

@armancharan armancharan commented Oct 22, 2026 •

Copy link
Copy Markdown
Contributor

Summary

String.capitalize and String.uncapitalize read self[0], which is one UTF-16 code unit. An astral letter is a surrogate pair, so String.capitalize("𐐨foo") stayed "𐐨foo". They now case the whole first code point, producing "𐐀foo". Lone surrogates are left unchanged.

The return types stay as the built-in Capitalize<T> / Uncapitalize<T>, so existing signatures are unchanged. On TypeScript 7 these already case the first code point and match the runtime; on TypeScript 5.9/6.0 they case one code unit, so an initial astral letter keeps its original case at the type level only.

Runtime tests cover an astral letter for both functions, a lone high surrogate, and an unchanged BMP suffix ("AΣ" → "aΣ"). Type tests pin the existing generic and string return types.

Closes EFF-2772

Refs #6734. The other casing findings in that report are unchanged.

Test plan

  • pnpm test --run packages/effect/test/String.test.ts
  • pnpm test-types String.tst.ts --target '>=5.9' (TypeScript 5.9.3 and 6.0.3)
  • pnpm doctest --run packages/effect/src/String.ts
  • pnpm --dir packages/effect check
  • pnpm lint
  • pnpm jsdocs --check
  • CI

Indexing the first UTF-16 code unit left astral letters unchanged. The return type now follows Uppercase of that code point, which matches toUpperCase on TypeScript 5.9 and 7.
@changeset-bot

changeset-bot Bot commented Oct 22, 2026 •

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: a47ad84

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 33 packages
Name Type
effect Patch
@effect/opentelemetry Patch
@effect/vitest Patch
@effect/ai-anthropic Patch
@effect/ai-cloudflare Patch
@effect/ai-openai-compat Patch
@effect/ai-openai Patch
@effect/ai-openrouter Patch
@effect/ai-typesafe Patch
@effect/atom-react Patch
@effect/atom-solid Patch
@effect/atom-vue Patch
@effect/platform-browser Patch
@effect/platform-bun Patch
@effect/platform-deno Patch
@effect/platform-node-shared Patch
@effect/platform-node Patch
@effect/sql-clickhouse Patch
@effect/sql-d1 Patch
@effect/sql-libsql Patch
@effect/sql-mssql Patch
@effect/sql-mysql2 Patch
@effect/sql-pg Patch
@effect/sql-pglite Patch
@effect/sql-sqlite-bun Patch
@effect/sql-sqlite-do Patch
@effect/sql-sqlite-node Patch
@effect/sql-sqlite-react-native Patch
@effect/sql-sqlite-wasm Patch
@effect/docgen Patch
@effect/doctest Patch
@effect/graphql-generator Patch
@effect/openapi-generator Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@github-actions

github-actions Bot commented Oct 22, 2026 •

Copy link
Copy Markdown
Contributor

Bundle Size Analysis

Generated from PR build output; treat the content below as untrusted.

File Name Current Size Previous Size Difference
arbitrary-combinators.ts 39.13 KB 39.13 KB 0.00 KB (0.00%)
basic.ts 7.04 KB 7.04 KB 0.00 KB (0.00%)
batching.ts 10.91 KB 10.91 KB 0.00 KB (0.00%)
brand.ts 6.71 KB 6.71 KB 0.00 KB (0.00%)
cache.ts 11.35 KB 11.35 KB 0.00 KB (0.00%)
config.ts 22.51 KB 22.51 KB 0.00 KB (0.00%)
differ.ts 21.66 KB 21.66 KB 0.00 KB (0.00%)
http-client.ts 26.06 KB 26.06 KB 0.00 KB (0.00%)
http-router.ts 35.95 KB 35.95 KB 0.00 KB (0.00%)
logger.ts 11.72 KB 11.72 KB 0.00 KB (0.00%)
metric.ts 9.27 KB 9.27 KB 0.00 KB (0.00%)
optic.ts 6.93 KB 6.93 KB 0.00 KB (0.00%)
pubsub.ts 15.78 KB 15.78 KB 0.00 KB (0.00%)
queue.ts 12.60 KB 12.60 KB 0.00 KB (0.00%)
schedule.ts 11.60 KB 11.60 KB 0.00 KB (0.00%)
schema-bigdecimal.ts 13.84 KB 13.84 KB 0.00 KB (0.00%)
schema-binary.ts 40.57 KB 40.57 KB 0.00 KB (0.00%)
schema-class.ts 21.36 KB 21.36 KB 0.00 KB (0.00%)
schema-fromJsonSchemaDocument.ts 32.34 KB 32.34 KB 0.00 KB (0.00%)
schema-representation-roundtrip.ts 27.54 KB 27.54 KB 0.00 KB (0.00%)
schema-string-transformation.ts 15.09 KB 15.09 KB 0.00 KB (0.00%)
schema-string.ts 12.10 KB 12.10 KB 0.00 KB (0.00%)
schema-template-literal.ts 16.02 KB 16.02 KB 0.00 KB (0.00%)
schema-toArbitrary.ts 38.65 KB 38.65 KB 0.00 KB (0.00%)
schema-toCodeDocument.ts 25.75 KB 25.75 KB 0.00 KB (0.00%)
schema-toCodecJson.ts 20.60 KB 20.60 KB 0.00 KB (0.00%)
schema-toEquivalence.ts 20.77 KB 20.77 KB 0.00 KB (0.00%)
schema-toFormatter.ts 20.88 KB 20.88 KB 0.00 KB (0.00%)
schema-toJsonSchemaDocument.ts 25.47 KB 25.47 KB 0.00 KB (0.00%)
schema-toRepresentation.ts 20.87 KB 20.87 KB 0.00 KB (0.00%)
schema.ts 20.54 KB 20.54 KB 0.00 KB (0.00%)
stm.ts 13.91 KB 13.91 KB 0.00 KB (0.00%)
stream.ts 10.15 KB 10.15 KB 0.00 KB (0.00%)

@tim-smart
tim-smart marked this pull request as ready for review October 22, 2026 01:55
TypeScript 5.9's Capitalize still rewrites one UTF-16 unit, so the type tests name String.Capitalize of that pair.
@tim-smart
tim-smart enabled auto-merge (squash) October 22, 2026 02:01
auto-merge was automatically disabled October 22, 2026 02:03

Head branch was pushed to by a user without write access

@effect-bot

Copy link
Copy Markdown
Contributor

Thanks for the fix! We've removed the exported String.Capitalize / String.Uncapitalize types again (a47ad84) and kept the runtime fix, for two reasons we confirmed on TypeScript 5.9.3, 6.0.3 and 7:

  1. Pair detection by casing isn't sound. Comparing Lowercase<Head + Next> with Lowercase<Head> + Lowercase<Next> also differs for ordinary BMP text: Greek final sigma makes String.Uncapitalize<"AΣ"> resolve to "aς", while String.uncapitalize("AΣ") returns "aΣ".
  2. Any custom return type breaks existing callers. The local aliases replace the built-in types in the signatures, so a string input now returns string instead of Capitalize<string>, and generic wrappers stop compiling:
    declare const input: string
    const a: Capitalize<string> = String.capitalize(input) // error
    const cap = <T extends string>(s: T): Capitalize<T> => String.capitalize(s) // error
    A custom alias is never assignable to the intrinsic Capitalize<T> for generic T, so a better boundary check wouldn't fix this.

TypeScript 7 already cases the first code point in the built-in Capitalize / Uncapitalize, so the type/runtime gap for astral letters only remains on 5.9/6.0, and the PR description notes it. The type tests now pin the existing signatures and the "AΣ" case so this stays covered.

@tim-smart
tim-smart enabled auto-merge (squash) October 22, 2026 02:22
@tim-smart
tim-smart merged commit 7e60c1a into Effect-TS:main Oct 22, 2026
9 of 10 checks passed

This branch is waiting to be deployed

1 waiting deployment
fork — a47ad842 Waiting Oct 22, 2026 by effect-bot via approval-gate #22551
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants