Typescript: Proposal: Allow `paths` compilerOption without `baseUrl`

Created on 12 Jun 2019  路  14Comments  路  Source: microsoft/TypeScript

Related issue: https://github.com/microsoft/TypeScript/issues/28321

Search Terms

baseUrl, paths, compilerOptions

Suggestion

Do not require 'baseUrl' in compilerOptions when 'paths' is provided.

Use Cases

We want to use 'paths' for type-checking, but don't want to resolve non-module relative names:

./1.ts

import { two } from "2"

./2.ts

export const two = 3;

Examples

tsconfig

{
  "compilerOptions": {
    "paths": {
        "foo": ["../foo"]
    }
  }
}

Actual behavior of paths without baseUrl:

Error: Option 'paths' cannot be used without specifying '--baseUrl' option

Expected behavior of paths without baseUrl:

  • No error message
  • paths are resolved relative to the project directory (which I think means the same thing as "where the tsconfig is")

Checklist

My suggestion meets these guidelines:

  • [x] This wouldn't be a breaking change in existing TypeScript/JavaScript code
  • [x] This wouldn't change the runtime behavior of existing JavaScript code
  • [x] This could be implemented without emitting different JS based on the types of the expressions
  • [x] This isn't a runtime feature (e.g. library functionality, non-ECMAScript syntax with JavaScript output, etc.)
  • [x] This feature would agree with the rest of TypeScript's Design Goals.
In Discussion Suggestion

Most helpful comment

We have a horrifying workaround that intentionally prevents resolving bare-specifiers against the "baseUrl" whilst permitting "paths" to be used.

It involves setting an impossible directory as "baseUrl" and then prepending "paths" values with "../" to jump out of the impossible directory back up to the project root.

{
  "compilerOptions": {
    "baseUrl": "\u0000",
    "paths": {
        "foo": ["../../foo"]
    }
  }
}

... which works today and achieves the same thing as the proposed example ...

{
  "compilerOptions": {
    "paths": {
        "foo": ["../foo"]
    }
  }
}

It would be great to be able to drop this workaround.

All 14 comments

We have a horrifying workaround that intentionally prevents resolving bare-specifiers against the "baseUrl" whilst permitting "paths" to be used.

It involves setting an impossible directory as "baseUrl" and then prepending "paths" values with "../" to jump out of the impossible directory back up to the project root.

{
  "compilerOptions": {
    "baseUrl": "\u0000",
    "paths": {
        "foo": ["../../foo"]
    }
  }
}

... which works today and achieves the same thing as the proposed example ...

{
  "compilerOptions": {
    "paths": {
        "foo": ["../foo"]
    }
  }
}

It would be great to be able to drop this workaround.

I'm pretty confused about what's going on here (since there is no file named "foo"). Can you provide some more examples of what you'd want this to do? What is the proposed difference between what you want and just setting baseUrl to the "project root" yourself?

Thanks for taking a look @RyanCavanaugh

I'm pretty confused about what's going on here (since there is no file named "foo")

The presence of a "foo" file is not intended to be relevant to this example. We mentioned it to show that specifying compilerOptions.paths forces also specifying baseUrl.

What is the proposed difference between what you want and just setting baseUrl to the "project root" yourself

When we set baseUrl, the semantics of TS non-relative imports changes in a way that conflicts with ECMAScript semantics. We're hoping to avoid this behavior. There's a complete example below.

Desired behavior:

tsconfig:

{
  "compilerOptions": {
    "paths": {
        "foo": ["../foo"]
    }
  }
}

file systen:

  • foo.ts

    • proj



      • tsconfig.json


      • 1.ts


      • 2.ts



1.ts should error:

import two from "2"; // Error: Cannot find module '2'

2.ts contents (not relevant):

export default 2;

Actual behavior when setting paths without baseUrl

all files are as described above ^^

TS Errors:

Error: Options 'paths' cannot be specified without specifying '--baseUrl' option

Actual behavior when setting baseUrl to project root manually

tsconfig.json:

{
  "compilerOptions": {
    "baseUrl": ".",
    "paths": {
        "foo": ["../foo"]
    }
  }
}

all other files are as described above ^^

Adding baseUrl changes the semantics of imports. Spefically, there is no longer an error in 1.ts after this change:

import two from "2"; // no error, but we want 'Cannot find module '2', which is the behavior when 'baseUrl' is not specified

TLDR: We're hoping to be able to use 'paths' without changing how all non-relative module paths are resolved

To give a more concrete example of why fixing this is important: setting baseUrl means that every new file you add to the baseUrl location (usually ., the project root) is an opportunity to accidentally break things.

For example: your code might be doing import * as ts from 'typescript', successfully importing from node_modules/typescript. You later create a file called typescript.ts at the project root for some testing. Whoops, your existing imports of typescript are now broken.

It's ideal if the only things that can override node_modules are explicit and minimal; using paths without baseUrl is the way to do that.

In the meanwhile, I discovered another workaround:

{
  "compilerOptions": {
    "baseUrl": ".",
    "paths": {
        "foo": ["../foo"],
        "*": ["__INVALID__/*"]
    }
  }
}

The other workaround caused some problems for me with Webstorm (and I think other tools), which didn't like having a baseUrl pointing to a nonexistent directory. (But it inspired this one, so thanks @robpalme.)

There鈥檚 one ambiguity that needs to be cleared up in order to allow dropping baseUrl. Consider two paths entries:

"foo1": ["./lib/foo"],
"foo2": ["lib/foo"]

The former value is a relative path, while the latter is an unrooted path. Currently, both of these are resolved to the same location, relative to baseUrl.

In the absence of a baseUrl, I think it鈥檚 fairly clear that the value for the foo1 mapping should be resolved relative to the tsconfig.json location, as all other relative paths in tsconfig.json are. It鈥檚 less clear what should happen with the value for the foo2 mapping. Without an explicit base, it looks like we could do a node_modules search for lib. This could be a useful feature, but is fairly asymmetrical from how resolution works with baseUrl.

There鈥檚 one ambiguity that needs to be cleared up in order to allow dropping baseUrl. Consider two paths entries:

"foo1": ["./lib/foo"],
"foo2": ["lib/foo"]

The former value is a relative path, while the latter is an unrooted path. Currently, both of these are resolved to the same location, relative to baseUrl.

In the absence of a baseUrl, I think it鈥檚 fairly clear that the value for the foo1 mapping should be resolved relative to the tsconfig.json location, as all other relative paths in tsconfig.json are. It鈥檚 less clear what should happen with the value for the foo2 mapping. Without an explicit base, it looks like we could do a node_modules search for lib. This could be a useful feature, but is fairly asymmetrical from how resolution works with baseUrl.

Thanks for looking into this. Re the foo2 example, another option is to error in such a case, so the user can fix the paths. (my two cents is) I think it's helpful to avoid new Node-specific things in a world with multiple JS runtimes.

I think it's helpful to avoid new Node-specific things in a world with multiple JS runtimes.

Good point; the framing of my question was node-specific, but I think my question still stands without making any node-specific assumptions. I shouldn鈥檛 have implied that if we see an unrooted path ("lib/foo") we would _definitely_ do a node_modules search, but rather that this module specifier could be passed along to whatever module resolution strategy is selected, which _currently_ would usually do a node_modules search. So I think my question is better stated: given the path map from my previous message and no baseUrl, when resolving the specifier "foo2", should the module resolver be asked to resolve "lib/foo" or "/path/to/tsconfig/directory/lib/foo"?

I think it's helpful to avoid new Node-specific things in a world with multiple JS runtimes.

Good point; the framing of my question was node-specific, but I think my question still stands without making any node-specific assumptions. I shouldn鈥檛 have implied that if we see an unrooted path ("lib/foo") we would _definitely_ do a node_modules search, but rather that this module specifier could be passed along to whatever module resolution strategy is selected, which _currently_ would usually do a node_modules search. So I think my question is better stated: given the path map from my previous message and no baseUrl, when resolving the specifier "foo2", should the module resolver be asked to resolve "lib/foo" or "/path/to/tsconfig/directory/lib/foo"?

Thanks for explaining, I understand better now. Fwiw I have no intuition about which behavior would be more intuitive in that case. I do like the idea of erroring, so the user can more explicitly express intent, but of course I lack the background to know what accords best with the design principles for compiler options.

I do not think it is useful for bare-specifiers in "paths" values to ever be treated as tsconfig-relative. If the user wants that, they should use a relative specifier.

If they were instead passed to the resolver directly, it then raises the question of whether that resolution would include further path resolution.

{
  "paths": {
    "first": ["second"],
    "second": ["./foo"],
  }
}

Path resolution is a demon-haunted world of complexity. I would strongly err towards simplicity rather than completeness, and because I don't have a use-case for bare-specifiers here, I'd suggest erroring for now (as Max said). If someone wants the feature, it can always be added later.

Doing more than one path substitution would make circularities possible, and I can鈥檛 think of a real use for it. I think that, at least, we would not do.

I know this is being worked on, but one consideration I wanted to mention is how auto-imports are suggested.

I converted a big project into a monorepo that uses these path specifiers, and only later noticed (when I was actually writing code) that VS Code was suggesting imports like src/foo/bar because I had to specify baseUrl in any package that wanted to have paths. I can work around the unwanted suggestions by forcing the editor to use relative suggestions, but RelativePreference.Relative (what VS Code maps to here) completely ignores paths and offers imports that look like ../../../packages/something/src/some/file, which defeats the niceness of being able to use paths.

Am I correct in assuming that baseUrl not being required would then prevent those unwanted "absolute-relative-to-baseUrl" imports from being suggested in favor of relative, while still letting imports listed explicitly paths be suggested?

Or do I need to request a mode that's "relative, but respect paths" to cover this scenario?

Am I correct in assuming that baseUrl not being required would then prevent those unwanted "absolute-relative-to-baseUrl" imports from being suggested in favor of relative, while still letting imports listed explicitly paths be suggested?

Yep!

Wonderful. I hope this can make it to 4.1. If only this were in 4.0 so we could use it now... 馃槂

Was this page helpful?
0 / 5 - 0 ratings