Skip to main content
  1. Posts/

Sharing Is Caring

·1948 words·10 mins
Photograph By Elaine Casap
Blog JavaScript Web Development
Table of Contents

The Copy-Paste That Broke Production
#

You have a frontend and a backend. They both need a User type. So you define it in both places. A week later, someone adds a role field to the backend type. The frontend doesn’t know about it. The API returns data the frontend can’t handle. Users see a blank screen. The fix takes 30 seconds. Finding the cause takes 2 hours.

This is the problem shared packages solve. One definition, multiple consumers. Change it once, everything updates. TypeScript catches mismatches at compile time instead of production.

The Three Packages Every Monorepo Needs
#

In my BHVR project , the packages/ directory has three shared packages. Each solves a different code-sharing problem.

Types: The Contract Between Frontend and Backend
#

This is the most impactful shared package. It contains Zod schemas that serve double duty — runtime validation AND TypeScript type inference:

// packages/types/src/index.ts
import { z } from "zod";

export const todoSchema = z.object({
  title: z.string().min(1, "Title is required"),
  description: z.string().optional(),
  completed: z.boolean().default(false),
});

// Type inferred from schema — always in sync
export type Todo = z.infer<typeof todoSchema>;

The schema validates data at runtime (API request bodies, form inputs). The type is inferred from the schema — you never manually define a type that could drift from validation. One source of truth for what a Todo is, used everywhere:

// Backend: validate incoming request
import { todoSchema } from "@repo/types";
app.post("/api/todos", zValidator("json", todoSchema), async (c) => {
  const data = c.req.valid("json"); // typed as Todo, validated at runtime
});

// Frontend: type the API response
import type { Todo } from "@repo/types";
const todos: Todo[] = await fetch("/api/todos").then((r) => r.json());

If someone renames title to name in the schema, both the backend validation and the frontend types break at compile time. Not in production. Not at 2 AM.

UI: Your Internal Component Library
#

The second package is a shared UI library — React components used across frontend apps:

// packages/ui/src/index.ts
export * from "./components/ui/button";
export * from "./components/ui/card";
export * from "./components/ui/input";
export * from "./components/ui/dialog";
export * from "./lib/utils";

The interesting part is the dual export pattern in package.json:

{
  "exports": {
    ".": "./src/index.ts",
    "./button": "./src/components/ui/button.tsx",
    "./card": "./src/components/ui/card.tsx",
    "./styles/globals.css": "./src/styles/globals.css"
  }
}

This enables two import styles:

import { Button, Card } from "@repo/ui"; // barrel (convenient)
import { Button } from "@repo/ui/button"; // direct (better tree-shaking)

Barrel imports are convenient for development. Direct imports are better for production bundles because the bundler doesn’t need to parse the entire package to find what you’re using.

One thing that tripped me up initially: the UI package uses peerDependencies for React, not dependencies:

{
  "peerDependencies": {
    "react": "^19.2.1",
    "react-dom": "^19.2.1"
  }
}

Why? If the UI package bundles its own React and the app bundles its own React, you get two React instances. Hooks break. State doesn’t share. Everything looks fine until it doesn’t. Peer dependencies tell the package manager: “I need React, but the consuming app should provide it.”

Config: Consistency Without Copy-Paste
#

The third package is shared ESLint configuration:

{
  "name": "@repo/eslint-config",
  "exports": {
    ".": "./eslint.config.js",
    "./create-config": "./create-config.js"
  }
}

Every app and package in the monorepo imports the same lint rules:

// apps/web/eslint.config.js
import config from "@repo/eslint-config";
export default config;

One set of rules. Change it in the config package, every app picks it up on the next lint run. No more “this project uses semicolons but that one doesn’t” inconsistencies.

But the real value is the create-config.js pattern — a factory function that lets each app extend or override the base config:

// packages/eslint-config/create-config.js
import antfu from "@antfu/eslint-config";

export default function createConfig(options, ...userConfigs) {
  return antfu(
    {
      type: "app",
      typescript: true,
      formatters: true,
      stylistic: { indent: 2, semi: true, quotes: "double" },
      ...options, // per-app overrides
    },
    {
      rules: {
        "ts/consistent-type-definitions": ["error", "type"],
        "no-console": ["warn"],
        "node/no-process-env": ["error"], // force env var validation
        "unicorn/filename-case": ["error", { case: "kebabCase" }],
      },
    },
    ...userConfigs,
  ); // additional per-app rules
}

The base config enforces the standards (double quotes, semicolons, kebab-case files, no raw process.env). Each app can extend it:

// apps/web/eslint.config.js — uses defaults
import config from "@repo/eslint-config";
export default config;

// apps/api/eslint.config.js — overrides for backend
import createConfig from "@repo/eslint-config/create-config";
export default createConfig({ type: "lib" }, {
  rules: { "no-console": "off" }  // backend can console.log
});

This took a few attempts to get right. The first version was a flat config object that couldn’t be extended — every app got exactly the same rules. The factory function pattern solved it by separating “shared defaults” from “per-app overrides.”

TypeScript Config: The One That Fought Back
#

TypeScript configuration in a monorepo is where I spent the most time debugging. The root tsconfig.json defines shared compiler options AND project references:

{
  "compilerOptions": {
    "target": "ESNext",
    "module": "ESNext",
    "moduleResolution": "bundler",
    "strict": true,
    "noUncheckedIndexedAccess": true,
    "verbatimModuleSyntax": true,
    "paths": {
      "@repo/types": ["./packages/types/src"],
      "@repo/ui": ["./packages/ui/src"],
      "@repo/gateway": ["./backend/gateway/src"]
    }
  },
  "files": [],
  "references": [
    { "path": "./packages/types" },
    { "path": "./backend/auth" },
    { "path": "./backend/gateway" },
    { "path": "./frontend/client" }
  ]
}

Each package extends the root and adds composite: true (required for project references):

// packages/types/tsconfig.json
{
  "extends": "../../tsconfig.json",
  "compilerOptions": {
    "composite": true,
    "declaration": true,
    "declarationMap": true,
    "outDir": "./dist"
  },
  "include": ["src/**/*"]
}
// frontend/client/tsconfig.json
{
  "extends": "../../tsconfig.json",
  "compilerOptions": {
    "composite": true,
    "paths": {
      "@/client/*": ["./src/*"],
      "@/ui/*": ["../../packages/ui/src/*"]
    }
  },
  "references": [
    { "path": "../../packages/types" },
    { "path": "../../backend/gateway" }
  ]
}

The gotchas that cost me hours:

composite: true is required for any package referenced by another. Without it, TypeScript project references silently fail — the IDE shows red squiggles but tsc might still compile.

paths must be defined in the root AND the consuming package. The root paths help the IDE resolve imports globally. The per-package paths help tsc resolve them during compilation. Miss either one and you get “module not found” errors that only show up in specific contexts.

references must explicitly list dependencies. If frontend/client imports from @repo/types, the client’s tsconfig.json needs { "path": "../../packages/types" } in its references array. Miss this and type-checking works in the IDE (because of root paths) but fails in CI (because tsc --build follows references).

files: [] in the root tsconfig. Without this, TypeScript tries to compile everything from the root, including node_modules in some cases. Setting files: [] with only references tells TypeScript: “don’t compile anything yourself, just delegate to the referenced projects.”

The gateway’s tsconfig was particularly tricky because it uses Hono’s JSX (not React’s):

{
  "compilerOptions": {
    "jsx": "react-jsx",
    "jsxImportSource": "hono/jsx",
    "types": ["bun-types"]
  }
}

Different JSX runtime, different type definitions, different compilation target — all extending the same base config. This is exactly why per-package tsconfig overrides matter.

The Audit: What’s Standard and What’s Not
#

After getting everything working, I went back and checked my setup against TypeScript’s official project references docs and the Nx guide on managing TS packages in monorepos . Here’s what I found:

What’s correct:

PracticeWhy It Matters
composite: true in all sub-packagesRequired for project references to work
declaration: true + declarationMap: trueProvides type info across package boundaries
files: [] in root tsconfigPrevents root from compiling anything directly
references in root AND consuming packagesTells tsc --build the dependency graph
moduleResolution: "bundler"Modern standard for Bun/Vite projects
strict: true with noUncheckedIndexedAccessBest practice for catching bugs at compile time

What could be cleaner:

My root tsconfig.json serves double duty — it defines shared compilerOptions AND lists references. The standard practice is to split this into two files:

tsconfig.base.json   → shared compilerOptions (extended by sub-packages)
tsconfig.json        → only references + files: [] (used by tsc --build)

Why? When sub-packages extends the root tsconfig that also has references, they technically inherit those references. It’s usually harmless — Turborepo handles build order regardless — but if you ever hit circular reference errors, this combined file is the first thing to check. For my project’s size, it works fine. For a larger monorepo with 20+ packages, the split is worth it.

The paths duplication is intentional:

Root paths point to source files (./packages/types/src) for IDE resolution. Per-package paths help tsc --build resolve imports during compilation. This looks redundant but solves different problems — the IDE and the compiler resolve modules differently, and both need to find your packages.

One subtlety: root paths point to source (src/), but compiled output lives in dist/. This works because moduleResolution: "bundler" and Bun handle the resolution. In a pure tsc setup without a bundler, you’d need paths pointing to dist/ for build resolution — another reason the split tsconfig.base.json approach is cleaner at scale.

References must be manually maintained:

Every time you add a package or create a new dependency between packages, you need to update the references array in both the root tsconfig and the consuming package’s tsconfig. Miss one and tsc --build doesn’t know about the dependency. Some tools (Nx , moonrepo ) auto-generate these from package.json dependencies. With Turborepo, it’s manual. Not a dealbreaker, but something to be aware of as the monorepo grows.

How workspace:* Actually Works
#

The glue that makes this possible is the workspace protocol:

{
  "dependencies": {
    "@repo/types": "workspace:*",
    "@repo/ui": "workspace:*"
  }
}

workspace:* tells the package manager: resolve this to the local package, not from npm. It creates a symlink — changes to @repo/types are immediately visible in every consumer without publishing, installing, or rebuilding.

This is what makes the monorepo workflow fast. Edit a type in packages/types/, save the file, and the frontend immediately sees the change. No version bump. No npm publish. No npm install. Just save and go.

Export Strategy: Source vs Compiled
#

You have a choice in how shared packages expose their code:

Source exports (export raw TypeScript, consumer bundles it):

{ "main": "./src/index.ts", "types": "./src/index.ts" }

No build step for the package. Changes reflect instantly. But the consumer’s bundler must handle TypeScript transpilation.

Compiled exports (build first with tsc, export JS + declarations):

{ "main": "dist/index.js", "types": "dist/index.d.ts" }

Faster consumer builds (already compiled). Can be published to npm. But requires Turborepo to manage the build order and caching.

In my project, the types package uses compiled exports (it’s small, fast to build, and might be extracted later). The UI package uses source exports (components are consumed only within the monorepo, and Vite handles transpilation).

The Anti-Patterns
#

The God Package
#

One packages/shared/ that contains types, utils, components, config, and 500 other files. Changing a utility function invalidates the Turborepo cache for UI components that don’t use it. Split by concern — types, ui, config, utils — so cache invalidation is granular.

Phantom Dependencies
#

Using a package without declaring it as a dependency. It works locally because a sibling package installed it and it got hoisted to the root node_modules/. It breaks in CI, in Docker, or when a teammate cleans their node_modules.

import { debounce } from "lodash"; // not in this package's package.json!

pnpm catches this with strict dependency resolution. Bun is more permissive. Regardless of your package manager, always declare what you use.

Circular Dependencies
#

@repo/ui depends on @repo/types
@repo/types depends on @repo/ui    // circular!

Turborepo can’t determine build order. The fix: extract the shared part into a third package, or restructure so dependencies flow in one direction.

When to Extract a Package
#

Not everything needs to be a package. The rule I follow:

  • Used by 2+ apps/services? → Extract it
  • Used by 1 app but likely to be shared? → Keep it in the app, extract when needed
  • Used by 1 app and app-specific? → Leave it in the app

Premature extraction creates packages that only have one consumer. That’s overhead without benefit. Extract when duplication actually happens, not when you think it might.

Aaron Yong
Author
Aaron Yong
Building things for the web. Writing about development, Linux, cloud, and everything in between.

Related

One Repo to Rule Them All
·1171 words·6 mins
Photograph By Heather Wilde
Blog JavaScript Web Development
Turborepo, monorepo architecture, and why rebuilding everything on every change is a waste of everyone’s time
Buntime Funtime
·1331 words·7 mins
Photograph By Cesar Carlevarino Aragon
Blog JavaScript Web Development
Bun as a runtime, package manager, and the all-in-one promise — from benchmarks to a real project
The Contract
·785 words·4 mins
Photograph By Romain Dancre
Blog Software Engineering Web Development
REST API design patterns that save your future self from debugging nightmares