A TypeScript Project Structure Guide

Directory Structure, Module Resolution and Related Configuration Options

image

Directory structures and module resolution of TypeScript projects are not complex, until you start organizing things into separate units, putting them into different directories, and experimenting with some of the configuration options discussed here.

This article provides an overall understanding of the mechanisms and configuration options that affect TypeScript project directory structures and module resolution, as well as some caveats in the hope of saving the reader from certain pitfalls. However, it is not a tutorial, and does not give detailed examples. For further details, you can find the link to the relevant part of the TypeScript handbook when a configuration option first appears, and a test project that demonstrates the features and configuration options discussed here at the end of this article.

This article assumes readers understand the basics of TypeScript and Node.js packages and module resolution. Unless otherwise mentioned, all discussed configuration options are TypeScript options, which are passed to the compiler as command-line options or set in “tsconfig.json”.

This article limits its discussion in common use cases. The TypeScript projects discussed here outputs the .js files into a directory (“outDir”) rather than a single output file (“outFile”), and the compiler uses the “node” module resolution strategy. So that the discussion does not distract readers with uncommon use cases and options only reserved for backward compatibility.

“Implementation Files” in this discussion means the TypeScript source files (.ts, .tsx) that will be compiled by the compiler into .js files. “Project Root” refers to the directory where the “tsconfig.json” locates.

General tsconfig.json Options

Option “extends

Option “extends” specifies another configuration file to inherit from, and may probably be a good starting point of your configuration. There are community maintained base configurations tuned to particular runtime environments that you can install and inherit in your projects. See project @tsconfig/bases.

Option “rootDir

“rootDir” specifies the root that is expected to contain all Implementation Files. “rootDir” is expected to contain all Implementation Files, i.e. source files that will be compiled.

By default “rootDir” is inferred as “the longest common path of all non-declaration input files”. This means if all your input source files are under “./src”, “./src” is inferred as the “rootDir”. If your source files are under both “./src” and “./test”, then “./” would be the “rootDir”.

Don’t confuse “rootDir” with “rootDirs”. They are different.

Output Options

These options set what the emitted JavaScript is like (so that they are compatible with different runtime environments), what other products are produced, and where are they outputted.

Option “outDir

If specified, the output.js (as well as .d.ts, .js.map, etc.) files will be emitted into this directory. In most TypeScript projects, “outDir” should be set. Otherwise, outputs will be emitted besides the sources, polluting the source folders.

The directory structure under “rootDir” will be preserved in “outDir”. That means the JavaScript compiled from “rootDir/pathA/pathB/moduleC.ts” will be emitted to “outDir/pathA/pathB/moduleC.js”.

Altogether, the rules are: “rootDir” contains all Implementation Files (the source files) to be compiled, and “outDir” is where the outputs are emitted. The same directory structure under “rootDir” is preserved in “outDir”. Some options discussed in this article allow a very flexible directory structure for the source files. However, under no circumstance this rule changes.

Option “target

Meant to be set according to the runtime environment that the emitted JS code will be executed in. This option determines what features in the source code (such as arrow function ()=>{}) are downleveled in the emitted JS, i.e. what features implemented in a newer version of the Javascript language should not be emitted in the output Javascript file, but rather be implemented with the syntax and features of an older version, so as to be compatible with an older runtime.

Option “target” changes the default values of options “lib”, “module”, and through option “module”, option “moduleResolution”.

Option “module

This option determines the module import & export codes in the emitted JavaScript (which is used at runtime), not the module resolution of TypeScript at compile time. The default value depends on the option “target”.

As this option affects the emitted Javascript, the value should dependen on the module system of the runtime environment where the emitted Javascript code is to be used. Nowadays, the most notable module systems are “CommonJS” and “ES Module”.

If you are using the emitted codes with Node, then the module system depends on the type of package you put the emitted Javascript code in. The type of package is determined by the “type” field in the package.json. If the nearest parent package.json lacks a “type” field or contains “type”: “commonjs”, CommonJS module system is used. If it contains “type”: “module”, then “ES Module” system is used. More about how to setup this option can be read here.

Option “declaration

If this option is set to “true”, the compiler generates type declaration “.d.ts” files. If the project is to be used by other codes, this option should be turned on.

Option “declarationMap

If “declaration” is turned on, option “declarationMap” makes the compiler emit source maps for the type declarations (.d.ts), mapping definitions back to the original “.ts” source files. The effect is when the user clicks a definition, editors (such as VS Code) can go to the original .ts file when using features like “Go to Definition”.

“package.json” Options

These options define the exposed interface of the package through exports of types, functions, values, objects, as well as the entry point that is executed when the package is imported.

Option “type”

Indicates the package’s type — is it a CommonJS package or a ES Module? Lack of a “type” field or “type” field has value “commonjs” indicates a CommonJS package. Value “module” indicates a ES Module.

If you are running your code with Node.js, this field determines what module system Node.js provides for your code. For CommonJS, “require()” is provided and available, ES Module “import” “export” statements are not. For ES Module, the reverse is true.

Option “types”, “typings”

Indicates the package’s type declaration file (.d.ts). “typings” is synonymous with “types” and both can be used. If “index.d.ts” lives at the root of the package, “types” can be omitted, although still recommended.

If the package is to be used by external codes, “types” should be indicated.

Option “main

This is the primary JavaScript entry point to the package.

Commonly, “types” points to the declaration file, and “main” points to the .js emitted from the TypeScript entry point in the “outDir”.

Option “exports

As an alternative to the “main” option, this option supports subpath exports and conditional exports. As of today under Node.js v15.0.1, both features are still experimental, therefore will not be further discussed.

Initial Implementation Files

Implementation files are source files (.ts, .tsx) to be compiled by tsc, and the outputs (.js) of the Implementation files are emitted into the “outDir”. By default, all source files under the Project Root are compiled.

File inclusion options (“include”, “exclude”, “files”) can expressly specify what files are initially included in Implementation Files. Then, during compiling, the compiler may include further Implementation Files referenced by the initial Implementation Files through module resolution (in an “import” statement), recursively. This will be discussed after the discussion of TypeScript module resolution.

Option “include

Specifies a list of filenames or file name patterns, relative to Project Root.

Option “exclude

Specifies a list of filenames or file name patterns to be skipped when resolving “include”. Note: “exclude” only works in resolving “include”. Even if a file is specified by “exclude”, the compiler may still include it in Implementation Files if the compiler resolves a module reference to it.

Option “files

Specifies a list of files to include as Implementation Files.

Module Reference Basics

Module reference is to resolve a path in an “import” statement to a file that represents the module. The representative file could be a source file (.ts, .tsx), or a type declaration (.d.ts).

General Options

Option “moduleResolution” (Module Resolution Strategy)

Option “moduleResolution” sets how TypeScript compiler resolves modules at compile time. Not to be confused with the option “module”, which sets the module system for the output .js files and affects the runtime behavior of the emitted codes.

Values of option “moduleResolution” can be “classic” or “node”. “classic” is just kept for backward compatibility, and is not discussed in this article. Value “node” is the one to be used in modern codes. All discussions here are based on “node”.

Since the default value of option “moduleResolution” can be affected by the value of option “target” or “module”, it’s best to explicitly set option “moduleResolution” to “node”.

Option “traceResolution”

This option enables the compiler to output each step of module resolution for diagnosis.

Relative and Non-relative Module Resolution

Depending on the paths in the “import” statements in TypeScript source files, module references are relative module references (starts with /, ./ or ../) and non-relative (absolute) module references.

Relative module resolution resolves a module relative to the importing file. It’s used for resolving internal modules in a package.

Here are options affecting TypeScript relative module resolution:

Option “rootDirs

Configured with a list of directories relative to the Project Root (where tsconfig.json locates), when compiler resolves relative module references, compiler treats these directories as if they were merged into one single root.

Important Note: Read “Caveats” about the inconsistency of the paths and the directory structure below.

Non-relative modules are resolved (1) relative to option baseUrl and by path mapping, if configured, (2) by mimicking the Node.js module resolution mechanism, or (3) to ambient module declarations. It’s commonly used for importing external dependencies. However “baseUrl” allows it to be used for importing internal modules as well.

Step 1: Attempt to resolve relative to “baseUrl” and “paths

This step only works when option “baseUrl” is set. “baseUrl” (relative to Project Root) points to a base directory, based on which compiler attempts to resolve non-relative module reference.

With “baseUrl”, two internal modules inside the same package can then reference each other using non-relative module reference, rather than having to use relative references, which are sometimes very cumbersome like “../../../../pathA/pathB/pathC”.

Option “paths” enables path mapping over “baseUrl” in non-relative module resolution. It maps path patterns to respective arrays of directory locations (one pattern maps to an array of locations). The directory locations are specified relative to “baseUrl”.

Providing more than one directory location in the array for one pattern allows the “fall back” mapping, which means attempts are made to resolve a module in the more than one location in turn.

Important Note: Read “Caveats” about the inconsistency of the paths and the directory structure below.

Step 2: Mimicking Node.js Module Resolution Mechanism

Suppose “/root/pathA/pathB/pathC/moduleA.ts” contains the statement “import {whatever} from ‘moduleB’ ”, TypeScript compiler, when mimicking Node.js module resolution, (1) looks up folder “node_modules” in turn under “/root/pathA/pathB/pathC”, “/root/pathA/pathB”, “/root/PathA”, “/root”, and “/”, proceeds up one directory a time, and (2) if folder “node_modules” is found, in turn looks up file “moduleB.ts”, “moduleB.tsx”, “moduleB.d.ts”, “moduleB/package.json” (containing “types” property), “@types/moduleB.d.ts”, “moduleB/index.ts”, “moduleB/index.tsx”, and “moduleB/index.d.ts”. If step (2) fails in a “node_modules” folder, the compiler returns to step (1) to continual process up directories until “/”.

Step 3: Resolve to Ambient Module Declarations

Ambient modules are type declarations of modules in type declarations (.d.ts). See “Ambient Modules” in TypeScript Handbook for more details.

Caveats in using “baseUrl”, “paths” and “rootDirs”

When none of “baseUrl”, “paths”, and “rootDirs” is used, the directory structure of the source files as well as of the emitted JavaScript (recall that directory structure in “rootDir” is preserved in “outdir”) is consistent with the Node.js module resolution mechanism. In the case of a relative module reference, the module can be resolved in the directory structure following the path in the import statement. In case of a non-relative module reference, the module should be an external one, installed under directory “node_modules” by a package manager such as npm. In the end, the compiled outputs can be directly executed by Node.js, or used by a downstream build tool.

Options “baseUrl”, “paths”, and “rootDirs” can configure TypeScript compiler to map module paths in “import” statements to non-conventional directories in file system during module resolution. “baseUrl” and “paths” allows resolution of non-relative modules based on a path not under “node_modules”, and “outDirs” allows resolution of relative modules in multiple roots virtually merged into one. Now, the directory structure under the “rootDir” is still preserved in the “outDir”, regardless of the mappings, meaning that the output directory structure is not compatible with the module paths in the “import” statements in the emitted .js files, therefore cannot be resolved directly by Node.js or similar tools.

This is not an issue of TypeScript. The assumption is that, in a source directory layout that does not match the final layout of the runtime executables, it’s the developer’s responsibility to resolve this issue, by including building steps such as copying dependencies from different locations into a final location. It’s important to note that the compiler will not perform any of these steps.

Inclusion of Referenced Implementation Files Through Module Resolution

Recall that module resolution resolves a module reference to a file that represents the module. The file can be a source file (e.g. .ts, .tsx), or a type declaration (.d.ts). A module reference can be a relative module reference, or a non-relative module reference. A relative module reference is resolved relative to the source file of the importing module. A non-relative module reference is resolved by (1) relative to the baseUrl and path mappings, (2) searching in “node_modules” folders mimicking the Node.js strategy, and (3) to an ambient module.

Now here are the rules about when a file that represents a referenced module be included in Implementation Files, meaning an output .js will be compiled and emitted into the outDir:

(1) In both relative and non-relative module resolution, if the module resolves to a type declaration file (.d.ts) or ambient module, it’s not included. The compiler is happy to use the typing information contained in that file and not look further. This includes the case if the module resolves to a package.json containing the “types” property.

(2) In non-relative module resolution, if the compiler, mimicking the Node.js module resolution, resolves to a file in a “node_modules” folder, the file is not included into Implementation Files. The module is assumed to be in an external package, is built separately, and integrated at runtime through a package manager such as npm.

(3) Otherwise the file (a source file) is included in Implementation Files, and the compiler will compile this file and emit a .js.

Incremental Compile and Project Reference

Incremental compile saves time by skipping those already compiled and not changed source files. Project reference allows building two or more related projects together, whereas the referenced project is compiled incrementally. With these two features, one monolithic project can be broken into smaller ones for better organization and faster building.

Assume that both ProjectA and ProjectB are in development. ProjectA imports and uses codes in ProjectB. When ProjectA is compiled, the developer wishes to compile ProjectB as well, but only when needed.

To achieve this, both projects need tsconfig.json in their Project Root. Then, in their configurations, ProjectA needs to refer to ProjectB using the option “reference”, and ProjectB must enable the option “composite”.

Option “reference

In ProjectA, option “reference” is configured with an array of objects. The “path” property of the objects refers to the Project Root (containing tsconfig.json) of the referenced projects (ProjectB). There’s also a “prepend” property of the objects, but that works with “outFile” only and is not discussed.

Note that this option only works when tsc is invoked with “- -build”.

Option “composite”:

By default, the value of this option is “true”. For a project being referenced by another project (ProjectB), it must be set to “true”. It enforces the following constraints to make it possible for build tools to quickly determine if a project needs rebuild without looking into the source codes:

“rootDir” defaults to project root (containing tsconfig.json)

All implementation files to be compiled must be explicitly matched by “include” and “files”, not to be included by module reference. In this way, build tools need not examine the source files to determine which files need to be built.

“declaration” defaults to true to produces type declaration (.d.ts). You may want to also enable “declarationMap” so that when the user clicks a definition in a code editor such as VS Code with the “Go to Definition” feature, the editor goes to the source file (.ts) instead of the type declaration (.d.ts).

Incremental Compilation Option “incremental” and Build Mode (-b, - - build)

Incremental compilation relies on a build information file to determine which sources need a rebuild. The location of the file can be controlled by the “tsBuildInfoFile” option. If the file is missing, tsc will do a full compilation.

Tsc command-line option “-b”, “ - -build” makes tsc build the referenced projects. Since a referenced project must turn on option “composite”, and “composite” in turn enforces “incremental”, a referenced project is always incrementally compiled. “composite” also turns on “declaration”, therefore the type declaration (.d.ts) for the referenced project is emitted. Then module reference to the referenced project is resolved to the type declaration, not to the implementation.

There are some flags that can be used in particular with “-b” or “- - build”. They are “- - dry” for a dry run, “- - clean” for cleaning up the build outputs, “- - force” to build as if all projects are out of date, and “- - watch” to build in watch mode.

Note that incremental compilation determines which source to compile based on the build information file, without checking the outputs. If the build information file from the last build is there, and no source file is changed, even if the emitted .js files are removed, incremental compilation won’t recreate them. Currently, there is an open issue about this on Github. Also, if the build information file is removed, when invoking tsc -b - -clean the emitted .js files won’t be removed. To avoid this, always remove the build information file (tsconfig.tsbuildinfo) together with the emitted .js.

Import a Referenced Project As a Module

Option “reference” only build referenced projects and does not change any behavior of TypeScript’s module reference. To import a referenced project as a module, refer to the module reference discussed above.

Caveats of Using Project Reference

The type declaration file (.d.ts) of the referenced project is generated by the compiler. This means, before the build, the import statement referencing the referenced project will show an error in code editors such as VS Code, since the declaration is missing. Sometimes, an error in VS Code won’t go away until it’s restarted.

Conclusion

With the understanding of these mechanisms and configuration options, you should be able to organize your TypeScript projects in a more flexible way. If you want to try out the features and configuration options discussed in this article, you can check this demo project and study its build process.

Further Reading

Sharing Types Between Your Frontend and Backend Applications

Enjoyed this article?

Share it with your network to help others discover it

Continue Learning

Discover more articles on similar topics