TypeScript module and moduleResolution

This lesson preview is part of the Bundling and Automation in Monorepos course and can be unlocked immediately with a \newline Pro subscription or a single-time purchase. Already have access to this course? Log in here.

This video is available to students only
Unlock This Course

Get unlimited access to Bundling and Automation in Monorepos, plus 90+ \newline books, guides and courses with the \newline Pro subscription.

Thumbnail for the \newline course Bundling and Automation in Monorepos
  • [00:00 - 00:28] Now that we've talked about how Node parses export conditions in package.json, we need to look into how TypeScript does it. Our goal here is to understand how the "module" and "moduleResolution" compiler options in our TSConfig cooperate to make TypeScript understand exports, and decide which .d.ts files to use for a given import.

    [00:29 - 00:50] As a background refresher, the "module" option in TSConfig decides how each file in our source is emitted. This means that for ES Module exports, it would keep import/export keywords, while if we're exporting for CJS it would change those to requires.

    [00:51 - 01:06] Or there is a special option that leaves the code untouched. "moduleResolution", on the other hand, decides where TypeScript should be looking to resolve the types for a given import symbol.

    [01:07 - 01:26] Based on the value of moduleResolution, it might apply classic Node rules, or it might apply the Node 16 and 18 graph rules. Or it can also apply the looser bundler rules that mimic what most bundlers are doing.

    [01:27 - 01:47] Although it's worth noting that bundlers are actually not uniform in the way that they resolve files. Still, TypeScript does a best effort. When TypeScript parses a package exports field, it will always add the conditions "type" at the beginning and "default" at the end.

    [01:48 - 02:06] The remaining condition that would be in the middle between these two will depend on information combined from the settings of "module" and "moduleResolution". If we are emitting ESM, the resolver adds the "import" condition, while if we are emitting CJS, the resolver adds the "require" condition.

    [02:07 - 02:24] This is just broadly speaking, and there is a lot of details to how this actually works that you can read in TypeScript's handbook. The way to think about this is what would Node or your bundler try first?

    [02:25 - 02:46] That's what TypeScript is trying to figure out when it reads the packages you're importing, and it tries to mimic that behavior. So TypeScript tries as much as possible to match what the runtime behavior would be once your application is actually running. I'm making the font smaller, just to show all of this table.

    [02:47 - 03:04] These are the popular option combinations for module and moduleResolution. First off, we can start with module: preserve and moduleResolution: bundler. It's implied it can be omitted, but usually it's better to always set both of these options to be clear.

    [03:05 - 03:21] So "preserve" plus "bundler" mimics the behavior of bundlers like Vite, ESBuild, Bun, tsx and turbopack. The statements that are being evaluated are going to depend on the source code.

    [03:22 - 03:41] So if your code has an import statement, then TypeScript is going to try to match based on the "types", "import" and finally "default" conditions. If you use a require in your code, TypeScript is going to try to match on "types" and then "require".

    [03:42 - 04:07] This means that in module: preserve, you're actually allowed mixing both of these in the same file. You can have a TypeScript file that has both import statements and require statements, and TypeScript will correctly apply different rules to each statement and load different .d.ts files. The next most common option is "esnext" with "bundler".

    [04:08 - 04:25] This usually implies that you're writing code for the browser, but you want to use import statements. In a way, it's slightly simpler than option one because it forbids mixing import and require statements. You can only use imports.

    [04:26 - 04:43] The third option is module: "nodenext" and moduleResolution: "nodenext". Now when I say nodenext this actually currently includes Node 18. "nodenext" just means targeting the current LTS version of Node.

    [04:44 - 05:08] You can say set... you can set it to "node18" and get the exact same behavior right now, although for most projects I would just set it to nodenext. It applies dual graph rules, which means in a single file you cannot mix, import and require statements, but you can have files with import statements and files with required statements in the same project.

    [05:09 - 05:29] Finally, there is "commonjs", if you are specifically targeting older versions of Node before Node 12. You shouldn't need to use this in any modern project, but it's worth keeping in mind if you ever need to write TypeScript for an old project.

    [05:30 - 05:49] It specifically does not support package.json "exports" and relies only on the "main" field inside package.json to resolve. A note on this table of conditions: the "default" condition is always applied to the end of all conditions.

    [05:50 - 06:23] As we said when we were talking about package.json exports, this is the safest rule that all systems are always going to resolve last, if nothing else can match. The final resolve stack is always going to begin with types, then other conditions which are usually going to be either "import", "require", "node" or a custom condition like "react-server", and then it's going to end with "default". TypeScript is going to check if the first value of the stack matches, and if it does, then it uses that .d.ts file.

    [06:24 - 06:34] Then it's going to check the next value and the next value until it exhausts them all. For further reading, there is a reference on module handling in TypeScript.

    [06:35 - 06:57] You can also add additional conditions for TypeScript to add to the resolve stack by using compiler optional "customConditions". For example, customConditions: "module" may in some situations more accurately may in some situations more accurately mimic bundler behavior.

    [06:58 - 07:17] However, in most cases, you don't need this. Don't touch it. Don't play with it. Finally, when you cannot figure why TypeScript is loading a particular set of types for a package, you can always use the "tsc --traceResolution" option.

    [07:18 - 07:37] It will print the full resolve condition stack for each and every file processed by TypeScript. It's a lot of output, but it gives you the exact behavior and it can help debug some hard to figure out cases. There are two combinations that I want us to focus on.

    [07:38 - 07:51] The first one is module: "preserve" with module resolution: "bundler". Why is this a combo that's common to use? Well, module: "preserve" will keep your original syntax exactly as it is.

    [07:52 - 08:10] It keeps both import export statements and require and export equal statements in the same file. This matches how all modern bundlers parse TypeScript. Bundlers run their own transformations on these statements and do their own resolution.

    [08:11 - 08:17] So what we are telling TypeScript is these are both valid. Don't touch them. Preserve them as they are.

    [08:18 - 08:58] Then module resolution "bundler" imitates the union of classic Node rules, which allow you to load a file without an extension and allow you to pass a directory and load the index file in that directory, with modern exports and import fields. In modern NodeJS, this actually does not work, but bundlers have made it allowed and TypeScript follows the rules of what bundlers are doing. The resolution algorithm in practice is that if we have an "import" statement, there is over is going to add the "import" rule.

    [08:59 - 09:09] And if we have the "import pkg = require("pkg")" statement, the resolver is going to add the "require" rule. So that's how you can have both in the same file.

    [09:10 - 09:30] Both statements always add "types" to the beginning and "default" to the end. So the final stacks are types, import, default and types, require, default. The "node" condition is not added in bundler mode because bundlers are usually not Node. When do we use this?

    [09:31 - 09:46] This is meant to be used when a bundler will handle the final code compilation. Think in terms of a Next.js application, a Vite application, or anything where you have a bundler rather than Node or TypeScript running your code.

    [09:47 - 09:57] It implies that you run "tsc --noEmit" separately from your build process. You do only type checking in TypeScript and something else does compilation or execution.

    [09:58 - 10:18] If you need custom rules added to the resolver like "browser" or "react-server", you can add them to compiler option "customConditions". You would only need to do this if you're building a very special library that must use special types for one of its imports.

    [10:19 - 10:36] So if you're building a browser library that imports another library that has special exports for workers, this might be a reason to add custom conditions. Another possible reason is when you want to resolve the react-server types of an import.

    [10:37 - 10:47] However, in most cases you should not need to do this. The other option that I want to highlight is module: "nodenext" with moduleResolution: "nodenext".

    [10:48 - 11:02] Why this combo? Well it directly models the current LTS Node rules and will keep evolving as Node.js itself evolves. It gives you true dual-format projects.

    [11:03 - 11:20] You cannot mix ESM and CJS in the same file, but you can have separate files that emit CJS and ESM based either on extension or the surrounding package.json type field. What does the resolution algorithm look in practice?

    [11:21 - 11:54] If we have a server.mts file that imports from a package called "mydb", there resolver conditions are going to be "types", "node", "import". If we have a worker.cts file that's a CJS emit, it's going to use resolver conditions of "types", "node", "require". Key points: the "node" resolver condition is always present. You are in node-land. You're telling TypeScript that this is called that's going to execute within NodeJS.

    [11:55 - 12:36] Extensionless relative imports only work in file positions that will emit a require CJS call. That means only in here could we add something like "import user = require('lib/user')" that resolves to "lib/user.ts". When we are in pure ESM, which is to say we're either in a .mts file or we have declared type=module in our package.json, we must write file names plus extensions.

    [12:37 - 12:49] So above to import the same file we'll do "import user from './lib/user.ts'". So here we need to have our actual extension to import.

    [12:50 - 13:10] Node 22 can now require an ESM export in synchronous code with the --experimental-require-module flag in 22 or without any flag in 23 and above. TypeScript 5.8 allows this, but only when the runtime supports it.

    [13:11 - 13:33] This combination of module "nodenext" and module resolution "nodenext" is meant for your server or CLI Node apps, or when you write libraries that are meant to be used in a Node environment. So to summarize, think of the module option as the syntax emitter.

    [13:34 - 13:48] It indirectly answers the question of will my import statement be rewritten to something else when this code gets compiled? Module resolution is the directory walker.

    [13:49 - 14:32] It decides which branch to take in the export condition, and then which .d.ts file to load to find the definitions for the types you're importing. Together they form the condition stack that walks, exports or import statements in your files and the two patterns to memorize as the ones to use almost universally for any new TypeScript code is "preserve", plus "bundler" for applications that use a bundler to consume TS files and "node" plus "nodenext" for libraries, servers, or CLI apps that run directly on Node.js.