Strongly type file paths - #64159
Conversation
Introduce rooted, normalized filename and directory types and carry them through compiler ingestion, module resolution, project state, symlink tracking, package caches, and auto-import identities. Use typed canonical transitions to avoid repeated normalization and replace heuristic module identity checks with tagged variants.
Add a custom analyzer that permits invariant-dropping path conversions while requiring named constructors for invariant-adding conversions. Keep test code exempt so concise canonical path fixtures remain practical. Add explicit typed-path transitions and validate serialized API paths without panicking on malformed client input.
Rename canonical path values to PathKey and organize typed path APIs by the invariant they establish. Model trailing-separator cache keys as PathPrefix and keep module specifiers as semantic tags. Extend custom linting to reject implicit invariant-adding constants and typed values converted to strings before redundant path work.
Require proven directory values when resolving raw file names and path keys. Retain typed current directories through compiler, resolver, project, execution, API, LSP, and test harness boundaries.
Return declaration output paths as FileName values. Keep typed names through source maps and parsed command output lists.
Retain validated file and directory names through project references, incremental state, build orchestration, auto-imports, API requests, and output path derivation.\n\nMove remaining normalization to config, protocol, filesystem, and serialization boundaries.
Replace ambiguous typed path resolution with explicit file and directory operations, while keeping unresolved candidates lexical until their meaning is known. Preserve trailing separators in package entrypoints, type roots, diagnostics, and relative module augmentations, with focused regression coverage.
Rename the public path types and constructors to match the rooted path lattice. Brand file and directory paths as refinements of RootedPath while keeping PathKey on a separate canonical identity branch. Update generated protocol and AST surfaces, filesystem callbacks, and API tests without compatibility aliases.
Require raw strings to become rooted presentation paths before they can be converted into canonical path keys. This keeps rooting and normalization separate from identity canonicalization. Remove the PathContext wrapper and keep current-directory and case-sensitivity state directly on their owning API objects.
Use compiler-provided path keys directly for projects and node handles instead of reconstructing them through presentation paths. Keep internal source-file and protocol map keys branded as PathKey. Match Go's normalized-path validation and filename canonicalization, including the special Unicode casing behavior used on case-insensitive filesystems.
Lower case path-key text one code point at a time so JavaScript does not apply context-sensitive casing rules that differ from Go's unicode.ToLower behavior.
Describe the rooted presentation and canonical identity branches as a standalone API model. Align Go and TypeScript terminology for the path types and their constructors, and document normal conversion usage without migration or performance history.
Treat RootedPath and its file and directory refinements as the path types themselves. Reserve special terminology for PathKey, which is derived for comparison and lookup and must not be used as a rooted path.
| checkedAbsolutePath := checkedName.WithoutRoot() | ||
| inputAbsolutePath := task.normalizedFilePath.WithoutRoot() |
There was a problem hiding this comment.
It's hard to pinpoint every case where we stop normalizing, but here's an example; we already know that these paths are rooted, normalized, etc, so we skip all of this, no longer need a current dir.
| type CompilerHost interface { | ||
| FS() vfs.FS | ||
| DefaultLibraryPath() string | ||
| GetCurrentDirectory() string |
There was a problem hiding this comment.
It's pretty amazing that we don't need this at all.
There was a problem hiding this comment.
🟡 Changes recommended
Path normalization, relative auto-import rebasing, and case-insensitive watcher invalidation have unresolved correctness defects.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Pull request overview
Introduces strongly typed rooted paths and canonical path keys throughout the compiler, language server, VFS, and unstable TypeScript API.
Changes:
- Adds typed-path primitives,
CaseSensitivity, conversion helpers, and lint enforcement. - Propagates typed paths through resolution, emit, watching, LSP, and API boundaries.
- Adds regression tests and updates generated baselines.
File summaries
| File group | Description |
|---|---|
tsc/internal/tspath/* |
Defines typed paths and path operations. |
tsc/internal/{compiler,module,checker,ast,binder,parser,printer,sourcemap,transformers}/* |
Migrates compiler internals. |
tsc/internal/{ls,lsp,project,contentmapper}/* |
Migrates language-service boundaries. |
tsc/internal/{vfs,execute,transpile,bundled}/* |
Migrates filesystem and execution paths. |
tsc/internal/{testutil,testrunner,fourslash,format}/* |
Updates test infrastructure and cases. |
tsc/testdata/tests/cases/compiler/* |
Adds path regression scenarios. |
tsc/testdata/baselines/reference/* |
Updates expected compiler and LSP output. |
packages/typescript/src/* |
Exposes typed paths in the unstable API. |
packages/typescript/test/* |
Updates JavaScript API tests and benchmarks. |
tools/customlint/* |
Enforces typed-path invariants. |
tools/{gen-proto,scripts/tsc}/*, Herebyfile.mjs |
Updates generators and generated enums. |
tsc/cmd/tsc/* |
Converts process-level path boundaries. |
Review details
- Files reviewed: 169/449 changed files
- Comments generated: 3
- Review effort level: Balanced
💡 Add a code-review agent skill for context-aware, tailored reviews. Learn more in the docs.
| func TryRootedPathFromAbsolute(path string) (RootedPath, bool) { | ||
| if !PathIsAbsolute(path) { | ||
| return "", false | ||
| } | ||
| return RootedPath(GetNormalizedAbsolutePath(path, "")), true |
| if _, changed := changedPaths[mapper.PackageDirectory.ResolveFile("package.json")]; changed { | ||
| return true | ||
| } |
| if export.UnresolvedModuleSpecifier != "" { | ||
| specifier := export.UnresolvedModuleSpecifier | ||
| if modulespecifiers.IsExcludedByRegex(specifier.AsString(), userPreferences.AutoImportSpecifierExcludeRegexes) { | ||
| return "", modulespecifiers.ResultKindNone | ||
| } | ||
| return string(export.ModuleID), modulespecifiers.ResultKindAmbient | ||
| return specifier, modulespecifiers.ResultKindRelative |
This is a wacky change I've wanted to try out for a while and finally started screwing around with with copilot.
Right now (and in Strada), we have just two kinds of paths:
string- 🤷Path- an OS dependent string used for map keys, lowercased on case insensitive systemsOur use of
stringpaths led to us slappingnormalizeSlashes,normalizePath, etc everywhere, as we often were unsure (or pessimistic) whether or not a path had its slashes normalized to/, had redundant components removed, trailing slashes removed, not relative, etc. This is extra bad because on Linux, macOS, etc, paths are basically guaranteed to meet all of the criteria, but we'd try and normalize them anyway.This PR change this by introducing named/branded types for paths which assert properties about those paths. This is not a new concept; I believe yarn's FS package has this, and I'm sure others do.
As a hierarchy:
string- No guarantees.RootedPath- The path is absolute, has normalized slashes, no trailing/.RootedFilePath- ARootedPath, but indicates that the path is supposed to point at a file.RootedDirectoryPath- ARootedPath, but indicates that the path is supposed to point at a directory.PathKey- Same as the oldPath, but renamed for clarity.This is a big refactor that requires changing a lot of code, but leads to some pretty important properties.
Paths are converted at the boundaries, e.g. paths provided via config files, CLI, from the OS, the editor, etc. Once converted, you always know exactly what format a path is in and therefore never need to normalize again.
Paths are always rooted. The "current working directory" does not need to be plumbed around as much anymore, since most uses were simply to root paths we were unsure about.
Since paths are always rooted,
ComparePathsOptions's current dir field is no longer needed! This means comparing paths only requiresUseCaseSensitiveFileNames. This applies also to all of our oldtoPathconversions, since we only ever need to canonicalize rooted paths. So, I created a newCaseSensitivityenum, and then all of the plumbing forComparePathsOptions, its working dir, etc, also get to go away.The impact of this is measurable; I instrumented main vs my branch to count how many of the normalizing operations go away and it's a lot:
Old compiler fixture
maintyped-pathsCombinePathsNormalizePathNormalizeSlashesVS Code
srcmaintyped-pathsCombinePathsNormalizePathNormalizeSlashesThat's millions of normalizations that no longer need to happen. In terms of runtime, it's not a lot of savings, even on Windows, but I did also measure about a 7% speedup in program load of the old compiler, which is nice.
Additionally, the strong typing here caught 3 different bugs that have been around in main for a while, places where we had mixed up paths, rooted them relative to the wrong directory, etc. Those are denoted in my (awful) git history as being things to port to main, which I may still do.
In addition to just the types themselves, a new lint rule bans manually hacking on the paths; all operations should go through methods on the paths themselves. No concat, splitting, conversions, yourself.
The downside here is just churning the API and introducing these concepts to downstream API users. But the strong typing itself I think is worth it, and doing a lot less work is a bonus too. We probably won't have a change to do something like this for a while.
I'm also going to say that this fixes #44174 just since this eliminates nearly all normalization; we might still do a quick check at the boundaries, but other than that, we never normalize gain.