Skip to content

refactor: split Brushless.res per module to make the package treeshakeable - #7

Merged
josher8a merged 1 commit into
masterfrom
refactor/treeshakeable-module-split
Jul 31, 2026
Merged

josher8a merged 1 commit into
masterfrom
refactor/treeshakeable-module-split

Conversation

@josher8a

@josher8a josher8a commented Jul 31, 2026 •

Copy link
Copy Markdown
Owner

Problem

Importing one 7-line constructor pulled in 9,314 B of a 9,658 B library. sideEffects: false alone did not fix it. Two causes:

  1. genType emitted every export as BrushlessJS.Condition.equals. A two-level member access on a namespace object cannot be proven side-effect free, so unused exports survived and each one pinned a whole module. That shape was forced by keeping every module inside one .res file.
  2. The public API was namespace objects. An object property is not a droppable unit, so C.equals retained all 20 condition functions.

Changes

  • Split src/Brushless.res into one file per module. genType now emits the single-level ConditionJS.equals form, which is individually droppable.
  • Added a per-module entry point for each. Same call syntax, now treeshakes:
    import * as C from 'brushless/condition'
  • rescript.json gets namespace: true, so ReScript's Brushless.Condition.* paths keep working. It emits no JS.
  • src/index.ts remains a compatibility barrel with every name it exported before.
  • Bundler swapped from tsup to tsdown (rolldown). tsup could not produce a treeshakeable multi-entry build either way: with splitting: true entries pull shared chunks in via bare import './chunk-*.mjs' statements and consumers keep the whole chunk (5,262 B whether one function is used or all of them), and with splitting: false entries shake but duplicate every shared leaf module (10,109 B for a consumer using all builders, worse than importing everything). rolldown emits named re-exports, so entries share code and still shake.
  • Added sideEffects: false and a prepublishOnly build hook. dist/ is gitignored but published, and the checked-out copy was a month stale with a Marshaller module that no longer exists in source.

Measured

esbuild, ESM, minified, un-gzipped. Whole library is ~9.4 KB.

Import Before After
one AttributeName helper 9,314 B 73 B
one Condition helper 9,314 B 92 B
Condition.build + deps 9,314 B 3,750 B
KeyCondition.build + deps 9,314 B 2,623 B
conditions + projection + register + paths 9,314 B 4,419 B
every builder via subpaths 9,314 B 5,204 B
root import, one name 9,314 B 506 B
root import, everything 9,658 B 9,372 B

Existing code gets most of this with no changes: the root import went from 9,314 B to 506 B with the specifier untouched. Per-module entries still win when you reach for a module object, since C.equals from the root costs 5,468 B against 92 B through brushless/condition.

Breaking (ReScript consumers only)

The shared types moved out of the root module into Brushless.Types:

  • Brushless.attributeValue_ becomes Brushless.Types.attributeValue_
  • Brushless.attributeValue becomes Brushless.Types.attributeValue
  • Brushless.comparator becomes Brushless.Types.comparator

Unchanged: Brushless.Condition.*, the C/K/U/P aliases, and Condition.Overload. TypeScript consumers are unaffected, since the root import still exports all three. The one reference in the repo (examples/DefaultMarshaller.res) is updated here.

namespace: true cannot re-expose values at the namespace root. The alternative, a hand-written facade with namespace: false, would leak Condition, Register, Types, C, K, U, P into every ReScript consumer's global namespace.

Output extensions are now .mjs and .cjs with .d.mts and .d.cts declarations. Import specifiers are unchanged.

Verified

  • 101 tests pass, 0 fail, against unchanged spec files
  • examples/ compiles; Overload_test is 12 PASS / 0 FAIL
  • Strict nodenext consumer typechecks with skipLibCheck off, covering subpaths, the barrel, and the compat type aliases
  • ESM and CJS both produce correct expressions and register state at runtime
  • dist/ has zero external imports; the Primitive_option helper stays inlined
  • npm pack: 150 files, 175 kB unpacked, 0 spec files

Notes for review

  • tsdown requires Node ^22.18.0 || >=24.11.0 to run. CI is fine: the build job pins Node 22, publish uses 24, and the 18/20 matrix jobs only run bun test. engines.node: >=18 is unchanged for consumers, but local builds on Node 18 or 20 no longer work.
  • @rescript/runtime is referenced solely because of the deprecated KeyCondition.Any variant, which forces ReScript to box sk?: skCondition. Dropping Any would remove that dependency entirely. Left alone here since it breaks anyone passing sk: K.any.
  • examples/Expression_test.res has no assertions. It is a print-only smoke script, not a test.

…eable

Nothing could be dropped from this package before: importing one 7-line
constructor pulled in 9,314 B of a 9,658 B library. Two causes, both fixed.

genType emitted every export as `BrushlessJS.Condition.equals` — a two-level
member access on a namespace object, which no bundler can prove side-effect
free, so a single import retained the whole graph. That shape was forced by
having every module nested inside one file. Splitting Brushless.res into one
file per module makes genType emit the single-level `ConditionJS.equals` form,
which is individually droppable.

The public API was also namespace *objects*, and an object property is not a
unit a bundler can drop. Each module now has its own entry point, so the same
call syntax treeshakes:

    import * as C from 'brushless/condition'   // C.equals(...)

rescript.json gets `namespace: true`, which keeps the ReScript-side
`Brushless.Condition.*` paths working and emits no JS. src/index.ts stays as a
compatibility barrel exporting every name it did before.

Bundler switched from tsup to tsdown (rolldown). tsup could not produce a
treeshakeable multi-entry build: with splitting on, entries pull shared chunks
in via bare `import './chunk-*.mjs'` statements and consumers retain the whole
chunk; with it off, entries shake but duplicate every shared leaf module.
rolldown emits named re-exports, so entries share code and still shake — and
the root import became treeshakeable too, which it never was under tsup.

Measured with esbuild (ESM, minified, un-gzipped):

  one AttributeName helper       9,314 B -> 73 B
  one Condition helper           9,314 B -> 92 B
  Condition.build + deps         9,314 B -> 3,750 B
  cond + proj + register + path   9,314 B -> 4,419 B
  every builder via subpaths     9,314 B -> 5,204 B
  root import, one name          9,314 B -> 506 B

Also adds sideEffects: false, and a prepublishOnly build hook — dist/ is
gitignored but published, so publishing from a stale tree shipped a stale
artifact silently.

BREAKING (ReScript consumers only): the shared types moved out of the root
module into Brushless.Types — Brushless.attributeValue_, .attributeValue and
.comparator are now Brushless.Types.*. Everything else resolves as before,
including the C/K/U/P aliases and Condition.Overload. TypeScript consumers are
unaffected; the root import still exports all three.

Verified: 101 tests pass against unchanged spec files; examples/ compiles with
Overload_test at 12 PASS / 0 FAIL; a strict nodenext consumer typechecks
against both the subpaths and the barrel with skipLibCheck off; ESM and CJS
both produce correct expressions at runtime; dist has zero external imports.
@josher8a
josher8a force-pushed the refactor/treeshakeable-module-split branch from 7548aa0 to bd0a41e Compare July 31, 2026 18:08
@josher8a
josher8a merged commit 4e6b50e into master Jul 31, 2026
6 checks passed
@josher8a
josher8a deleted the refactor/treeshakeable-module-split branch July 31, 2026 18:40
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.

1 participant