Improving Scala’s Import Resolution with an Import-Map-Inspired Physical Import Model

Summary

I would like to start a discussion on an idea for enhancing Scala’s import resolution model by introducing a physical import syntax inspired by concepts like ECMAScript “import maps” and Go module paths. This design would coexist with existing package imports and aim to improve inspectability and learnability for both LLM-based tooling and developers without IDE support.


Motivation

Existing JVM import semantics (import org.apache.xxx…) rely on classpath and binary artifacts, which creates two usability issues:

  1. Tooling blind spots — Without sophisticated IDE integration, it is hard to inspect the definition or signature of a class from its name alone. This is a known pain point for developers, especially newcomers, who lack immediate visibility into API contract and implementation details.

  2. LLM assistance ambiguity — Because import names do not directly map to local source or semantic artifacts, large language models may produce outdated, inferred, or incorrect signatures when assisting with code completion or generation.

These issues are structural, not merely tooling gaps — they stem from a language resolution model that is semantic and binary oriented rather than physically inspectable.


Proposal: Physical Import Syntax + Import Map

Introduce a string-literal import syntax that is resolved via a project-level import map:

import "spark/sql/Dataset"

In this model:

  • The quoted string is a physical import path, akin to how ECMAScript import maps allow bare specifiers to be mapped to URLs or file paths. You can see the import-map concept here: Import Maps specification and usage examples. (developer.mozilla.org)

  • A workspace-level import map defines the binding of logical roots to specific artifacts:

{
  "imports": {
    "spark": "com.apache.spark:spark-core_3:3.5.1",
    "flink": "org.apache.flink:flink-core:1.19.0"
  }
}

This import map functions like the browser import maps idea for JS modules, where a bare specifier resolves to a defined module path. (developer.mozilla.org)

  • During compilation, the string import resolves through the import map to a local materialized directory (e.g., extracted .tasty, source, or semantic representation) — enabling deterministic lookup.

This combination preserves compatibility with the normal Scala import syntax while giving an inspectable resolution path.


Analogies and Precedents

  • ECMAScript Import Maps — Let developers map symbolic specifiers to concrete paths before module resolution. (developer.mozilla.org)

  • Go module paths — Although Go imports are full module paths (e.g., "``github.com/foo/bar/v2``"), they are explicit strings tying import to a known on-disk identity. What’s proposed here decouples the resolution from opaque classpath mechanisms and makes it local and inspectable. (Go)

Both show that explicit, resolvable import mappings improve tool predictability.


Benefits Beyond LLMs

This design is not only LLM-friendly — it also benefits human developers, especially new Scala programmers:

  • Improved learnability — With conventional imports, a developer without an IDE must search external repositories or rely on external indices to locate definitions. Physical imports make it trivial to navigate to definitions.

  • Better tooling support — Tools (linters, static analyzers, docs generators, refactoring tools) can deterministically follow import references.

  • Explicit version binding — Import maps make dependency versioning explicit and centralized, reducing ambiguity about which artifact version a symbol refers to.

This aligns with broader community goals around learnability and tooling; for example, recent discussions about ecosystem learnability indicate the need for improvements in tooling and discoverability. (Scala)


Example Workspace

workspace/
├── module-a/
│   ├── lib/
│   │   ├── spark/      (resolved via import map)
│   │   │   └── sql/
│   │   │       ├── Dataset.tasty
│   │   │       └── Encoder.tasty
│   ├── src/
│   └── test/
└── module-b/

With an import map I can write:

import "spark/sql/Dataset"

and tooling can directly navigate to the physical path in module-a/lib/spark/sql.


Discussion Questions

  1. Design suitability: Would contributors be receptive to exploring physical import paths as a language extension separate from dotted package imports?

  2. Integration with build tools: How might this interact with sbt, Coursier, and other Scala/JVM build ecosystems?

  3. Import map format: What are desirable semantics and formats for an import map in a Scala context?

  4. Compiler/toolchain considerations: What are possible implementation challenges (e.g., incremental compilation, TASTy extraction, cross-module resolution)?

  5. Community precedent: Are there related past discussions in the Scala community on import resolution, tooling transparency, or semantic extraction?


Next Steps

I am seeking initial feedback on this idea so that — if there is community interest — we can refine a pre-SIP proposal with clearer grammar, resolution semantics, and prototyping plans.


References

  • Import maps allow bare module specifiers to be mapped to concrete paths for JavaScript modules: script type=“importmap” specification. (developer.mozilla.org)

  • Go modules use explicit module paths defined in go.mod, which become import paths in code. (Go)

  • Tooling and learnability continue to be areas of focus for Scala ecosystem improvement. (Scala)

2 Likes

Ok, but how it’s supposed to work?

  • JavaScript packaging is about making some .js file, everything is in that file, so you can point to it, make an alias of sort (this name → this file), and then import from that alias
  • additionally, due to how JavaScript works, you can have multiple versions of the same library
  • cannot say anything about Go’s packaging system, but I assume it’s module system has some build assumptions about how it being compiled and run

On JVM:

  • dependency is a JAR - a zipped catalogue with .class files (in Scala’s case also .tasty) and other resources
  • each ClassLoader can have only 1 version of each class, and since most Scala programs do not use OSGi, Java EE, etc there is only 1 class path, with 1 version of each library

That aligns with how current import work, but not necessarily with how such “importmap” would work:

  • e.g. the Spark SQL from the example would be a JAR
  • sure, we can point to it, say spark = lib/spark-sql-fd9s7df.jar
  • but inside that jar there are com/apache/spark/sql directories, only there you’d find any .class or .tasty
  • so that example importmap becomes import “spark/com/apache/spark/sql” - not only it’s not an improvement, we still need to know the bytecode structure, but also we need to add a prefix!

OK, but let’s say we can tell it to skip that initial com/apache/com/apache/spark. But where that information should be defined?

  • new libraries could have some metadata telling this import system to ignore the bytecode alignment and using something else instead, e.g. list of paths where some prefix is stripped to support it out of the box
    • which is still not ideal for discovery - now we are relying on some magical files laying next to the files, to tell the compiler how to map the files inside a JAR
    • it would also run into corner cases really fast - what if there are directories inside a JAR like com/apache/intrgration_for_something_else, com/apache/spark/sql and com/apache/spark/something_else (it’s rare an inelegant, but not unheard of e.g. as a way of bypassing package-private restrictions) - what should be the “root” of such a JAR? com/apache? Or maybe com/apache/spark and some packages be unavailable in the new system?
  • existing Scala libraries and any Java libraries till the end of time would not have any extra files, so someone, probably the person maintaining the build, would have to not only write a map of version to the JAR, but also how to interpret this JAR’s content as… bytecode agnostic paths?

Then then is also a problem that every: runtime exception, runtime reflection, logger, type name in a macro, etc., would return the bytecode-based FQN - so user’s would see some versions of things when importing and writing code, and then completely different when running and debugging that code. That would be even more confusing to the newcomers, who were promised to run away from bytecode knowledge, only to learn that it was a lie, you cannot debug anything not knowing anything about how JVM work. It would ease the onboarding of some people, at the very entry level step, but at the cost of creating a wrong mental model of how everything works, one that would get actively in the way when they would try to understand why someone does not work the way they assumed.

3 Likes

You can already ask the build tools about the classpath, and about the dependency metadata.
You can ask coursier to download sources for the given dependencies (e.g. cs fetch --sources org.apache.flink:flink-core:1.19.0will print a list of source jars).

Packages in Scala are not required to relate to the path of the sourcecode, but often are by convention, and there exist simple (grep) to sophisticated (IDE) tools that make it easy to find what you need. I guess an LLM agent would have little trouble finding the relevant sourcefiles.

I don’t see anything that needs adding to the language.

2 Likes

Good question. Let me write down a more complete example, and hopes it answer your questions or concerns. If the spark jar also contains some class files with other package prefix eg. apache-commons, then it can be configured like below.

{
  "dependencies": {
    "spark": "org.apache.spark:spark-core_3:^3.5.1",
    "flink": "org.apache.flink:flink-core:^1.19.0"
  },
  "imports": {
    "spark": {
      "library": "spark",
      "namespace": "org.apache.spark"
    },
    "spark-commons": {
      "library": "spark",
      "namespace": "org.apache.commons"
    }
  }
}

In the user Scala code, you import it like

import "spark/sql/Dataset"
import "spark-commons/lang3/Strings"

Treat the import map like a path/namespace alias without ambiguity. Both human and tools no longer rely on class path to search for any symbols.

Also as mentioned before, the current import statement is still supported for those edge case. In addition, after source code processing, the above import statements become imports with FQN.

import org.apache.commons.lang3.Strings

Regarding your concern of mental model, I don’t think it’s a lie, it’s more explicit and clear than the class path magic. Class path is only JVM concept, it does not exist on Scala JS and native target. I don’t think this flawed design should limit our imagination of the modern language.

That is factually incorrect. Scala.js and Scala Native have the same notion of classpath.

1 Like

Hello @lilac!
I don’t want to discourage you from sharing your ideas in the future, but I must say I agree with the other messages on this post, I think the current system is good enough, and adding another one risks splitting the ecosystem further

But I don’t want to dismiss your original concerns either, when you had trouble with LLMs, were you using the MCP server ?
I don’t know much about it, but it seems like it would give LLMs exactly the information you want them to have:

As for IDE support, what are you using ?
If you are on VS Code (or VS Codium), metals is pretty good
I think it is also available for vim, but I don’t know much, this might help getting a better idea
There’s also support in Intellij IDEA from JetBrains, Scala 3 support used not to be on-par, but I hear the situation has much improved

There are some Dotty project tests that currently fail on JVM 25 because they have a local class IO and attempt IO() constructor proxy syntax, which is disallowed because java.lang.IO.

I agree that imports are, if not totally broken, then at least unmanageable.

I don’t know what the status is of Li Haoyi imports, which specify maven coordinates for the REPL.

Why can’t ordinary source specify their version requirements, which then see the same conflict resolutions we currently have in the build tool?

It follows that if I can specify maven versions, I can specify locally residing versions.

The objection to -Yimports is that you must be able to read from the source text what, for example, the identifier IO means. Obviously this is not the case in (checks watch) 2026.

I wouldn’t mind a syntax export mylib:blah:1.0 at a package to mean that subpackages incurring lookups in mylib will use that version. (I have not given this any thought.)

I hope we can just refer to Li imports as limports, though I’d also be OK with haoyimports.

2 Likes

It does seem that Metals already can provide in the UI of VS Code the mapping from a symbol to its jar location (which also powers go-to-source) so can this be exposed to more tooling programatically?

We should be able to provide for sure. I now realized that by default we return virtual docs, which might be hard for the agents to figure out. But you can start metals as a standalone LSP server and add it as a plugin in Claude. I that case we will actually unpack the files with definition and point the agent there.

2 Likes

Sorry, wrong thread.

1 Like