Basilic
Architecture

ESM & TypeScript Strategy

ESM-first module architecture with TypeScript v5.9.3, dual-mode exports, subpath exports, ts-reset, and tsgo (TypeScript Go compiler) for fast builds and typechecks.

Overview

This document describes the monorepo’s ESM-first strategy and how packages are authored and consumed across different runtimes (Next.js and Node/Fastify).

Key ideas:

  • ESM everywhere: packages are "type": "module" and use import/export
  • Source in monorepo, dist for publishing: workspace development imports TypeScript source; publishing switches to dist/
  • Subpath exports: consumers import from explicit subpaths (no root barrels)
  • Runtime-aware configs: Next.js and Node/Fastify have different constraints
  • Fast compilation/typecheck: we use tsgo (TypeScript Go compiler) for builds and --noEmit typechecks

ESM-First Architecture

All packages use "type": "module" in package.json. This enables:

  • Native ESM imports (import/export) throughout the codebase
  • No CommonJS interop overhead
  • Better tree-shaking and bundling optimization

Dual-Mode Package Exports

Packages use a dual-mode export strategy for optimal developer experience and publishing:

Development Mode (Monorepo)

Packages expose TypeScript source for workspace consumption. Two patterns exist:

Pattern Aimport points to source (packages like @repo/core, @repo/react):

{
  "type": "module",
  "exports": {
    ".": { "types": "./src/index.ts", "import": "./src/index.ts" }
  }
}

Pattern Bsource condition for bundlers, node/import for Node (packages like @repo/utils, @repo/sentry, @repo/email, @repo/notif):

{
  "exports": {
    "./async": {
      "types": "./src/async/index.ts",
      "source": "./src/async/index.ts",
      "import": "./dist/async/index.js",
      "node": "./dist/async/index.js",
      "default": "./dist/async/index.js"
    }
  }
}
  • Next.js (webpack): resolves source first to consume TypeScript directly.
  • Node / serverless (Vercel, etc.): resolves node or import to compiled dist/. The explicit node and default conditions ensure serverless runtimes always get the compiled output, avoiding ERR_MODULE_NOT_FOUND when resolvers prefer non-standard conditions.

Publishing Mode (npm)

The prepack script transforms exports to compiled dist/ before publishing. Subpath exports are preserved.

{
  "exports": {
    "./async": {
      "types": "./dist/async/index.d.ts",
      "import": "./dist/async/index.js"
    }
  },
  "files": ["dist"]
}

Process (high level):

  1. prepack runs before pnpm pack/publish
  2. Package is built (ESM + .d.ts)
  3. prepare-publish.mjs switches all export paths from src/dist/
  4. postpack restores the original exports after packing

Directory/Subpath Exports

Packages use subpath exports for better tree-shaking and explicit imports:

Pattern:

{
  "exports": {
    "./async": { "types": "./src/async/index.ts", "import": "./src/async/index.ts" },
    "./web3": { "types": "./src/web3/index.ts", "import": "./src/web3/index.ts" },
    "./components/*": { "types": "./src/components/*.tsx", "import": "./src/components/*.tsx" }
  }
}

Rules:

  • ✅ Always use subpath imports: import { delay } from "@repo/utils/async"
  • ❌ Never use root imports: import { delay } from "@repo/utils" (no root export)
  • ✅ Use directory patterns for UI: import { Button } from "@repo/ui/components/button"

Runtime consumption

Next.js apps (App Router)

Next.js apps import TypeScript source directly using package interpolation:

Configuration:

// next.config.mjs
export default {
  transpilePackages: ['@repo/ui', '@repo/core', '@repo/react', '@repo/sentry', '@repo/utils'],
  serverExternalPackages: ['import-in-the-middle', 'require-in-the-middle'],
  webpack: config => {
    // Resolve "source" condition so Next.js uses TypeScript source from workspace packages
    config.resolve.conditionNames = ['source', ...(config.resolve.conditionNames ?? ['...'])]
    // Allow .js imports to resolve to .ts/.tsx in transpiled packages
    // Merge with existing extensionAlias if present to preserve Next.js defaults
    const existingExtensionAlias = config.resolve.extensionAlias || {}
    config.resolve.extensionAlias = {
      ...existingExtensionAlias,
      '.js': ['.ts', '.tsx', '.js', '.jsx'],
      '.jsx': ['.tsx', '.jsx'],
    }
    return config
  },
}
// tsconfig.json
{
  "compilerOptions": {
    "paths": {
      "@repo/ui/*": ["../../packages/ui/src/*"],
      "@repo/core": ["../../packages/core/src/index.ts"],
      "@repo/core/*": ["../../packages/core/src/*"]
    }
  }
}

How It Works:

  1. conditionNames: ['source', ...] makes Next.js prefer the source export when packages define it
  2. extensionAlias lets .js imports resolve to .ts/.tsx in transpiled packages
  3. TypeScript path mappings resolve @repo/* to src/ for packages that need them
  4. transpilePackages ensures workspace packages are transpiled during build
  5. No pre-build step required – Fast refresh works with direct TypeScript source

Rules:

  • ✅ Add all workspace packages to transpilePackages array
  • ✅ Use TypeScript path mappings for direct src/ imports
  • ✅ Import from package names (not relative paths): import { Button } from "@repo/ui/components/button"
  • ❌ Don't use relative paths across packages: import { Button } from "../../packages/ui/src/components/button"

Node.js/Fastify apps

Node.js apps use native ESM and typically run TypeScript in development via tsx (Node ESM loader), while builds/typechecks use tsgo.

Development:

{
  "scripts": {
    "dev": "node --watch --import tsx server.ts"
  }
}

Build / watch / typecheck (tsgo):

{
  "scripts": {
    "build:ts": "tsgo",
    "watch:ts": "tsgo -w",
    "checktypes": "tsgo --noEmit"
  }
}

What is tsgo?

tsgo is the TypeScript Go compiler (preview tool as of 2025). We use it because it’s fast and works well as the default tool for monorepo compilation and --noEmit typechecks. Known limitations: incomplete declaration (.d.ts) emit, gaps with some downlevel emit targets, watch-mode caveats (watch:ts), and incompatibility with the legacy TypeScript compiler API. The scripts build:ts, watch:ts, and checktypes are affected. Contributors should avoid relying on unsupported emit modes or legacy-API tooling integrations until these gaps are resolved.

TypeScript config (Node ESM):

{
  "compilerOptions": {
    "module": "NodeNext",
    "moduleResolution": "NodeNext"
  }
}

Rules:

  • ✅ Use tsx (or equivalent) for dev execution when you need to run TS directly
  • ✅ Use tsgo for builds and --noEmit typechecks (default across apps/packages)
  • ✅ Configure moduleResolution: "NodeNext" for proper ESM support
  • ✅ Import workspace packages normally – dev resolves to src/ (tsx); built output resolves to dist/ (Pattern B packages include node/default conditions for serverless)

Import Extension Rules

Extensionless imports (import "./utils/delay") work only when using moduleResolution: "bundler" (e.g., Next.js) or tooling that supports extensionless resolution. With moduleResolution: "NodeNext" (used for Fastify/Node ESM), Node requires explicit .js extensions for relative imports.

In TypeScript source:

  • import { delay } from "./utils/delay.js" (required for NodeNext/strict ESM)
  • import { delay } from "./utils/delay" (bundler environments only—Next.js, etc.)
  • import { delay } from "./utils/delay.ts" (don't use .ts extension)

Best Practice:

  • Use .js extensions for NodeNext/strict ESM (Fastify, Node apps)
  • Omit extensions only in bundler-based setups (moduleResolution: "bundler")
  • Never use .ts extensions in imports

TypeScript Strategy

The monorepo uses TypeScript v5.9.3 with strict mode enabled and enhanced type safety via @total-typescript/ts-reset.

Shared TypeScript Configuration

Packages extend shared TypeScript configs (strict defaults + runtime-specific overrides).

Base Configuration (base.json):

  • Strict mode enabled
  • ES2022 target
  • ESNext modules
  • Bundler module resolution (for Next.js/bundler environments)
  • noUncheckedIndexedAccess enabled (safer array/object access)
  • ts-reset integration for enhanced type safety

Next.js Configuration (nextjs.json):

  • Extends base config
  • Next.js plugin support
  • Bundler module resolution
  • JSX preserve mode
  • No emit (Next.js handles compilation)

React Library Configuration (react-library.json):

  • Extends base config
  • React JSX support
  • Declaration files for library distribution

ts-reset Integration

@total-typescript/ts-reset is configured globally to enhance TypeScript's built-in type safety:

Key effects:

  • Some “unsafe by default” APIs become unknown until validated
  • TypeScript nudges you toward boundary validation (e.g. Zod in app code, TypeBox in Fastify)

Module resolution

Bundler Resolution (Next.js, React libraries):

  • moduleResolution: "bundler" - Optimized for bundlers
  • Works with ESM imports and subpath exports
  • TypeScript resolves to source files via path mappings

Node Resolution (Fastify, Node.js apps):

  • moduleResolution: "NodeNext" - Native ESM support
  • May require explicit file extensions in strict ESM contexts
  • Works with a TypeScript runtime (tsx) in development

Rules:

  • ✅ Use bundler resolution for Next.js apps and React libraries
  • ✅ Use NodeNext resolution for Node.js/Fastify apps
  • ✅ Exceptions allowed for infrastructure tooling (Pulumi, Anchor/Solana)

On this page