Skip to content

feat: native character encoding with Effect streams - #8133

Draft
joepjoosten wants to merge 7 commits into
Effect-TS:mainfrom
joepjoosten:feat/character-encoding
Draft

feat: native character encoding with Effect streams#8133
joepjoosten wants to merge 7 commits into
Effect-TS:mainfrom
joepjoosten:feat/character-encoding

Conversation

@joepjoosten

@joepjoosten joepjoosten commented Sep 8, 2026

Copy link
Copy Markdown
Contributor

Summary

Draft a native character-set conversion module with synchronous incremental codecs, typed Effect failures, and backpressured Effect Stream encode/decode/transcode operators.

  • 94 canonical encodings: UTF-8, explicit UTF-16/32 LE/BE, 81 single-byte encodings, eight multibyte codecs, and aliases.
  • Mapping tables imported from iconv-lite commit 2472166ea5a4825ca091b9550713c403852a566b (local 1.0.0-alpha.2 checkout), with full MIT attribution and a regeneration script.
  • Local TypeScript conversion; no added runtime dependency. iconv-lite is a development-only reference for tests. Node native iconv bindings stay in an external benchmark environment.
  • Fresh incremental state per stream run, split-character handling, normal EOF flushing, typed strict errors, cancellation and upstream failure preservation.
  • Optional Node Buffer string-construction fast path, with a portable fallback.

Draft decisions / scope

The requested source location is packages/effect/src, so this currently exports effect/CharacterEncoding, not a separately published @effect/encoding package. Core versus standalone packaging needs review. Existing effect/Encoding remains unchanged.

This is not a compatibility-complete replacement. UTF-7, CESU-8, automatic UTF-16/32 endianness, pseudo-codecs, transliteration and iconv's wider stateful codec set are outside this initial draft. UTF-16 decoding follows TextDecoder / iconv-lite alpha replacement semantics rather than 0.7.x raw Buffer semantics. Codecs now have explicit entry points, with an optional complete registry. Further mapping compression, broader malformed-input corpora and multibyte performance need work. No MSSQL dependency changes are included.

Explicit codecs and selective bundles

Conversion takes codec values, not globally resolved strings:

import * as CharacterEncoding from "effect/CharacterEncoding"
import * as Utf8 from "effect/encoding/Utf8"
import * as Windows1251 from "effect/encoding/Windows1251"

const encoded = CharacterEncoding.encode("Привет", Windows1251.encoding)
const converted = source.pipe(
  CharacterEncoding.transcodeStream(Windows1251.encoding, Utf8.encoding)
)

const registry = CharacterEncoding.makeRegistry([
  Utf8.encoding,
  Windows1251.encoding
])
// Runtime labels, without importing other codecs:
const encoding = registry.resolveUnsafe("windows-1251")
  • Core operators import no codec or mapping modules.
  • Each codec imports only its own mapping data, loaded with that module. Lookup arrays/tries are initialized lazily once; incremental state stays fresh per conversion.
  • makeRegistry accepts only explicit codecs, supports normalized aliases, and offers typed resolve plus synchronous resolveUnsafe. Unknown labels do not trigger implicit loading.
  • effect/encoding/All is an explicit opt-in to every codec and alias.
  • No dynamic import or asynchronous loading requirement is imposed on synchronous protocol-codec use cases.

esbuild and Rolldown bundle checks verify zero mapping modules for UTF-8, exactly one for CP1251 (also for a restricted UTF-8/CP1251 registry), and 89 for All. They execute the generated bundles too. The report includes exact byte/gzip sizes and reproduction.

Benchmarks

Node 24.20.0, macOS ARM64; approximately 64 KiB source text, 4,093-byte stream chunks, 100 ms warmup, five rotating-order rounds of 150 ms per provider. Full output equality is checked before timing. Synchronous rows use Unsafe APIs; streaming rows include each framework's pipeline overhead.

Throughput was rerun after the explicit-codec refactor, with label resolution outside timing. Effect throughput remains roughly in line with the first draft. Results are still mixed: single-byte conversion is promising; multibyte conversion and most streaming workloads trail iconv-lite. Published 0.7.3 has a substantial UTF-16 speed advantage through raw Buffer paths. The alpha CP1251 streaming baseline was unusually slow in this rerun; the report flags this variability rather than attributing an apparent win to the refactor.

Complete stable/alpha tables, all raw samples, limitations and reproduction commands:

Verification

  • 110 targeted tests pass on Node, Bun and Deno.
  • 7,556 additional differential checks against the supplied alpha checkout pass across all 94 encodings.
  • Type tests pass on TypeScript 5.9.3 and 6.0.3 (14 assertions).
  • Regenerating descriptors and data, then formatting, produces no diff.
  • The initial portable fallback probe passed without global Buffer.
  • pnpm lint-fix, pnpm --filter effect check, and git diff --check pass.
  • Repository-wide pnpm check reports TS6133 in unchanged packages/effect/typetest/Schedule.tst.ts:80 (metadata).
  • pnpm jsdocs --check reports two issues in unchanged Logger.ts (tag ordering and unresolved consolePretty link); none in the new module.

Keeping this draft for API/placement review, broader compatibility testing and performance work.

@changeset-bot

changeset-bot Bot commented Sep 8, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 726906a

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

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

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 Sep 8, 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 34.16 KB 34.16 KB 0.00 KB (0.00%)
basic.ts 6.87 KB 6.87 KB 0.00 KB (0.00%)
batching.ts 9.95 KB 9.95 KB 0.00 KB (0.00%)
brand.ts 6.45 KB 6.45 KB 0.00 KB (0.00%)
cache.ts 10.77 KB 10.77 KB 0.00 KB (0.00%)
config.ts 21.43 KB 21.43 KB 0.00 KB (0.00%)
differ.ts 20.23 KB 20.23 KB 0.00 KB (0.00%)
http-client.ts 21.93 KB 21.93 KB 0.00 KB (0.00%)
http-router.ts 33.39 KB 33.39 KB 0.00 KB (0.00%)
logger.ts 10.88 KB 10.88 KB 0.00 KB (0.00%)
metric.ts 9.02 KB 9.02 KB 0.00 KB (0.00%)
optic.ts 6.70 KB 6.70 KB 0.00 KB (0.00%)
pubsub.ts 15.10 KB 15.10 KB 0.00 KB (0.00%)
queue.ts 11.85 KB 11.85 KB 0.00 KB (0.00%)
schedule.ts 10.96 KB 10.96 KB 0.00 KB (0.00%)
schema-binary.ts 39.42 KB 39.42 KB 0.00 KB (0.00%)
schema-class.ts 19.98 KB 19.98 KB 0.00 KB (0.00%)
schema-fromJsonSchemaDocument.ts 30.40 KB 30.40 KB 0.00 KB (0.00%)
schema-representation-roundtrip.ts 26.18 KB 26.18 KB 0.00 KB (0.00%)
schema-string-transformation.ts 13.60 KB 13.60 KB 0.00 KB (0.00%)
schema-string.ts 11.08 KB 11.08 KB 0.00 KB (0.00%)
schema-template-literal.ts 15.36 KB 15.36 KB 0.00 KB (0.00%)
schema-toArbitrary.ts 33.70 KB 33.70 KB 0.00 KB (0.00%)
schema-toCodeDocument.ts 24.50 KB 24.50 KB 0.00 KB (0.00%)
schema-toCodecJson.ts 19.18 KB 19.18 KB 0.00 KB (0.00%)
schema-toEquivalence.ts 19.32 KB 19.32 KB 0.00 KB (0.00%)
schema-toFormatter.ts 19.43 KB 19.43 KB 0.00 KB (0.00%)
schema-toJsonSchemaDocument.ts 23.51 KB 23.51 KB 0.00 KB (0.00%)
schema-toRepresentation.ts 19.48 KB 19.48 KB 0.00 KB (0.00%)
schema.ts 19.18 KB 19.18 KB 0.00 KB (0.00%)
stm.ts 12.80 KB 12.80 KB 0.00 KB (0.00%)
stream.ts 9.83 KB 9.83 KB 0.00 KB (0.00%)

@joepjoosten

Copy link
Copy Markdown
Contributor Author

Implemented in 4ad6253: explicit codec entry points, isolated registries, and an opt-in All registry.

How it works

import * as CharacterEncoding from "effect/CharacterEncoding"
import * as Utf8 from "effect/encoding/Utf8"
import * as Windows1251 from "effect/encoding/Windows1251"

const encoded = CharacterEncoding.encode("Привет", Windows1251.encoding)
const converted = source.pipe(
  CharacterEncoding.transcodeStream(Windows1251.encoding, Utf8.encoding)
)

const registry = CharacterEncoding.makeRegistry([
  Utf8.encoding,
  Windows1251.encoding
])
registry.resolveUnsafe("cp1251") // Windows1251.encoding
registry.encodingExists("cp932") // false
// registry.resolve(label) provides a typed Effect lookup instead.

The core operators import no mapping tables. Each codec module loads only its own mapping data. Typed lookup arrays and decoding tries are still initialized lazily on first use and cached; each conversion has independent incremental state.

For applications needing all runtime labels, explicitly import effect/encoding/All. That deliberately includes every codec. There is no global registration, implicit loading, or dynamic-import requirement.

Verified bundle boundaries

Minified browser ESM; sizes are bytes, with gzip in parentheses. These standalone decoder entry points include retained Effect infrastructure.

Entry point Mapping modules esbuild 0.28.2 Rolldown 1.2.7
Operators only 0 14,199 (5,202) 13,290 (4,796)
UTF-8 only 0 16,987 (6,326) 16,028 (5,831)
CP1251 only 1 16,646 (6,333) 15,711 (5,904)
UTF-8 + CP1251 registry 1 97,801 (33,229) 94,242 (30,731)
All encodings 89 484,458 (292,691) 481,040 (289,117)

The restricted registry retains Effect infrastructure for its typed lookup method; it still imports only one mapping table. These cases expose different APIs, so sizes are not interchangeable workload comparisons.

The checked-in bundle harness asserts the imported mapping files and executes output bundles from both bundlers. Throughput benchmarks were also rerun; Effect rates remain roughly similar, with baseline variability called out in the report.

Verification: 110 tests pass on Node/Bun/Deno, type tests pass on TS 5.9.3/6.0.3, 7,556 differential checks pass, and regeneration is reproducible. Source check and lint pass. Full local checks still report the previously documented unchanged Schedule/Logger issues.

API explanation, measurements and reproduction.

@joepjoosten

joepjoosten commented Sep 8, 2026

Copy link
Copy Markdown
Contributor Author

Multibyte codec optimization: what, why, and benchmark results

Following the runtime experiments, we retained only the runtime-independent multibyte optimization.

Status: committed and pushed in ccb487d06. The results below compare this implementation against 4ad6253ca.

What changed

  • Multibyte codecs without GB18030 ranges or multi-character encoding sequences use a simpler encoder loop, avoiding sequence-map lookups and range-handling branches.
  • Buffer capacities are derived once from mapping data instead of allocating four bytes per UTF-16 code unit for every encoder and two UTF-16 units per input byte for every decoder.
  • Bounds include encode-only additions and decode-only entries, including subtrees excluded from encoding. Decoder capacity remains sufficient for malformed-input replacement. GB18030 encoding retains its four-byte bound.
  • Complex codecs retain their existing sequence/range conversion paths, including incremental buffering.

There are no runtime checks, new dependencies, or public API changes.

Why retain this optimization?

The simpler encoder gives substantial gains on Node, Bun, and Deno. It reduces work and over-allocation within the codec, without relying on runtime-specific APIs or changing Effect Streams.

The other two experiments are not included in the committed implementation: the Buffer UTF-16LE path regressed on Deno, and direct CP1251-to-UTF8 transcoding regressed on Bun. Neither is part of these measurements.

Fresh benchmark results

Median throughput in MiB/s, baseline → candidate. Percentages are medians of paired changes, so they need not equal the ratio of the displayed, rounded medians.

Workload Node Bun Deno
CP932 encode 419 → 590 (+42.4%) 768 → 1112 (+45.3%) 771 → 1125 (+46.8%)
CP932 decode 203 → 207 (+1.7%) 332 → 407 (+20.8%) 280 → 277 (-0.4%)
CP932 ASCII encode 169 → 255 (+52.5%) 305 → 471 (+54.4%) 269 → 391 (+45.3%)
CP932 ASCII decode 132 → 134 (+1.4%) 267 → 310 (+16.7%) 182 → 186 (+1.1%)
GB18030 encode 269 → 280 (+3.7%) 420 → 438 (+3.5%) 322 → 322 (+0.6%)
GB18030 decode 185 → 182 (-1.5%) 286 → 328 (+14.6%) 228 → 231 (+1.5%)
Big5-HKSCS encode 339 → 345 (+1.1%) 490 → 513 (+3.2%) 419 → 432 (+2.6%)
Big5-HKSCS decode 198 → 198 (+0.4%) 326 → 385 (+18.1%) 269 → 268 (-0.3%)
CP932 → UTF8 stream 129 → 135 (+3.2%) 254 → 294 (+16.2%) 160 → 162 (+1.1%)

Method: macOS ARM64; Node 24.20.0, Bun 1.4.0, Deno 2.9.6. Baseline and candidate run in the same process, with five alternating 150 ms measurement rounds after 250 ms warmups per implementation. Runtime processes run sequentially. Inputs are approximately 64 KiB UTF-8-equivalent text; streaming uses 4,093-byte chunks. String-input throughput uses UTF-8 byte length; byte-input throughput uses actual input length. Output equality is checked before timing; timed one-shot cases consume output lengths, and streams consume chunks without collecting them. No I/O or cold-start costs are included.

The clearest reproducible gain is CP932 encoding: approximately 42–47% across all three runtimes. Decoder and streaming benefits vary. Small changes may be noise; these microbenchmarks are not application-wide speedup claims.

Correctness and validation

  • 114 targeted tests pass with Vitest launched on Node, Bun, and Deno.
  • 542,336 differential checks pass against the baseline on Node: every isolated two-byte input for all eight multibyte codecs, all BMP code units in blocks, and seeded malformed/chunk-split input in strict and replacement modes.
  • Added regression tests cover allocation sizes, encode-only additions, astral mappings, split/unpaired surrogates, strict failures, decode-only subtrees, and multi-character sequences.
  • pnpm lint-fix, pnpm --filter effect check, and git diff --check pass.
  • Full pnpm check still fails on the pre-existing unused metadata parameter at packages/effect/typetest/Schedule.tst.ts:80.

The benchmark report, reproduction commands, and links to raw samples are now committed alongside the benchmark harness and differential checks.

@tim-smart

Copy link
Copy Markdown
Contributor

I think this will need some redesign before being considered. It feels too complex right now.

@joepjoosten

Copy link
Copy Markdown
Contributor Author

UTF-16LE Buffer encoding enabled — rerun and Deno fix

Committed and pushed in c061be462, in addition to the retained multibyte optimization.

What changed

UTF-16LE encoding now uses the available global Buffer.from(text, "utf16le"), with the existing JavaScript DataView fallback when Buffer is absent. Strict surrogate validation, cross-chunk state, and BOM handling are preserved. Results remain plain Uint8Array values: exact-sized backing stores are viewed directly, while pooled/sliced stores are copied so unrelated bytes cannot be exposed through .buffer.

UTF-16 decoding and UTF-16BE encoding are unchanged. No new dependency or runtime/version guard was added.

Deno issue and solution

The original Deno regression led to denoland/deno#36803 and the proposed fix denoland/deno#36804. The fix replaces the per-code-unit JavaScript conversion plus temporary-array copy with V8's native UTF-16 write into aligned destination buffers, preserving raw code units and safely handling unaligned destinations.

That Deno PR is still open and unmerged. Released Deno 2.9.6 still regresses with this Effect change enabled. The fixed-build results below require the proposed Deno patch; they do not describe an already released fix.

Fresh results

Effect baseline: ccb487d06 (multibyte optimization already included). Candidate: c061be462.

Median MiB/s, baseline → candidate. Percentages are medians of paired changes.

Runtime UTF-16LE encode Encode + full-byte checksum UTF8 → UTF-16LE stream
Node 24.20.0 486 → 13912 (+2715.8%) 134 → 185 (+38.2%) 304 → 666 (+121.0%)
Bun 1.4.0 938 → 5436 (+480.4%) 642 → 1383 (+114.3%) 341 → 479 (+40.4%)
Deno 2.9.6 released 740 → 213 (-71.3%) 278 → 145 (-47.9%) 336 → 154 (-54.9%)
Deno local, before fix 747 → 214 (-71.4%) 281 → 145 (-48.3%) 371 → 159 (-56.9%)
Deno local, with fix 757 → 16538 (+2081.5%) 287 → 421 (+46.6%) 373 → 725 (+96.4%)

The matched local Deno builds make the difference clear: on the fixed runtime, the Buffer path improves raw encoding throughput about 22× over the JavaScript loop, the full-byte-checksum workload by 47%, and streaming throughput by 96%. On the unpatched local runtime, the same Effect change still regresses.

Earlier results, for comparison

The earlier confirmation run used Effect baseline 4ad6253ca and the same workload sizes/timing method:

Runtime UTF-16LE encode Encode + full-byte checksum UTF8 → UTF-16LE stream
Node 498 → 13773 (+2630.3%) 135 → 186 (+36.5%) 309 → 694 (+126.8%)
Bun 934 → 5299 (+468.6%) 659 → 1444 (+119.0%) 349 → 494 (+42.2%)
Deno, without fix 767 → 220 (-71.4%) 283 → 146 (-48.4%) 345 → 163 (-53.2%)

The rerun remains broadly consistent with the previous Node/Bun gains and the old Deno regression. The patched Deno results demonstrate that the bottleneck was in Deno's Buffer conversion path, not a fundamental Effect Streams limitation.

Method and validation

  • macOS ARM64 / Apple M2 Max; Node 24.20.0, Bun 1.4.0, Deno 2.9.6.
  • Within each runtime, baseline and candidate run in one process, with five alternating 150 ms rounds after 250 ms warmups. Runtime processes run sequentially.
  • Approximately 64 KiB UTF-8-equivalent source text; 4,093-byte stream chunks. Output equality is checked before timing.
  • Raw encoding consumes output lengths; the checksum case scans every output byte; streaming consumes chunks without collecting them. These are warm microbenchmarks, not application-wide speedup claims.
  • UTF-8 encode control changes stayed within ±0.5% using paired percentages.
  • Local Deno before/fixed builds use identical Rust 1.95.0 release-lite settings: main 336da420f versus fix dbbcc3461. Published Deno has different build settings, so the matched local pair is the appropriate comparison for isolating the Deno fix. Both local binaries report version 2.9.6.
  • 116 targeted tests pass with Vitest launched on Node, Bun, released Deno, and patched Deno. A separate no-global-Buffer fallback probe passes.
  • Lint, package type check, and diff checks pass. Full workspace type check still fails on the pre-existing unused metadata parameter in Schedule.tst.ts:80.

Committed report, reproduction commands, and raw samples.

@joepjoosten

Copy link
Copy Markdown
Contributor Author

I think this will need some redesign before being considered. It feels too complex right now.

I think it got more complicated because of the registry. But it's there because when you want to limit the bundle size, you can decide to register the encodings you want to bundle yourself. Otherwise it would inflate the bundle to 500kb.

This means for example that if you know which encodings you need to support you can supply functions your own registry, which they can use to check if the encoding is supported, and use it, otherwise fail.

But the registry makes it more complicated indeed.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

4.0 enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants