Why importing source files across your monorepo will cost you more than it saves

When working in JavaScript/TypeScript monorepos, it can be tempting to directly import source files from another package as a shortcut to skip building it. While convenient, this approach has risks and can lead to unpredictable behavior and discrepancies between development and production.

Why would devs import source files?

Developers often import source files from another package in the same monorepo to simplify and speed up development. Here are some common reasons why this shortcut can seem appealing:

  1. Avoiding the build step

    Building packages on each change or running a separate watcher can feel cumbersome and slow. Skipping this step by directly importing source files can seem like an easy way to streamline development.

  2. Flexible access to internal files

    Directly accessing specific files within a package can be convenient, as it allows you to bypass unnecessary exports and load only what you need. This might include development utilities that aren’t included in the published package.

    // Importing a package with relative paths
    import helper from '../../pkg-a/src/helper-util'
  3. Enhanced IDE experience

    Modern IDEs with IntelliSense allow quick navigation to source code by clicking on functions. In a monorepo, developers may want this functionality across packages—so that clicking on import { add } from '@org/math-utils' opens the actual add function’s source code instead of just the type declarations. Directly importing source files enables this linking to original files.

While these benefits are appealing, directly importing source files can lead to hidden costs and bypass package boundaries, causing discrepancies and potential build issues.

Dangers of importing source files

While directly importing from another package might feel convenient in development, they introduce several risks that impact project development in unexpected ways.

1. Environment discrepancies

Importing source files directly from another package skips its entire build process, leading to potential inconsistencies. Important settings like path aliases, environment variables, and plugins are applied by the build. By bypassing this step, these configurations are ignored, and your code might not work as expected.

As a simplified example, if pkg-a uses aliases defined in its build configuration, these aliases can only be resolved when pkg-a is built with the that configuration. Directly importing the source files from pkg-a means these aliases won’t be recognized, resulting in broken imports and errors.

As build configurations become more complex, so do the bugs. Bugs can appear in niche situations during development, requiring extra debugging and bandaid fixes that handle development and production environments differently, ultimately adding unnecessary complexity to your codebase.

As a relevant example to further enforce this point, Node.js recently introduced built-in type stripping to support a subset of TypeScript features. However, they decided not to strip types in dependency packages for similar reasons: loading source files from dependencies without their respective configurations can lead to complex bugs and unexpected behavior that are difficult to manage over time.

Coupling with the dev environment

Additionally, if you’re developing directly with source code, you might overlook testing the compiled production version entirely. This oversight can result in unexpected bugs once published that didn’t appear during development because you weren’t using the compiled code. For instance, the source file of the imported package may be developed to inadvertently depend on the dev environment of the consuming package, which will be missing in production.

While building packages before importing them may add some overhead, this extra step may be worth it on the long run. It simplifies considerations by conslidating the dev environment with prod, and improves the robustness of your code.

2. Growing performance costs

Performance costs often build up gradually behind the scenes.

Loading another package’s source code increases TypeScript’s processing load, adding to memory usage and computation time. This can have a bigger impact than you expect because it’s not always clear how many other files it will pull in. As projects grow, each extra source file will gradually contribute to slower builds. Even if you’re not using tsc to compile, this cost is still incurred during type checking, which can affect your IDE and cause it to be sluggish.

While this may not be an issue in smaller monorepos, larger ones will feel the effects. Node.js’s type stripping PR also highlighted performance drawbacks as a reason to discourage publishing uncompiled TypeScript files. To demonstrate, Andrew Branch from the TypeScript team shows the memory and type-checking costs of processing source .ts files from dependency packages compared to compiled .d.ts files. In a large enough monorepo, directly importing TypeScript files from other packages can lead to similar performance issues.

3. Risks of bypassing the package API

When you directly import a package’s files using relative paths, you bypass its package API:

// ❌ Importing a package with relative paths — don't do this!
import helper from '../../pkg-a/src/helper-util'

Bypassing the package API carries the same risks as using any private interface—it may break at any time without notice. Since these files aren’t intended for public use, the author might change or remove them without notice. Even if you may be the package maintainer, doing this will increase coupling across packages and make it harder to refactor or make updates to the package in the future.

Additionally, if you’re importing dev utilities, these files might not even be included when the package is published, as the publishing process typically excludes non-essential files.

Relative imports bypasses package security

Node.js introduced the exports field in package.json as a secure way to define the package interface. This field explicitly specifies which paths are accessible and prevents access to other files. However, when package files are imported using relative paths, Node.js does not recognize them as packages, bypassing the interface provided by exports entirely.

→ Confirm this behavior

→ Learn more about the exports field

Real-life analogy: cutting corners in construction

Imagine you’re constructing a building using parts crafted by specialized teams. Each part is meticulously designed, tested, and certified to meet specific standards. This ensures that the final structure is cohesive, safe, and durable. These parts are engineered to integrate seamlessly when assembled, much like dependency packages that are compiled and published with specific configurations and dependencies to function as intended.

If you attempt to “optimize” this process by taking these parts before they’ve undergone their final processing, you introduce significant risks. Critical specifications like safety, durability, and compatibility might be overlooked. The parts may not fit together properly, leading to structural weaknesses that could fail under real-world conditions—just as a missing environment variable can break entire application. Additionally, forcing these parts to fit might unintentionally create dependencies on specific tools or conditions unique to your construction site, leading to unforeseen issues once the building is in use.

This approach also slows down the project, draining resources like labor, time, and materials—much like the increased computation time and memory usage seen when importing raw source files in software development. As the project grows, these inefficiencies compound, causing delays and escalating costs. Bypassing standardized connections and interfaces further risks incompatibilities, complicates future renovations or repairs, and ultimately compromises the building’s integrity, safety, and efficiency, potentially leading to critical failures over time.

What should I do instead?

Use Workspaces

Declare dependencies on the workspace packages using your package manager’s workspace feature (pnpm, npm, yarn) and import them as packages rather than with relative paths:

// ✅ Import as a package
import helper from 'pkg-a/helper-util'

This approach ensures you’re working with the package’s public API (especially if it uses an exports field) and not accessing internal files that might be missing in the published package. (Although, if not using exports, you may still be able to access files excluded from publish.)

This approach also improves maintainability as devs can easily see which workspace packages depend on each other by checking the respective package.json files.

Build your packages

When you import a package’s source code directly, you’re likely relying on a just-in-time compiler like tsx or ts-node to compile files as they’re imported. While this feels instant and convenient, the compilation cost doesn’t go away—it’s just shifted to runtime. Over time, these repeated on-demand compilations add up, slowing down development.

Instead of trying to skip the build step, focus on optimizing the build speed and developer experience. For example, replace tsc with next-generation compilers like esbuild or SWC for faster builds (tsx and ts-node leverage these under the hood). Pair this with a watcher to automatically rebuild on file changes.

This approach ensures that files are only recompiled when changes are made, with the output acting as a cache to avoid repeated runtime compilation. It also guarantees you’re working with compiled, production-ready code, eliminating potential inconsistencies from source files. As a bonus, your CI will benefit from faster builds.

When is it okay to import source files?

As the author of tsx—a tool that allows importing TypeScript files from dependencies—I feel it’s important to acknowledge that there are exceptions where this practice might be acceptable.

As a default rule, prefer using built files. But if there are packages you feel strongly about importing source files from, evaluate each situation carefully, weighing the benefits against the potential risks.

Importing source files can be acceptable for simple packages without complex build steps. For instance, a private package with TypeScript scripts or shared configuration files that don’t require a build process will likely be safe as there are no risks of discrepancies between development and production.

To assess a package’s suitability, ask questions like:

  • Does it use features that compile differently based on configuration? (e.g., JSX, Decorators)
  • Does it involve complex build configurations? (e.g., plugins, replacing environment variables)
  • Does it mix multiple module formats across dependencies? (e.g., CommonJS and ESM)

Even in simple cases, inconsistencies can arise, such as differences in how modules are loaded. For example, importing named exports from a CommonJS file into an ESM file behaves differently between tsx’s runtime and bundled code.

If you choose to import a package’s source files, make sure you don’t import them using relative paths. Instead, use Workspaces to link the packages and configure the package exports field to point to source files under the development condition.

Ultimately, given the speed and reliability of modern compilers, the risks of skipping a build process often outweigh the benefits. For most projects—especially those with teams of developers with varying levels of experience—importing built packages remains the safer, more predictable choice.