Skip to content

Strongly type file paths - #64159

Open
Jake Bailey (jakebailey) wants to merge 129 commits into
microsoft:mainfrom
jakebailey:typed-paths
Open

Strongly type file paths#64159
Jake Bailey (jakebailey) wants to merge 129 commits into
microsoft:mainfrom
jakebailey:typed-paths

Conversation

@jakebailey

@jakebailey Jake Bailey (jakebailey) commented Sep 3, 2026

Copy link
Copy Markdown
Member

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 systems

Our use of string paths led to us slapping normalizeSlashes, 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 - A RootedPath, but indicates that the path is supposed to point at a file.
      • RootedDirectoryPath - A RootedPath, but indicates that the path is supposed to point at a directory.
  • PathKey - Same as the old Path, 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 requires UseCaseSensitiveFileNames. This applies also to all of our old toPath conversions, since we only ever need to canonicalize rooted paths. So, I created a new CaseSensitivity enum, and then all of the plumbing for ComparePathsOptions, 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

Metric main typed-paths Change
Absolute rooting 1,228 6 -99.511%
Canonicalize 27,213 1,115 -95.903%
CombinePaths 2,145 7 -99.674%
Lowercase 189 189 unchanged
NormalizePath 27,567 2 -99.993%
NormalizeSlashes 35,693 588 -98.353%
Total path-key construction 26,689 1,115 -95.822%

VS Code src

Metric main typed-paths Change
Absolute rooting 237,348 851 -99.641%
Canonicalize 878,794 199,944 -77.248%
CombinePaths 234,338 156 -99.933%
Lowercase 7,996 7,996 unchanged
NormalizePath 971,125 6 -99.999%
NormalizeSlashes 2,507,002 8,720 -99.652%
Total path-key construction 735,489 116,736 -84.128%

That'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.

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.
Copilot AI balanced review requested due to automatic review settings September 3, 2026 22:34
@github-project-automation github-project-automation Bot moved this to Not started in PR Backlog Sep 3, 2026
@typescript-automation typescript-automation Bot added Author: Team For Milestone Bug PRs that fix a bug with a specific milestone labels Sep 3, 2026
Comment on lines +426 to +427
checkedAbsolutePath := checkedName.WithoutRoot()
inputAbsolutePath := task.normalizedFilePath.WithoutRoot()

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's pretty amazing that we don't need this at all.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 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.

Comment on lines +45 to +49
func TryRootedPathFromAbsolute(path string) (RootedPath, bool) {
if !PathIsAbsolute(path) {
return "", false
}
return RootedPath(GetNormalizedAbsolutePath(path, "")), true
Comment thread tsc/internal/execute/watcher.go Outdated
Comment on lines 637 to 639
if _, changed := changedPaths[mapper.PackageDirectory.ResolveFile("package.json")]; changed {
return true
}
Comment on lines +14 to +19
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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Author: Team For Milestone Bug PRs that fix a bug with a specific milestone

Projects

Status: Not started

Development

Successfully merging this pull request may close these issues.

normalizeSlashes should probably no-op on *nix

2 participants