Skip to content

Strongly type file paths - #64159

Merged
Jake Bailey (jakebailey) merged 7 commits into
microsoft:mainfrom
jakebailey:typed-paths
Oct 3, 2026
Merged

Jake Bailey (jakebailey) merged 7 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 changes 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.

In the course of this PR, copilot found 15 bugs. 8 of which were unrelated but noticed as the files were being read, but 7 of which were related to typed paths. 4 of those bugs were also present in Strada!

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.

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 thread tsc/internal/compiler/filesparser.go
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 thread tsc/internal/tspath/rooted_path.go Outdated
Comment thread tsc/internal/execute/watcher.go Outdated
Comment thread tsc/internal/ls/autoimport/specifiers.go
Comment thread packages/typescript/src/api/async/api.ts Outdated
Comment thread packages/typescript/src/api/node/node.ts

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

CommonDirectoryOfFiles treats case-equivalent drive roots as unrelated, potentially corrupting common-source and emit-path computation.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Review details
  • Files reviewed: 164/668 changed files
  • Comments generated: 1
  • Review effort level: Balanced

Comment thread tsc/internal/tspath/rooted_path.go
Comment thread tsc/internal/compiler/host.go Outdated
@jakebailey
Jake Bailey (jakebailey) force-pushed the typed-paths branch 4 times, most recently from 067147e to ec2f191 Compare September 8, 2026 19:10
Comment on lines +11 to +12
GetMTime(fileName tspath.RootedFilePath) time.Time
SetMTime(fileName tspath.RootedFilePath, mTime time.Time) error

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.

Sort of wonder why we have these like this; the only methods left

Comment thread tsc/internal/fourslash/statebaseline.go Outdated
Comment thread tsc/internal/ls/autoimport/specifiers.go Outdated
Comment thread tsc/internal/ls/change/tracker.go Outdated
Comment thread tsc/internal/ls/lsconv/converters.go Outdated
Comment thread tsc/internal/project/background/queue.go
Comment thread tsc/internal/vfs/osvfs/os.go Outdated
Comment thread tsc/internal/vfs/osvfs/os.go
Comment thread tsc/internal/vfs/vfsmatch/vfsmatch.go Outdated
Comment thread tsc/internal/project/snapshothost.go

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.

Copilot review overview

🟡 Changes recommended

Public diagnostic-host typing remains incomplete, and two tests or benchmarks no longer preserve their intended semantics.

Review effort: Balanced
Findings: 1 High severity · 2 Medium severity · 1 Low severity

Open (4)
Resolved since last review (1)

Comment thread packages/typescript/src/api/async/types.ts Outdated
Comment thread tsc/internal/lsp/server_flakydiagnostics_test.go Outdated
Comment thread tsc/internal/vfs/vfs_test.go Outdated

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.

Copilot review overview

🔵 Needs a closer look

The async realpath callback rejects valid relative results, and extension removal can construct an invalid rooted file path.

Review effort: Balanced
Findings: None

Resolved since last review (4)
Previously missed (1)

In code that hasn't changed since last review

Medium severity Async realpath incorrectly roots relative callback results

packages/​typescript/​src/​api/​async/​client.ts:192

realpath callbacks are allowed to return a relative path: the server resolves that value against the queried path's directory (callbackfs.go:283-299), and the sync client forwards it unchanged. Rooting it here with no current directory makes only the async client throw for a valid result such as ../real/file.ts. Forward the callback result and let the server perform the boundary conversion.

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.

Copilot review overview

🔵 Needs a closer look

The cross-cutting path-identity and public API migration requires final human validation despite no confirmed blocking defect in the reviewed changes.

Review effort: Balanced
Findings: None

@andrewbranch

Copy link
Copy Markdown
Member

(not that the conflict-free state is going to survive the stuff already enabled to auto-merge)

Replace ambiguous string path contracts with a typed lattice for rooted
files, rooted directories, normalized relative paths, and canonical path
keys. Keep canonical identity as a one-way sink while retaining
presentation spelling wherever diagnostics, watches, symlinks, or
protocol responses need it.

Carry those invariants through compiler inputs and outputs, module
resolution, project snapshots, language-service hosts, VFS operations,
source maps, LSP conversion, and the JavaScript API. Separate raw
compiler option wire values from finalized rooted options, and
centralize explicit normalization, rooting, and case-sensitivity
boundaries.

Keep absent and empty sourceRoot values equivalent when decoding source
maps. This preserves published map compatibility without weakening
rooted-file invariants or selecting sources by file existence.

Preserve the host casing policy when aggregating watcher directories.
Presentation spelling must not prevent case-insensitive paths from
sharing a watch, and case-sensitive paths must remain distinct.

This commit consolidates the exploratory migration into one reviewable
rewrite after the independently portable fixes. It also adapts those
fixes to the typed representation and retains the two newer main
changes, including auto-import completion retries and tuple completion
filtering.

Category: Typed-path migration
Keep path branding internal to finalized compiler state while allowing API
callers to continue supplying ordinary strings. Normalize those values at
API boundaries using generated compiler-option path metadata.
Path validation in the timed loop obscures filesystem performance.
Prepare typed paths once so benchmark results exclude constructor work.
The path-type migration unintentionally changed the test filesystem
from case-insensitive to case-sensitive. Keep coverage of canonicalized
file identities and guard the test filesystem policy.
Custom diagnostic hosts should continue accepting ordinary directory
strings, including spellings that need normalization. Cover this API
boundary and remove the stale branded-directory imports.
Relative realpath callback results are resolved by the server against
the queried path's directory. Normalizing them without a base in the
async client rejects valid results and differs from the sync client.
Valid filenames such as .ts, ..ts, and ...ts lose their normalized path
structure when their extensions are removed. Treating those incomplete
prefixes as rooted paths can panic or misidentify their directory.

Keep incomplete filename prefixes separate so resolution, output
naming, and renaming preserve literal filenames without weakening
rooted-path invariants.
@@ -17,11 +19,75 @@ const (
CommandLineOptionTypeEnum CommandLineOptionKind = "enum" // map
)

type CommandLineOptionPathKind uint8

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.

This predates generated compiler options code; I will do a follow-up seeing if I can drop this.

@jakebailey
Jake Bailey (jakebailey) added this pull request to the merge queue Oct 2, 2026
Merged via the queue into microsoft:main with commit ed48072 Oct 3, 2026
29 checks passed
@jakebailey
Jake Bailey (jakebailey) deleted the typed-paths branch October 3, 2026 00:05
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: Done

Development

Successfully merging this pull request may close these issues.

normalizeSlashes should probably no-op on *nix

4 participants