Munchable is a pnpm workspace: two apps (an Expo app and a Next.js site plus API) and five shared packages. Not one of those packages has a build step. There is no dist anywhere in the repo, no tsup, no tsc -b, and every tsconfig.json in packages/ sets "noEmit": true.

What each package publishes is its source:

{ "name": "@munchable/rules-engine", "exports": { ".": "./src/index.ts" }, "main": "./src/index.ts", "types": "./src/index.ts", "sideEffects": false } 

That is a decision with consequences in four different runtimes, and after a year of living with it I would make it again. But the bill came in places I did not expect, and the most interesting one is a missing .ts on an import.

Why no build step

The engine that turns an ingredient list into a verdict runs on the device. The same package has to be understood by Metro, by the Next.js compiler, by the Node test runner, and by one-off scripts. Publishing compiled output would mean every one of those consumes a build artefact, which means a watch process in development, a stale dist at least once a week, and a sourcemap hop between the stack trace and the line I need to read.

Consuming source instead means a change to a shared package is live in both apps and in the tests the moment it is saved. The site's public pages and the app's screens are generated from the same content package, which is why the recipe list at munchable.app/recipes and the one inside the app cannot drift: there is no intermediate artefact for them to drift across.

The price is simple and worth saying out loud: every consumer must be able to compile TypeScript. These packages are unpublishable as they stand, and a plain-JavaScript consumer is impossible. For a private workspace that is not a cost at all. For anything you intend to put on npm it is disqualifying.

Four ways TypeScript runs in one repository

I counted, expecting two. The test scripts alone are three:

"test": "node --test 'src/**/*.test.ts'" // rules-engine, content, pricing "test": "node --experimental-strip-types --test 'src/**/*.test.ts'" // reels "test": "node --import tsx --test 'src/**/*.test.ts'" // both apps 

Plus the bundlers: Next compiles the workspace packages through its own pipeline, Metro transforms them for the app, and tsx runs the operational scripts.

The second line is a fossil. --experimental-strip-types was required on Node 22, which is what the repo pins (engines: >=22.18, .nvmrc says 22.18.0). On the Node 24 I am typing this on, the flag is a no-op: I ran that package's tests without it and got the same single passing test. Not a bug, but a line that now documents a Node version nobody is running.

The third line is not a fossil, and the reason took a minute to find.

The missing extension

Bare node --test runs the shared packages' tests happily, because those test files import like this:

import { padRect, viewRectToPhotoRect } from '../selection.ts'; 

Note the .ts. Node's ESM resolver does not guess extensions, so the extension has to be there, and TypeScript only tolerates it with "allowImportingTsExtensions": true, which every tsconfig in the repo sets. With that in place, Node's built-in type stripping runs the file directly. No loader, no transform, no config.

Run the mobile app's tests the same way and 55 of 67 pass. One file fails, and the error is not about types at all:

Error [ERR_MODULE_NOT_FOUND]: Cannot find module '.../src/lib/fitCheck' imported from .../src/lib/recipes.ts 

The test file was extension-correct. The module it imported was not: app code is written for Metro, and Metro resolves ./fitCheck to ./fitCheck.ts the way every bundler has since 2015. Node will not. So the apps need tsx (which does guess extensions) and the packages do not, and the dividing line between the two is a convention nobody thinks of as a dependency.

The lesson I took: if you want your library to be runnable by the plainest possible runtime, the extensions in your internal imports are part of that promise, not a style preference. One extension-less import anywhere in the graph moves the whole package to the other side of the line.

What 1,129 tests cost

The whole suite, every project, is just under four seconds:

Project Tests Duration
content 474 0.29 s
web 358 1.78 s
rules-engine 204 0.65 s
mobile 67 0.51 s
pricing 25 0.33 s
reels 1 0.36 s

1,129 tests in about 3.9 seconds of runner time, with no transpile step, no bundler and no DOM. Nothing here is a benchmark to boast about, it is the consequence of one decision: the test runner imports the same files the apps import, so there is nothing to compile first. The fastest test suite is the one with the shortest distance between the file and the assertion.

The one project with no test script at all is the design tokens package, which is a palette and a type. pnpm -r run test therefore reports six projects, not seven, and the seventh is not a gap in coverage so much as a file with nothing in it to be wrong.

Two configuration lines I would not have guessed

Metro, inside a pnpm workspace, needs to be told where the world is:

config.watchFolders = [workspaceRoot]; config.resolver.nodeModulesPaths = [ path.resolve(projectRoot, 'node_modules'), path.resolve(workspaceRoot, 'node_modules'), ]; 

and then, importantly, not told to stop walking up. The config carries a note about this, because turning hierarchical lookup off is the usual monorepo advice and it breaks here: pnpm keeps peer dependencies in its nested store, and Metro needs the walk-up to resolve them from inside expo.

The Next.js side is one line with a comment that is now slightly out of date:

transpilePackages: ['@munchable/rules-engine', '@munchable/pricing'], 

The site depends on three workspace packages, and two of them are named there. The build is green, which is the only evidence I have that the list is not load-bearing for the third, and that is exactly the kind of configuration I will be puzzling over in six months. A list that enumerates some of a set is worse than one that enumerates all of it, even when both work.

Was it the right call

For a private workspace with two consumers I control, yes, decisively. The thing I would warn about is not the tooling, it is that "no build step" is not actually a property of the package. It is a property of the package plus every runtime that touches it, and each of those runtimes has a slightly different idea of what TypeScript is. You can have one source of truth, but you will still be maintaining four opinions about it.

If you want to see what comes out of the end of this, the public pages generated from the content package are at munchable.app/conditions and munchable.app/answers, and the app that imports the very same files is linked from munchable.app.