Repository: oxc-project/oxc-resolver
Pull request #360 (2025-01-18 13:50:04)

Manifest traits
This introduces `PackageJson` and `TsConfig` traits. The old structs have been renamed to `PackageJsonSerde` and TsConfigSerde` respectively. Both are behind the `fs_cache` feature flag now. `serde` as a dependency has become optional and is only needed for the `fs_cache` feature too.

For now, I have opted not to replace the `FileSystem::read_to_string()` method with trait-specific versions yet, since this functionality is now all encapsulated within the `FsCache`. Consumers that wish to use custom implementation of the manifest traits most likely want to use a custom cache altogether (I know this will be true for Biome at least), so I didn't see much reason for additional complexity and breaking changes there now.

~~I'm still working on a Biome PoC based on this, so I'll keep the PR on Draft until I can verify everything works. Feedback is certainly already welcome!~~ **Update:** See https://github.com/biomejs/biome/pull/4929

codspeed-hq[bot]: <!-- __CODSPEED_PERFORMANCE_REPORT_COMMENT__ -->
<!-- __CODSPEED_INSTRUMENTATION_PERFORMANCE_REPORT_COMMENT__ -->

## [CodSpeed Performance Report](https://codspeed.io/oxc-project/oxc-resolver/branches/arendjr%3Amanifest-traits)

### Merging #360 will **not alter performance**

<sub>Comparing <code>arendjr:manifest-traits</code> (1f16116) with <code>main</code> (2098f8a)</sub>



### Summary

`✅ 3` untouched benchmarks  




arendjr: Biome PR seems to be coming along well, so I think from our side this is good :+1:

Patch:
diff --git a/Cargo.toml b/Cargo.toml
index 35f136e..509116d 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -70,8 +70,8 @@ once_cell = "1" # Use `std::sync::OnceLock::get_or_try_init` when it is stable.
 papaya = "0.1.8"
 rustc-hash = { version = "2" }
 seize = { version = "0.4" }
-serde = { version = "1", features = ["derive"] } # derive for Deserialize from package.json
-serde_json = { version = "1", features = ["preserve_order"] } # preserve_order: package_json.exports requires order such as `["require", "import", "default"]`
+serde = { version = "1", features = ["derive"], optional = true } # derive for Deserialize from package.json
+serde_json = { version = "1", features = ["preserve_order"], optional = true } # preserve_order: package_json.exports requires order such as `["require", "import", "default"]`
 simdutf8 = { version = "0.1" }
 thiserror = "1"
 tracing = "0.1"
@@ -89,8 +89,8 @@ vfs = "0.12.0" # for testing with in memory file system
 [features]
 default = ["fs_cache"]
 ## Provides the `FsCache` implementation.
-fs_cache = []
-## Enables the [PackageJson::raw_json] API,
+fs_cache = ["dep:serde", "dep:serde_json"]
+## Enables the [PackageJsonSerde::raw_json] API,
 ## which returns the `package.json` with `serde_json::Value`.
 package_json_raw_json_api = []
 ## [Yarn Plug'n'Play](https://yarnpkg.com/features/pnp)
diff --git a/napi/src/lib.rs b/napi/src/lib.rs
index d41cb08..9593c50 100644
--- a/napi/src/lib.rs
+++ b/napi/src/lib.rs
@@ -9,7 +9,7 @@ use std::{
 
 use napi::{bindgen_prelude::AsyncTask, Task};
 use napi_derive::napi;
-use oxc_resolver::{ResolveOptions, Resolver};
+use oxc_resolver::{PackageJson, ResolveOptions, Resolver};
 
 use self::{
     options::{NapiResolveOptions, StrOrStrList},
@@ -32,11 +32,7 @@ fn resolve(resolver: &Resolver, path: &Path, request: &str) -> ResolveResult {
         Ok(resolution) => ResolveResult {
             path: Some(resolution.full_path().to_string_lossy().to_string()),
             error: None,
-            module_type: resolution
-                .package_json()
-                .and_then(|p| p.r#type.as_ref())
-                .and_then(|t| t.as_str())
-                .map(|t| t.to_string()),
+            module_type: resolution.package_json().and_then(|p| p.r#type()).map(|t| t.to_string()),
         },
         Err(err) => ResolveResult { path: None, module_type: None, error: Some(err.to_string()) },
     }
diff --git a/src/cache.rs b/src/cache.rs
index 14841cf..2156661 100644
--- a/src/cache.rs
+++ b/src/cache.rs
@@ -1,13 +1,16 @@
 use std::{
+    fmt::Debug,
     path::{Path, PathBuf},
     sync::Arc,
 };
 
-use crate::{tsconfig::TsConfig, Ctx, PackageJson, ResolveError, ResolveOptions};
+use crate::{Ctx, PackageJson, ResolveError, ResolveOptions, TsConfig};
 
 #[allow(clippy::missing_errors_doc)] // trait impls should be free to return any typesafe error
 pub trait Cache: Sized {
     type Cp: CachedPath + Clone;
+    type Pj: PackageJson;
+    type Tc: TsConfig + Debug;
 
     /// Clears the cache.
     fn clear(&self);
@@ -34,7 +37,7 @@ pub trait Cache: Sized {
         path: &Self::Cp,
         options: &ResolveOptions,
         ctx: &mut Ctx,
-    ) -> Result<Option<(Self::Cp, Arc<PackageJson>)>, ResolveError>;
+    ) -> Result<Option<(Self::Cp, Arc<Self::Pj>)>, ResolveError>;
 
     /// Returns the tsconfig stored in the given path.
     ///
@@ -43,14 +46,15 @@ pub trait Cache: Sized {
     ///
     /// `callback` can be used for modifying the returned tsconfig with
     /// `extends`.
-    fn get_tsconfig<F: FnOnce(&mut TsConfig) -> Result<(), ResolveError>>(
+    fn get_tsconfig<F: FnOnce(&mut Self::Tc) -> Result<(), ResolveError>>(
         &self,
         root: bool,
         path: &Path,
         callback: F,
-    ) -> Result<Arc<TsConfig>, ResolveError>;
+    ) -> Result<Arc<Self::Tc>, ResolveError>;
 }
 
+#[allow(clippy::missing_errors_doc)] // trait impls should be free to return any typesafe error
 pub trait CachedPath: Sized {
     fn path(&self) -> &Path;
 
@@ -68,16 +72,13 @@ pub trait CachedPath: Sized {
     fn cached_node_modules<C: Cache<Cp = Self>>(&self, cache: &C, ctx: &mut Ctx) -> Option<Self>;
 
     /// Find package.json of a path by traversing parent directories.
-    ///
-    /// # Errors
-    ///
-    /// * [ResolveError::JSON]
+    #[allow(clippy::type_complexity)]
     fn find_package_json<C: Cache<Cp = Self>>(
         &self,
         options: &ResolveOptions,
         cache: &C,
         ctx: &mut Ctx,
-    ) -> Result<Option<(Self, Arc<PackageJson>)>, ResolveError>;
+    ) -> Result<Option<(Self, Arc<C::Pj>)>, ResolveError>;
 
     #[must_use]
     fn add_extension<C: Cache<Cp = Self>>(&self, ext: &str, cache: &C) -> Self;
diff --git a/src/error.rs b/src/error.rs
index 61778d2..c4cb4e0 100644
--- a/src/error.rs
+++ b/src/error.rs
@@ -108,6 +108,7 @@ impl ResolveError {
     }
 
     #[must_use]
+    #[cfg(feature = "fs_cache")]
     pub fn from_serde_json_error(path: PathBuf, error: &serde_json::Error) -> Self {
         Self::JSON(JSONError {
             path,
diff --git a/src/fs_cache.rs b/src/fs_cache.rs
index ff73c24..2d835d0 100644
--- a/src/fs_cache.rs
+++ b/src/fs_cache.rs
@@ -20,9 +20,9 @@ use rustc_hash::FxHasher;
 use crate::{
     cache::{Cache, CachedPath},
     context::ResolveContext as Ctx,
-    package_json::PackageJson,
     path::PathUtil,
-    FileMetadata, FileSystem, ResolveError, ResolveOptions, TsConfig,
+    FileMetadata, FileSystem, PackageJsonSerde, ResolveError, ResolveOptions, TsConfig,
+    TsConfigSerde,
 };
 
 static THREAD_COUNT: AtomicU64 = AtomicU64::new(1);
@@ -39,11 +39,13 @@ thread_local! {
 pub struct FsCache<Fs> {
     pub(crate) fs: Fs,
     paths: HashSet<FsCachedPath, BuildHasherDefault<IdentityHasher>>,
-    tsconfigs: HashMap<PathBuf, Arc<TsConfig>, BuildHasherDefault<FxHasher>>,
+    tsconfigs: HashMap<PathBuf, Arc<TsConfigSerde>, BuildHasherDefault<FxHasher>>,
 }
 
 impl<Fs: FileSystem> Cache for FsCache<Fs> {
     type Cp = FsCachedPath;
+    type Pj = PackageJsonSerde;
+    type Tc = TsConfigSerde;
 
     fn clear(&self) {
         self.paths.pin().clear();
@@ -109,7 +111,7 @@ impl<Fs: FileSystem> Cache for FsCache<Fs> {
         path: &Self::Cp,
         options: &ResolveOptions,
         ctx: &mut Ctx,
-    ) -> Result<Option<(Self::Cp, Arc<PackageJson>)>, ResolveError> {
+    ) -> Result<Option<(Self::Cp, Arc<PackageJsonSerde>)>, ResolveError> {
         // Change to `std::sync::OnceLock::get_or_try_init` when it is stable.
         let result = path
             .package_json
@@ -123,7 +125,7 @@ impl<Fs: FileSystem> Cache for FsCache<Fs> {
                 } else {
                     package_json_path.clone()
                 };
-                PackageJson::parse(package_json_path.clone(), real_path, &package_json_string)
+                PackageJsonSerde::parse(package_json_path.clone(), real_path, &package_json_string)
                     .map(|package_json| Some((path.clone(), (Arc::new(package_json)))))
                     .map_err(|error| ResolveError::from_serde_json_error(package_json_path, &error))
             })
@@ -148,12 +150,12 @@ impl<Fs: FileSystem> Cache for FsCache<Fs> {
         result
     }
 
-    fn get_tsconfig<F: FnOnce(&mut TsConfig) -> Result<(), ResolveError>>(
+    fn get_tsconfig<F: FnOnce(&mut TsConfigSerde) -> Result<(), ResolveError>>(
         &self,
         root: bool,
         path: &Path,
         callback: F, // callback for modifying tsconfig with `extends`
-    ) -> Result<Arc<TsConfig>, ResolveError> {
+    ) -> Result<Arc<TsConfigSerde>, ResolveError> {
         let tsconfigs = self.tsconfigs.pin();
         if let Some(tsconfig) = tsconfigs.get(path) {
             return Ok(Arc::clone(tsconfig));
@@ -172,12 +174,13 @@ impl<Fs: FileSystem> Cache for FsCache<Fs> {
             .fs
             .read_to_string(&tsconfig_path)
             .map_err(|_| ResolveError::TsconfigNotFound(path.to_path_buf()))?;
-        let mut tsconfig =
-            TsConfig::parse(root, &tsconfig_path, &mut tsconfig_string).map_err(|error| {
+        let mut tsconfig = TsConfigSerde::parse(root, &tsconfig_path, &mut tsconfig_string)
+            .map_err(|error| {
                 ResolveError::from_serde_json_error(tsconfig_path.to_path_buf(), &error)
             })?;
         callback(&mut tsconfig)?;
-        let tsconfig = Arc::new(tsconfig.build());
+        tsconfig.expand_template_variables();
+        let tsconfig = Arc::new(tsconfig);
         tsconfigs.insert(path.to_path_buf(), Arc::clone(&tsconfig));
         Ok(tsconfig)
     }
@@ -264,7 +267,7 @@ pub struct CachedPathImpl {
     canonicalized: OnceLock<Result<FsCachedPath, ResolveError>>,
     canonicalizing: AtomicU64,
     node_modules: OnceLock<Option<FsCachedPath>>,
-    package_json: OnceLock<Option<(FsCachedPath, Arc<PackageJson>)>>,
+    package_json: OnceLock<Option<(FsCachedPath, Arc<PackageJsonSerde>)>>,
 }
 
 impl CachedPathImpl {
@@ -327,7 +330,7 @@ impl CachedPath for FsCachedPath {
         options: &ResolveOptions,
         cache: &C,
         ctx: &mut Ctx,
-    ) -> Result<Option<(Self, Arc<PackageJson>)>, ResolveError> {
+    ) -> Result<Option<(Self, Arc<C::Pj>)>, ResolveError> {
         let mut cache_value = self;
         // Go up directories when the querying path is not a directory
         while !cache.is_dir(cache_value, ctx) {
diff --git a/src/lib.rs b/src/lib.rs
index d4c5934..230ac36 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -57,10 +57,14 @@ mod file_system;
 mod fs_cache;
 mod options;
 mod package_json;
+#[cfg(feature = "fs_cache")]
+mod package_json_serde;
 mod path;
 mod resolution;
 mod specifier;
 mod tsconfig;
+#[cfg(feature = "fs_cache")]
+mod tsconfig_serde;
 
 #[cfg(test)]
 mod tests;
@@ -75,14 +79,18 @@ use std::{
 };
 
 use rustc_hash::FxHashSet;
-use serde_json::Value as JSONValue;
 
 #[cfg(feature = "fs_cache")]
 pub use crate::{
     file_system::{FileMetadata, FileSystem, FileSystemOs},
     fs_cache::{FsCache, FsCachedPath},
+    package_json_serde::PackageJsonSerde,
+    tsconfig_serde::{CompilerOptionsSerde, ExtendsField, ProjectReferenceSerde, TsConfigSerde},
 };
 
+#[cfg(feature = "fs_cache")]
+pub type FsResolution = Resolution<FsCache<FileSystemOs>>;
+
 pub use crate::{
     builtins::NODEJS_BUILTINS,
     cache::{Cache, CachedPath},
@@ -91,18 +99,15 @@ pub use crate::{
         Alias, AliasValue, EnforceExtension, ResolveOptions, Restriction, TsconfigOptions,
         TsconfigReferences,
     },
-    package_json::PackageJson,
-    resolution::Resolution,
-    tsconfig::{
-        CompilerOptions, CompilerOptionsPathsMap, ExtendsField, ProjectReference, TsConfig,
+    package_json::{
+        ImportsExportsArray, ImportsExportsEntry, ImportsExportsKind, ImportsExportsMap,
+        PackageJson, PackageType,
     },
+    path::PathUtil,
+    resolution::Resolution,
+    tsconfig::{CompilerOptions, CompilerOptionsPathsMap, ProjectReference, TsConfig},
 };
-use crate::{
-    context::ResolveContext as Ctx,
-    package_json::JSONMap,
-    path::{PathUtil, SLASH_START},
-    specifier::Specifier,
-};
+use crate::{context::ResolveContext as Ctx, path::SLASH_START, specifier::Specifier};
 
 type ResolveResult<Cp> = Result<Option<Cp>, ResolveError>;
 
@@ -182,7 +187,7 @@ impl<C: Cache> ResolverGeneric<C> {
         &self,
         directory: P,
         specifier: &str,
-    ) -> Result<Resolution, ResolveError> {
+    ) -> Result<Resolution<C>, ResolveError> {
         let mut ctx = Ctx::default();
         self.resolve_tracing(directory.as_ref(), specifier, &mut ctx)
     }
@@ -198,7 +203,7 @@ impl<C: Cache> ResolverGeneric<C> {
     /// # Errors
     ///
     /// * See [ResolveError]
-    pub fn resolve_tsconfig<P: AsRef<Path>>(&self, path: P) -> Result<Arc<TsConfig>, ResolveError> {
+    pub fn resolve_tsconfig<P: AsRef<Path>>(&self, path: P) -> Result<Arc<C::Tc>, ResolveError> {
         let path = path.as_ref();
         self.load_tsconfig(true, path, &TsconfigReferences::Auto)
     }
@@ -213,7 +218,7 @@ impl<C: Cache> ResolverGeneric<C> {
         directory: P,
         specifier: &str,
         resolve_context: &mut ResolveContext,
-    ) -> Result<Resolution, ResolveError> {
+    ) -> Result<Resolution<C>, ResolveError> {
         let mut ctx = Ctx::default();
         ctx.init_file_dependencies();
         let result = self.resolve_tracing(directory.as_ref(), specifier, &mut ctx);
@@ -232,7 +237,7 @@ impl<C: Cache> ResolverGeneric<C> {
         directory: &Path,
         specifier: &str,
         ctx: &mut Ctx,
-    ) -> Result<Resolution, ResolveError> {
+    ) -> Result<Resolution<C>, ResolveError> {
         let span = tracing::debug_span!("resolve", path = ?directory, specifier = specifier);
         let _enter = span.enter();
         let r = self.resolve_impl(directory, specifier, ctx);
@@ -252,7 +257,7 @@ impl<C: Cache> ResolverGeneric<C> {
         path: &Path,
         specifier: &str,
         ctx: &mut Ctx,
-    ) -> Result<Resolution, ResolveError> {
+    ) -> Result<Resolution<C>, ResolveError> {
         ctx.with_fully_specified(self.options.fully_specified);
         let cached_path = self.cache.value(path);
         let cached_path = self.require(&cached_path, specifier, ctx)?;
@@ -872,7 +877,7 @@ impl<C: Cache> ResolverGeneric<C> {
         // Note: The subpath is not prepended with a dot on purpose
         for exports in package_json.exports_fields(&self.options.exports_fields) {
             if let Some(path) =
-                self.package_exports_resolve(cached_path, &format!(".{subpath}"), exports, ctx)?
+                self.package_exports_resolve(cached_path, &format!(".{subpath}"), &exports, ctx)?
             {
                 // 6. RESOLVE_ESM_MATCH(MATCH)
                 return self.resolve_esm_match(specifier, &path, ctx);
@@ -897,8 +902,7 @@ impl<C: Cache> ResolverGeneric<C> {
         // 3. If the SCOPE/package.json "exports" is null or undefined, return.
         // 4. If the SCOPE/package.json "name" is not the first segment of X, return.
         if let Some(subpath) = package_json
-            .name
-            .as_ref()
+            .name()
             .and_then(|package_name| Self::strip_package_name(specifier, package_name))
         {
             // 5. let MATCH = PACKAGE_EXPORTS_RESOLVE(pathToFileURL(SCOPE),
@@ -910,7 +914,7 @@ impl<C: Cache> ResolverGeneric<C> {
                 if let Some(cached_path) = self.package_exports_resolve(
                     &package_url,
                     &format!(".{subpath}"),
-                    exports,
+                    &exports,
                     ctx,
                 )? {
                     // 6. RESOLVE_ESM_MATCH(MATCH)
@@ -945,7 +949,7 @@ impl<C: Cache> ResolverGeneric<C> {
         cached_path: &C::Cp,
         module_specifier: Option<&str>,
         package_url: &C::Cp,
-        package_json: &PackageJson,
+        package_json: &C::Pj,
         ctx: &mut Ctx,
     ) -> ResolveResult<C::Cp> {
         let path = cached_path.path();
@@ -1163,62 +1167,43 @@ impl<C: Cache> ResolverGeneric<C> {
         root: bool,
         path: &Path,
         references: &TsconfigReferences,
-    ) -> Result<Arc<TsConfig>, ResolveError> {
+    ) -> Result<Arc<C::Tc>, ResolveError> {
         self.cache.get_tsconfig(root, path, |tsconfig| {
             let directory = self.cache.value(tsconfig.directory());
             tracing::trace!(tsconfig = ?tsconfig, "load_tsconfig");
 
             // Extend tsconfig
-            if let Some(extends) = &tsconfig.extends {
-                let extended_tsconfig_paths = match extends {
-                    ExtendsField::Single(s) => {
-                        vec![self.get_extended_tsconfig_path(&directory, tsconfig, s)?]
-                    }
-                    ExtendsField::Multiple(specifiers) => specifiers
-                        .iter()
-                        .map(|s| self.get_extended_tsconfig_path(&directory, tsconfig, s))
-                        .collect::<Result<Vec<PathBuf>, ResolveError>>()?,
-                };
-                for extended_tsconfig_path in extended_tsconfig_paths {
-                    let extended_tsconfig = self.load_tsconfig(
-                        /* root */ false,
-                        &extended_tsconfig_path,
-                        &TsconfigReferences::Disabled,
-                    )?;
-                    tsconfig.extend_tsconfig(&extended_tsconfig);
-                }
+            let extended_tsconfig_paths = tsconfig
+                .extends()
+                .map(|specifier| self.get_extended_tsconfig_path(&directory, tsconfig, specifier))
+                .collect::<Result<Vec<_>, _>>()?;
+            for extended_tsconfig_path in extended_tsconfig_paths {
+                let extended_tsconfig = self.load_tsconfig(
+                    /* root */ false,
+                    &extended_tsconfig_path,
+                    &TsconfigReferences::Disabled,
+                )?;
+                tsconfig.extend_tsconfig(&extended_tsconfig);
             }
 
-            // Load project references
-            match references {
-                TsconfigReferences::Disabled => {
-                    tsconfig.references.drain(..);
-                }
-                TsconfigReferences::Auto => {}
-                TsconfigReferences::Paths(paths) => {
-                    tsconfig.references = paths
-                        .iter()
-                        .map(|path| ProjectReference { path: path.clone(), tsconfig: None })
-                        .collect();
-                }
-            }
-            if !tsconfig.references.is_empty() {
+            if tsconfig.load_references(references) {
+                let path = tsconfig.path().to_path_buf();
                 let directory = tsconfig.directory().to_path_buf();
-                for reference in &mut tsconfig.references {
-                    let reference_tsconfig_path = directory.normalize_with(&reference.path);
+                for reference in tsconfig.references_mut() {
+                    let reference_tsconfig_path = directory.normalize_with(reference.path());
                     let tsconfig = self.cache.get_tsconfig(
                         /* root */ true,
                         &reference_tsconfig_path,
                         |reference_tsconfig| {
-                            if reference_tsconfig.path == tsconfig.path {
+                            if reference_tsconfig.path() == path {
                                 return Err(ResolveError::TsconfigSelfReference(
-                                    reference_tsconfig.path.clone(),
+                                    reference_tsconfig.path().to_path_buf(),
                                 ));
                             }
                             Ok(())
                         },
                     )?;
-                    reference.tsconfig.replace(tsconfig);
+                    reference.set_tsconfig(tsconfig);
                 }
             }
             Ok(())
@@ -1252,7 +1237,7 @@ impl<C: Cache> ResolverGeneric<C> {
     fn get_extended_tsconfig_path(
         &self,
         directory: &C::Cp,
-        tsconfig: &TsConfig,
+        tsconfig: &C::Tc,
         specifier: &str,
     ) -> Result<PathBuf, ResolveError> {
         match specifier.as_bytes().first() {
@@ -1313,7 +1298,7 @@ impl<C: Cache> ResolverGeneric<C> {
                             if let Some(path) = self.package_exports_resolve(
                                 &cached_path,
                                 &format!(".{subpath}"),
-                                exports,
+                                &exports,
                                 ctx,
                             )? {
                                 return Ok(Some(path));
@@ -1343,16 +1328,16 @@ impl<C: Cache> ResolverGeneric<C> {
     }
 
     /// PACKAGE_EXPORTS_RESOLVE(packageURL, subpath, exports, conditions)
-    fn package_exports_resolve(
+    fn package_exports_resolve<'a, Io: ImportsExportsEntry<'a>>(
         &self,
         package_url: &C::Cp,
         subpath: &str,
-        exports: &JSONValue,
+        exports: &Io,
         ctx: &mut Ctx,
     ) -> ResolveResult<C::Cp> {
         let conditions = &self.options.condition_names;
         // 1. If exports is an Object with both a key starting with "." and a key not starting with ".", throw an Invalid Package Configuration error.
-        if let JSONValue::Object(map) = exports {
+        if let Some(map) = exports.as_map() {
             let mut has_dot = false;
             let mut without_dot = false;
             for key in map.keys() {
@@ -1381,27 +1366,25 @@ impl<C: Cache> ResolverGeneric<C> {
                 ));
             }
             // 1. Let mainExport be undefined.
-            let main_export = match exports {
+            let main_export = match exports.kind() {
                 // 2. If exports is a String or Array, or an Object containing no keys starting with ".", then
-                JSONValue::String(_) | JSONValue::Array(_) => {
+                ImportsExportsKind::String | ImportsExportsKind::Array => {
                     // 1. Set mainExport to exports.
-                    Some(exports)
+                    Some(Cow::Borrowed(exports))
                 }
                 // 3. Otherwise if exports is an Object containing a "." property, then
-                JSONValue::Object(map) => {
-                    // 1. Set mainExport to exports["."].
+                _ => exports.as_map().and_then(|map| {
                     map.get(".").map_or_else(
                         || {
                             if map.keys().any(|key| key.starts_with("./") || key.starts_with('#')) {
                                 None
                             } else {
-                                Some(exports)
+                                Some(Cow::Borrowed(exports))
                             }
                         },
-                        Some,
+                        |entry| Some(Cow::Owned(entry)),
                     )
-                }
-                _ => None,
+                }),
             };
             // 4. If mainExport is not undefined, then
             if let Some(main_export) = main_export {
@@ -1409,7 +1392,7 @@ impl<C: Cache> ResolverGeneric<C> {
                 let resolved = self.package_target_resolve(
                     package_url,
                     ".",
-                    main_export,
+                    main_export.as_ref(),
                     None,
                     /* is_imports */ false,
                     conditions,
@@ -1422,14 +1405,14 @@ impl<C: Cache> ResolverGeneric<C> {
             }
         }
         // 3. Otherwise, if exports is an Object and all keys of exports start with ".", then
-        if let JSONValue::Object(exports) = exports {
+        if let Some(exports) = exports.as_map() {
             // 1. Let matchKey be the string "./" concatenated with subpath.
             // Note: `package_imports_exports_resolve` does not require the leading dot.
             let match_key = &subpath;
             // 2. Let resolved be the result of PACKAGE_IMPORTS_EXPORTS_RESOLVE( matchKey, exports, packageURL, false, conditions).
             if let Some(path) = self.package_imports_exports_resolve(
                 match_key,
-                exports,
+                &exports,
                 package_url,
                 /* is_imports */ false,
                 conditions,
@@ -1450,7 +1433,7 @@ impl<C: Cache> ResolverGeneric<C> {
     fn package_imports_resolve(
         &self,
         specifier: &str,
-        package_json: &PackageJson,
+        package_json: &C::Pj,
         ctx: &mut Ctx,
     ) -> Result<Option<C::Cp>, ResolveError> {
         // 1. Assert: specifier begins with "#".
@@ -1472,13 +1455,13 @@ impl<C: Cache> ResolverGeneric<C> {
                 if specifier == "#" || specifier.starts_with("#/") {
                     return Err(ResolveError::InvalidModuleSpecifier(
                         specifier.to_string(),
-                        package_json.path.clone(),
+                        package_json.path().to_path_buf(),
                     ));
                 }
             }
             if let Some(path) = self.package_imports_exports_resolve(
                 specifier,
-                imports,
+                &imports,
                 &self.cache.value(package_json.directory()),
                 /* is_imports */ true,
                 &self.options.condition_names,
@@ -1493,7 +1476,7 @@ impl<C: Cache> ResolverGeneric<C> {
         if has_imports {
             Err(ResolveError::PackageImportNotDefined(
                 specifier.to_string(),
-                package_json.path.clone(),
+                package_json.path().to_path_buf(),
             ))
         } else {
             Ok(None)
@@ -1501,10 +1484,10 @@ impl<C: Cache> ResolverGeneric<C> {
     }
 
     /// PACKAGE_IMPORTS_EXPORTS_RESOLVE(matchKey, matchObj, packageURL, isImports, conditions)
-    fn package_imports_exports_resolve(
+    fn package_imports_exports_resolve<'a, Io: ImportsExportsMap<'a>>(
         &self,
         match_key: &str,
-        match_obj: &JSONMap,
+        match_obj: &Io,
         package_url: &C::Cp,
         is_imports: bool,
         conditions: &[String],
@@ -1523,7 +1506,7 @@ impl<C: Cache> ResolverGeneric<C> {
                 return self.package_target_resolve(
                     package_url,
                     match_key,
-                    target,
+                    &target,
                     None,
                     is_imports,
                     conditions,
@@ -1537,7 +1520,7 @@ impl<C: Cache> ResolverGeneric<C> {
         let mut best_key = "";
         // 2. Let expansionKeys be the list of keys of matchObj containing only a single "*", sorted by the sorting function PATTERN_KEY_COMPARE which orders in descending order of specificity.
         // 3. For each key expansionKey in expansionKeys, do
-        for (expansion_key, target) in match_obj {
+        for (expansion_key, target) in match_obj.iter() {
             if expansion_key.starts_with("./") || expansion_key.starts_with('#') {
                 // 1. Let patternBase be the substring of expansionKey up to but excluding the first "*" character.
                 if let Some((pattern_base, pattern_trailer)) = expansion_key.split_once('*') {
@@ -1574,7 +1557,7 @@ impl<C: Cache> ResolverGeneric<C> {
             return self.package_target_resolve(
                 package_url,
                 best_key,
-                best_target,
+                &best_target,
                 Some(best_match),
                 is_imports,
                 conditions,
@@ -1587,11 +1570,11 @@ impl<C: Cache> ResolverGeneric<C> {
 
     /// PACKAGE_TARGET_RESOLVE(packageURL, target, patternMatch, isImports, conditions)
     #[allow(clippy::too_many_arguments)]
-    fn package_target_resolve(
+    fn package_target_resolve<'a, Io: ImportsExportsEntry<'a>>(
         &self,
         package_url: &C::Cp,
         target_key: &str,
-        target: &JSONValue,
+        target: &Io,
         pattern_match: Option<&str>,
         is_imports: bool,
         conditions: &[String],
@@ -1623,110 +1606,106 @@ impl<C: Cache> ResolverGeneric<C> {
             Ok(target)
         }
 
-        match target {
-            // 1. If target is a String, then
-            JSONValue::String(target) => {
-                // 1. If target does not start with "./", then
-                if !target.starts_with("./") {
-                    // 1. If isImports is false, or if target starts with "../" or "/", or if target is a valid URL, then
-                    if !is_imports || target.starts_with("../") || target.starts_with('/') {
-                        // 1. Throw an Invalid Package Target error.
-                        return Err(ResolveError::InvalidPackageTarget(
-                            target.to_string(),
-                            target_key.to_string(),
-                            package_url.path().join("package.json"),
-                        ));
-                    }
-                    // 2. If patternMatch is a String, then
-                    //   1. Return PACKAGE_RESOLVE(target with every instance of "*" replaced by patternMatch, packageURL + "/").
-                    let target =
-                        normalize_string_target(target_key, target, pattern_match, package_url)?;
-                    // // 3. Return PACKAGE_RESOLVE(target, packageURL + "/").
-                    return self.package_resolve(package_url, &target, ctx);
-                }
-
-                // 2. If target split on "/" or "\" contains any "", ".", "..", or "node_modules" segments after the first "." segment, case insensitive and including percent encoded variants, throw an Invalid Package Target error.
-                // 3. Let resolvedTarget be the URL resolution of the concatenation of packageURL and target.
-                // 4. Assert: resolvedTarget is contained in packageURL.
-                // 5. If patternMatch is null, then
-                let target =
-                    normalize_string_target(target_key, target, pattern_match, package_url)?;
-                if Path::new(target.as_ref()).is_invalid_exports_target() {
+        // 1. If target is a String, then
+        if let Some(target) = target.as_string() {
+            // 1. If target does not start with "./", then
+            if !target.starts_with("./") {
+                // 1. If isImports is false, or if target starts with "../" or "/", or if target is a valid URL, then
+                if !is_imports || target.starts_with("../") || target.starts_with('/') {
+                    // 1. Throw an Invalid Package Target error.
                     return Err(ResolveError::InvalidPackageTarget(
-                        target.to_string(),
+                        (*target).to_string(),
                         target_key.to_string(),
                         package_url.path().join("package.json"),
                     ));
                 }
-                // 6. If patternMatch split on "/" or "\" contains any "", ".", "..", or "node_modules" segments, case insensitive and including percent encoded variants, throw an Invalid Module Specifier error.
-                // 7. Return the URL resolution of resolvedTarget with every instance of "*" replaced with patternMatch.
-                return Ok(Some(package_url.normalize_with(target.as_ref(), self.cache.as_ref())));
+                // 2. If patternMatch is a String, then
+                //   1. Return PACKAGE_RESOLVE(target with every instance of "*" replaced by patternMatch, packageURL + "/").
+                let target =
+                    normalize_string_target(target_key, target, pattern_match, package_url)?;
+                // // 3. Return PACKAGE_RESOLVE(target, packageURL + "/").
+                return self.package_resolve(package_url, &target, ctx);
             }
-            // 2. Otherwise, if target is a non-null Object, then
-            JSONValue::Object(target) => {
-                // 1. If exports contains any index property keys, as defined in ECMA-262 6.1.7 Array Index, throw an Invalid Package Configuration error.
-                // 2. For each property p of target, in object insertion order as,
-                for (key, target_value) in target {
-                    // 1. If p equals "default" or conditions contains an entry for p, then
-                    if key == "default" || conditions.contains(key) {
-                        // 1. Let targetValue be the value of the p property in target.
-                        // 2. Let resolved be the result of PACKAGE_TARGET_RESOLVE( packageURL, targetValue, patternMatch, isImports, conditions).
-                        let resolved = self.package_target_resolve(
-                            package_url,
-                            target_key,
-                            target_value,
-                            pattern_match,
-                            is_imports,
-                            conditions,
-                            ctx,
-                        );
-                        // 3. If resolved is equal to undefined, continue the loop.
-                        if let Some(path) = resolved? {
-                            // 4. Return resolved.
-                            return Ok(Some(path));
-                        }
-                    }
-                }
-                // 3. Return undefined.
-                return Ok(None);
+
+            // 2. If target split on "/" or "\" contains any "", ".", "..", or "node_modules" segments after the first "." segment, case insensitive and including percent encoded variants, throw an Invalid Package Target error.
+            // 3. Let resolvedTarget be the URL resolution of the concatenation of packageURL and target.
+            // 4. Assert: resolvedTarget is contained in packageURL.
+            // 5. If patternMatch is null, then
+            let target = normalize_string_target(target_key, target, pattern_match, package_url)?;
+            if Path::new(target.as_ref()).is_invalid_exports_target() {
+                return Err(ResolveError::InvalidPackageTarget(
+                    target.to_string(),
+                    target_key.to_string(),
+                    package_url.path().join("package.json"),
+                ));
             }
-            // 3. Otherwise, if target is an Array, then
-            JSONValue::Array(targets) => {
-                // 1. If _target.length is zero, return null.
-                if targets.is_empty() {
-                    // Note: return PackagePathNotExported has the same effect as return because there are no matches.
-                    return Err(ResolveError::PackagePathNotExported(
-                        pattern_match.unwrap_or(".").to_string(),
-                        package_url.path().join("package.json"),
-                    ));
-                }
-                // 2. For each item targetValue in target, do
-                for (i, target_value) in targets.iter().enumerate() {
-                    // 1. Let resolved be the result of PACKAGE_TARGET_RESOLVE( packageURL, targetValue, patternMatch, isImports, conditions), continuing the loop on any Invalid Package Target error.
+            // 6. If patternMatch split on "/" or "\" contains any "", ".", "..", or "node_modules" segments, case insensitive and including percent encoded variants, throw an Invalid Module Specifier error.
+            // 7. Return the URL resolution of resolvedTarget with every instance of "*" replaced with patternMatch.
+            return Ok(Some(package_url.normalize_with(target.as_ref(), self.cache.as_ref())));
+        }
+        // 2. Otherwise, if target is a non-null Object, then
+        else if let Some(target) = target.as_map() {
+            // 1. If exports contains any index property keys, as defined in ECMA-262 6.1.7 Array Index, throw an Invalid Package Configuration error.
+            // 2. For each property p of target, in object insertion order as,
+            for (key, target_value) in target.iter() {
+                // 1. If p equals "default" or conditions contains an entry for p, then
+                if key == "default" || conditions.iter().any(|condition| condition == key) {
+                    // 1. Let targetValue be the value of the p property in target.
+                    // 2. Let resolved be the result of PACKAGE_TARGET_RESOLVE( packageURL, targetValue, patternMatch, isImports, conditions).
                     let resolved = self.package_target_resolve(
                         package_url,
                         target_key,
-                        target_value,
+                        &target_value,
                         pattern_match,
                         is_imports,
                         conditions,
                         ctx,
                     );
-
-                    if resolved.is_err() && i == targets.len() {
-                        return resolved;
-                    }
-
-                    // 2. If resolved is undefined, continue the loop.
-                    if let Ok(Some(path)) = resolved {
-                        // 3. Return resolved.
+                    // 3. If resolved is equal to undefined, continue the loop.
+                    if let Some(path) = resolved? {
+                        // 4. Return resolved.
                         return Ok(Some(path));
                     }
                 }
-                // 3. Return or throw the last fallback resolution null return or error.
-                // Note: see `resolved.is_err() && i == targets.len()`
             }
-            _ => {}
+            // 3. Return undefined.
+            return Ok(None);
+        }
+        // 3. Otherwise, if target is an Array, then
+        else if let Some(targets) = target.as_array() {
+            // 1. If _target.length is zero, return null.
+            if targets.is_empty() {
+                // Note: return PackagePathNotExported has the same effect as return because there are no matches.
+                return Err(ResolveError::PackagePathNotExported(
+                    pattern_match.unwrap_or(".").to_string(),
+                    package_url.path().join("package.json"),
+                ));
+            }
+            // 2. For each item targetValue in target, do
+            for (i, target_value) in targets.iter().enumerate() {
+                // 1. Let resolved be the result of PACKAGE_TARGET_RESOLVE( packageURL, targetValue, patternMatch, isImports, conditions), continuing the loop on any Invalid Package Target error.
+                let resolved = self.package_target_resolve(
+                    package_url,
+                    target_key,
+                    &target_value,
+                    pattern_match,
+                    is_imports,
+                    conditions,
+                    ctx,
+                );
+
+                if resolved.is_err() && i == targets.len() {
+                    return resolved;
+                }
+
+                // 2. If resolved is undefined, continue the loop.
+                if let Ok(Some(path)) = resolved {
+                    // 3. Return resolved.
+                    return Ok(Some(path));
+                }
+            }
+            // 3. Return or throw the last fallback resolution null return or error.
+            // Note: see `resolved.is_err() && i == targets.len()`
         }
         // 4. Otherwise, if target is null, return null.
         Ok(None)
diff --git a/src/package_json.rs b/src/package_json.rs
index aa4b76e..5d0c92e 100644
--- a/src/package_json.rs
+++ b/src/package_json.rs
@@ -1,221 +1,156 @@
-//! package.json definitions
-//!
-//! Code related to export field are copied from [Parcel's resolver](https://github.com/parcel-bundler/parcel/blob/v2/packages/utils/node-resolver-rs/src/package_json.rs)
-use std::path::{Path, PathBuf};
-
-use serde_json::Value as JSONValue;
-
-use crate::{path::PathUtil, ResolveError};
-
-pub type JSONMap = serde_json::Map<String, JSONValue>;
-
-/// Deserialized package.json
-#[derive(Debug, Default)]
-pub struct PackageJson {
-    /// Path to `package.json`. Contains the `package.json` filename.
-    pub path: PathBuf,
-
-    /// Realpath to `package.json`. Contains the `package.json` filename.
-    pub realpath: PathBuf,
-
-    /// The "name" field defines your package's name.
-    /// The "name" field can be used in addition to the "exports" field to self-reference a package using its name.
-    ///
-    /// <https://nodejs.org/api/packages.html#name>
-    pub name: Option<String>,
-
-    /// The "type" field.
+use std::{fmt::Display, path::Path};
+
+use crate::ResolveError;
+
+/// Abstract representation for the contents of a `package.json` file, as well
+/// as the location where it was found.
+///
+/// This representation makes no assumptions regarding how the file was
+/// deserialized.
+#[allow(clippy::missing_errors_doc)] // trait impls should be free to return any typesafe error
+pub trait PackageJson: Sized {
+    /// Returns the path where the `package.json` was found.
     ///
-    /// <https://nodejs.org/api/packages.html#type>
-    pub r#type: Option<JSONValue>,
-
-    /// The "sideEffects" field.
+    /// Contains the `package.json` filename.
     ///
-    /// <https://webpack.js.org/guides/tree-shaking>
-    pub side_effects: Option<JSONValue>,
-
-    raw_json: std::sync::Arc<JSONValue>,
-}
-
-impl PackageJson {
-    /// # Panics
-    /// # Errors
-    pub(crate) fn parse(
-        path: PathBuf,
-        realpath: PathBuf,
-        json: &str,
-    ) -> Result<Self, serde_json::Error> {
-        let mut raw_json: JSONValue = serde_json::from_str(json)?;
-        let mut package_json = Self::default();
-
-        if let Some(json_object) = raw_json.as_object_mut() {
-            // Remove large fields that are useless for pragmatic use.
-            #[cfg(feature = "package_json_raw_json_api")]
-            {
-                json_object.remove("description");
-                json_object.remove("keywords");
-                json_object.remove("scripts");
-                json_object.remove("dependencies");
-                json_object.remove("devDependencies");
-                json_object.remove("peerDependencies");
-                json_object.remove("optionalDependencies");
-            }
-
-            // Add name, type and sideEffects.
-            package_json.name =
-                json_object.get("name").and_then(|field| field.as_str()).map(ToString::to_string);
-            package_json.r#type = json_object.get("type").cloned();
-            package_json.side_effects = json_object.get("sideEffects").cloned();
-        }
-
-        package_json.path = path;
-        package_json.realpath = realpath;
-        package_json.raw_json = std::sync::Arc::new(raw_json);
-        Ok(package_json)
-    }
-
-    fn get_value_by_path<'a>(
-        fields: &'a serde_json::Map<String, JSONValue>,
-        path: &[String],
-    ) -> Option<&'a JSONValue> {
-        if path.is_empty() {
-            return None;
-        }
-        let mut value = fields.get(&path[0])?;
-        for key in path.iter().skip(1) {
-            if let Some(inner_value) = value.as_object().and_then(|o| o.get(key)) {
-                value = inner_value;
-            } else {
-                return None;
-            }
-        }
-        Some(value)
-    }
+    /// This does not need to be the path where the file is stored on disk.
+    /// See [Self::realpath()].
+    #[must_use]
+    fn path(&self) -> &Path;
 
-    /// Raw serde json value of `package.json`.
+    /// Returns the path where the `package.json` file was stored on disk.
     ///
-    /// This is currently used in Rspack for:
-    /// * getting the `sideEffects` field
-    /// * query in <https://www.rspack.dev/config/module.html#ruledescriptiondata> - search on GitHub indicates query on the `type` field.
+    /// Contains the `package.json` filename.
     ///
-    /// To reduce overall memory consumption, large fields that useless for pragmatic use are removed.
-    /// They are: `description`, `keywords`, `scripts`,
-    /// `dependencies` and `devDependencies`, `peerDependencies`, `optionalDependencies`.
-    #[cfg(feature = "package_json_raw_json_api")]
+    /// This is the canonicalized version of [Self::path()], where all symbolic
+    /// links are resolved.
     #[must_use]
-    pub const fn raw_json(&self) -> &std::sync::Arc<JSONValue> {
-        &self.raw_json
-    }
+    fn realpath(&self) -> &Path;
 
-    /// Directory to `package.json`
+    /// Directory to `package.json`.
     ///
     /// # Panics
     ///
-    /// * When the package.json path is misconfigured.
+    /// * When the `package.json` path is misconfigured.
     #[must_use]
-    pub fn directory(&self) -> &Path {
-        debug_assert!(self.realpath.file_name().is_some_and(|x| x == "package.json"));
-        self.realpath.parent().unwrap()
-    }
+    fn directory(&self) -> &Path;
+
+    /// Name of the package.
+    ///
+    /// The "name" field can be used together with the "exports" field to
+    /// self-reference a package using its name.
+    ///
+    /// <https://nodejs.org/api/packages.html#name>
+    fn name(&self) -> Option<&str>;
+
+    /// Returns the package type, if one is configured in the `package.json`.
+    ///
+    /// <https://nodejs.org/api/packages.html#type>
+    fn r#type(&self) -> Option<PackageType>;
 
-    /// The "main" field defines the entry point of a package when imported by name via a node_modules lookup. Its value is a path.
+    /// The "main" field defines the entry point of a package when imported by
+    /// name via a node_modules lookup. Its value should be a path.
     ///
-    /// When a package has an "exports" field, this will take precedence over the "main" field when importing the package by name.
+    /// When a package has an "exports" field, this will take precedence over
+    /// the "main" field when importing the package by name.
     ///
-    /// Values are dynamically retrieved from [ResolveOptions::main_fields].
+    /// Values are dynamically retrieved from [crate::ResolveOptions::main_fields].
     ///
     /// <https://nodejs.org/api/packages.html#main>
-    pub(crate) fn main_fields<'a>(
-        &'a self,
-        main_fields: &'a [String],
-    ) -> impl Iterator<Item = &'a str> + 'a {
-        main_fields
-            .iter()
-            .filter_map(|main_field| self.raw_json.get(main_field))
-            .filter_map(|value| value.as_str())
-    }
+    #[must_use]
+    fn main_fields<'a>(&'a self, main_fields: &'a [String]) -> impl Iterator<Item = &'a str> + 'a;
 
-    /// The "exports" field allows defining the entry points of a package when imported by name loaded either via a node_modules lookup or a self-reference to its own name.
+    /// The "exports" field allows defining the entry points of a package when
+    /// imported by name loaded either via a node_modules lookup or a
+    /// self-reference to its own name.
     ///
     /// <https://nodejs.org/api/packages.html#exports>
-    pub(crate) fn exports_fields<'a>(
+    #[must_use]
+    fn exports_fields<'a>(
         &'a self,
         exports_fields: &'a [Vec<String>],
-    ) -> impl Iterator<Item = &'a JSONValue> + 'a {
-        exports_fields.iter().filter_map(|object_path| {
-            self.raw_json
-                .as_object()
-                .and_then(|json_object| Self::get_value_by_path(json_object, object_path))
-        })
-    }
+    ) -> impl Iterator<Item = impl ImportsExportsEntry<'a>> + 'a;
 
-    /// In addition to the "exports" field, there is a package "imports" field to create private mappings that only apply to import specifiers from within the package itself.
+    /// In addition to the "exports" field, there is a package "imports" field
+    /// to create private mappings that only apply to import specifiers from
+    /// within the package itself.
     ///
     /// <https://nodejs.org/api/packages.html#subpath-imports>
-    pub(crate) fn imports_fields<'a>(
+    #[must_use]
+    fn imports_fields<'a>(
         &'a self,
         imports_fields: &'a [Vec<String>],
-    ) -> impl Iterator<Item = &'a JSONMap> + 'a {
-        imports_fields.iter().filter_map(|object_path| {
-            self.raw_json
-                .as_object()
-                .and_then(|json_object| Self::get_value_by_path(json_object, object_path))
-                .and_then(|value| value.as_object())
-        })
-    }
+    ) -> impl Iterator<Item = impl ImportsExportsMap<'a>> + 'a;
 
-    /// The "browser" field is provided by a module author as a hint to javascript bundlers or component tools when packaging modules for client side use.
-    /// Multiple values are configured by [ResolveOptions::alias_fields].
+    /// Resolves the request string for this `package.json` by looking at the
+    /// "browser" field.
     ///
     /// <https://github.com/defunctzombie/package-browser-field-spec>
-    fn browser_fields<'a>(
-        &'a self,
-        alias_fields: &'a [Vec<String>],
-    ) -> impl Iterator<Item = &'a JSONMap> + 'a {
-        alias_fields.iter().filter_map(|object_path| {
-            self.raw_json
-                .as_object()
-                .and_then(|json_object| Self::get_value_by_path(json_object, object_path))
-                // Only object is valid, all other types are invalid
-                // https://github.com/webpack/enhanced-resolve/blob/3a28f47788de794d9da4d1702a3a583d8422cd48/lib/AliasFieldPlugin.js#L44-L52
-                .and_then(|value| value.as_object())
-        })
-    }
-
-    /// Resolve the request string for this package.json by looking at the `browser` field.
-    ///
-    /// # Errors
-    ///
-    /// * Returns [ResolveError::Ignored] for `"path": false` in `browser` field.
-    pub(crate) fn resolve_browser_field<'a>(
+    fn resolve_browser_field<'a>(
         &'a self,
         path: &Path,
         request: Option<&str>,
         alias_fields: &'a [Vec<String>],
-    ) -> Result<Option<&'a str>, ResolveError> {
-        for object in self.browser_fields(alias_fields) {
-            if let Some(request) = request {
-                if let Some(value) = object.get(request) {
-                    return Self::alias_value(path, value);
-                }
-            } else {
-                let dir = self.path.parent().unwrap();
-                for (key, value) in object {
-                    let joined = dir.normalize_with(key);
-                    if joined == path {
-                        return Self::alias_value(path, value);
-                    }
-                }
-            }
+    ) -> Result<Option<&'a str>, ResolveError>;
+}
+
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
+#[cfg_attr(feature = "fs_cache", derive(serde::Deserialize, serde::Serialize))]
+#[cfg_attr(feature = "fs_cache", serde(rename_all = "lowercase"))]
+pub enum PackageType {
+    CommonJs,
+    Module,
+}
+
+impl Display for PackageType {
+    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+        match self {
+            Self::CommonJs => f.write_str("commonjs"),
+            Self::Module => f.write_str("module"),
         }
-        Ok(None)
     }
+}
 
-    fn alias_value<'a>(key: &Path, value: &'a JSONValue) -> Result<Option<&'a str>, ResolveError> {
-        match value {
-            JSONValue::String(value) => Ok(Some(value.as_str())),
-            JSONValue::Bool(b) if !b => Err(ResolveError::Ignored(key.to_path_buf())),
-            _ => Ok(None),
-        }
+/// Trait used for representing entries in the `imports` and `exports` fields
+/// without allocation.
+pub trait ImportsExportsEntry<'a>: Clone + Sized {
+    type Array: ImportsExportsArray<'a, Entry = Self>;
+    type Map: ImportsExportsMap<'a, Entry = Self>;
+
+    fn kind(&self) -> ImportsExportsKind;
+
+    fn as_string(&self) -> Option<&'a str>;
+    fn as_array(&self) -> Option<Self::Array>;
+    fn as_map(&self) -> Option<Self::Map>;
+}
+
+/// Trait used for representing array values in the `imports` and `exports`
+/// fields without allocation.
+pub trait ImportsExportsArray<'a>: Clone + Sized {
+    type Entry: ImportsExportsEntry<'a, Array = Self>;
+
+    fn is_empty(&self) -> bool {
+        self.len() == 0
     }
+
+    fn len(&self) -> usize;
+    fn iter(&self) -> impl Iterator<Item = Self::Entry>;
+}
+
+/// Trait used for representing map (object) values in the `imports` and
+/// `exports` fields without allocation.
+pub trait ImportsExportsMap<'a>: Clone + Sized {
+    type Entry: ImportsExportsEntry<'a, Map = Self>;
+
+    fn get(&self, key: &str) -> Option<Self::Entry>;
+    fn keys(&self) -> impl Iterator<Item = &'a str>;
+    fn iter(&self) -> impl Iterator<Item = (&'a str, Self::Entry)>;
+}
+
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
+pub enum ImportsExportsKind {
+    String,
+    Array,
+    Map,
+    Invalid,
 }
diff --git a/src/package_json_serde.rs b/src/package_json_serde.rs
new file mode 100644
index 0000000..132f67c
--- /dev/null
+++ b/src/package_json_serde.rs
@@ -0,0 +1,337 @@
+//! package.json definitions
+//!
+//! Code related to export field are copied from [Parcel's resolver](https://github.com/parcel-bundler/parcel/blob/v2/packages/utils/node-resolver-rs/src/package_json.rs)
+use std::path::{Path, PathBuf};
+
+use serde_json::Value as JSONValue;
+
+use crate::{
+    package_json::{ImportsExportsArray, ImportsExportsEntry, PackageType},
+    path::PathUtil,
+    ImportsExportsKind, ImportsExportsMap, PackageJson, ResolveError,
+};
+
+pub type JSONMap = serde_json::Map<String, JSONValue>;
+
+/// Serde implementation for the deserialized `package.json`.
+///
+/// This implementation is used by the [crate::FsCache] and enabled through the
+/// `fs_cache` feature.
+#[cfg(feature = "fs_cache")]
+#[derive(Debug, Default)]
+pub struct PackageJsonSerde {
+    /// Path to `package.json`. Contains the `package.json` filename.
+    pub path: PathBuf,
+
+    /// Realpath to `package.json`. Contains the `package.json` filename.
+    pub realpath: PathBuf,
+
+    /// Name of the package.
+    pub name: Option<String>,
+
+    /// The "type" field.
+    ///
+    /// <https://nodejs.org/api/packages.html#type>
+    pub r#type: Option<PackageType>,
+
+    /// The "sideEffects" field.
+    ///
+    /// <https://webpack.js.org/guides/tree-shaking>
+    pub side_effects: Option<JSONValue>,
+
+    raw_json: std::sync::Arc<JSONValue>,
+}
+
+#[allow(refining_impl_trait)]
+impl PackageJson for PackageJsonSerde {
+    fn path(&self) -> &Path {
+        &self.path
+    }
+
+    fn realpath(&self) -> &Path {
+        &self.realpath
+    }
+
+    fn directory(&self) -> &Path {
+        debug_assert!(self.realpath.file_name().is_some_and(|x| x == "package.json"));
+        self.realpath.parent().unwrap()
+    }
+
+    fn name(&self) -> Option<&str> {
+        self.name.as_deref()
+    }
+
+    fn r#type(&self) -> Option<PackageType> {
+        self.r#type
+    }
+
+    fn main_fields<'a>(&'a self, main_fields: &'a [String]) -> impl Iterator<Item = &'a str> + 'a {
+        main_fields
+            .iter()
+            .filter_map(|main_field| self.raw_json.get(main_field))
+            .filter_map(JSONValue::as_str)
+    }
+
+    fn exports_fields<'a>(
+        &'a self,
+        exports_fields: &'a [Vec<String>],
+    ) -> impl Iterator<Item = ImportsExportsSerdeEntry<'a>> + 'a {
+        exports_fields
+            .iter()
+            .filter_map(|object_path| {
+                self.raw_json
+                    .as_object()
+                    .and_then(|json_object| Self::get_value_by_path(json_object, object_path))
+            })
+            .map(ImportsExportsSerdeEntry)
+    }
+
+    fn imports_fields<'a>(
+        &'a self,
+        imports_fields: &'a [Vec<String>],
+    ) -> impl Iterator<Item = ImportsExportsSerdeMap<'a>> + 'a {
+        imports_fields
+            .iter()
+            .filter_map(|object_path| {
+                self.raw_json
+                    .as_object()
+                    .and_then(|json_object| Self::get_value_by_path(json_object, object_path))
+                    .and_then(JSONValue::as_object)
+            })
+            .map(ImportsExportsSerdeMap)
+    }
+
+    fn resolve_browser_field<'a>(
+        &'a self,
+        path: &Path,
+        request: Option<&str>,
+        alias_fields: &'a [Vec<String>],
+    ) -> Result<Option<&'a str>, ResolveError> {
+        for object in self.browser_fields(alias_fields) {
+            if let Some(request) = request {
+                if let Some(value) = object.get(request) {
+                    return Self::alias_value(path, value);
+                }
+            } else {
+                let dir = self.path.parent().unwrap();
+                for (key, value) in object {
+                    let joined = dir.normalize_with(key);
+                    if joined == path {
+                        return Self::alias_value(path, value);
+                    }
+                }
+            }
+        }
+        Ok(None)
+    }
+}
+
+impl PackageJsonSerde {
+    /// # Panics
+    /// # Errors
+    pub(crate) fn parse(
+        path: PathBuf,
+        realpath: PathBuf,
+        json: &str,
+    ) -> Result<Self, serde_json::Error> {
+        let mut raw_json: JSONValue = serde_json::from_str(json)?;
+        let mut package_json = Self::default();
+
+        if let Some(json_object) = raw_json.as_object_mut() {
+            // Remove large fields that are useless for pragmatic use.
+            #[cfg(feature = "package_json_raw_json_api")]
+            {
+                json_object.remove("description");
+                json_object.remove("keywords");
+                json_object.remove("scripts");
+                json_object.remove("dependencies");
+                json_object.remove("devDependencies");
+                json_object.remove("peerDependencies");
+                json_object.remove("optionalDependencies");
+            }
+
+            // Add name, type and sideEffects.
+            package_json.name =
+                json_object.get("name").and_then(|field| field.as_str()).map(ToString::to_string);
+            package_json.r#type =
+                json_object.get("type").and_then(|ty| serde_json::from_value(ty.clone()).ok());
+            package_json.side_effects = json_object.get("sideEffects").cloned();
+        }
+
+        package_json.path = path;
+        package_json.realpath = realpath;
+        package_json.raw_json = std::sync::Arc::new(raw_json);
+        Ok(package_json)
+    }
+
+    fn get_value_by_path<'a>(
+        fields: &'a serde_json::Map<String, JSONValue>,
+        path: &[String],
+    ) -> Option<&'a JSONValue> {
+        if path.is_empty() {
+            return None;
+        }
+        let mut value = fields.get(&path[0])?;
+        for key in path.iter().skip(1) {
+            if let Some(inner_value) = value.as_object().and_then(|o| o.get(key)) {
+                value = inner_value;
+            } else {
+                return None;
+            }
+        }
+        Some(value)
+    }
+
+    /// Raw serde json value of `package.json`.
+    ///
+    /// This is currently used in Rspack for:
+    /// * getting the `sideEffects` field
+    /// * query in <https://www.rspack.dev/config/module.html#ruledescriptiondata> - search on GitHub indicates query on the `type` field.
+    ///
+    /// To reduce overall memory consumption, large fields that useless for pragmatic use are removed.
+    /// They are: `description`, `keywords`, `scripts`,
+    /// `dependencies` and `devDependencies`, `peerDependencies`, `optionalDependencies`.
+    #[cfg(feature = "package_json_raw_json_api")]
+    #[must_use]
+    pub const fn raw_json(&self) -> &std::sync::Arc<JSONValue> {
+        &self.raw_json
+    }
+
+    /// The "browser" field is provided by a module author as a hint to javascript bundlers or component tools when packaging modules for client side use.
+    /// Multiple values are configured by [ResolveOptions::alias_fields].
+    ///
+    /// <https://github.com/defunctzombie/package-browser-field-spec>
+    fn browser_fields<'a>(
+        &'a self,
+        alias_fields: &'a [Vec<String>],
+    ) -> impl Iterator<Item = &'a JSONMap> + 'a {
+        alias_fields.iter().filter_map(|object_path| {
+            self.raw_json
+                .as_object()
+                .and_then(|json_object| Self::get_value_by_path(json_object, object_path))
+                // Only object is valid, all other types are invalid
+                // https://github.com/webpack/enhanced-resolve/blob/3a28f47788de794d9da4d1702a3a583d8422cd48/lib/AliasFieldPlugin.js#L44-L52
+                .and_then(|value| value.as_object())
+        })
+    }
+
+    fn alias_value<'a>(key: &Path, value: &'a JSONValue) -> Result<Option<&'a str>, ResolveError> {
+        match value {
+            JSONValue::String(value) => Ok(Some(value.as_str())),
+            JSONValue::Bool(b) if !b => Err(ResolveError::Ignored(key.to_path_buf())),
+            _ => Ok(None),
+        }
+    }
+}
+
+#[derive(Clone)]
+pub struct ImportsExportsSerdeEntry<'a>(pub(crate) &'a JSONValue);
+
+impl<'a> ImportsExportsEntry<'a> for ImportsExportsSerdeEntry<'a> {
+    type Array = ImportsExportsSerdeArray<'a>;
+    type Map = ImportsExportsSerdeMap<'a>;
+
+    fn kind(&self) -> ImportsExportsKind {
+        match &self.0 {
+            JSONValue::String(_) => ImportsExportsKind::String,
+            JSONValue::Array(_) => ImportsExportsKind::Array,
+            JSONValue::Object(_) => ImportsExportsKind::Map,
+            _ => ImportsExportsKind::Invalid,
+        }
+    }
+
+    fn as_string(&self) -> Option<&'a str> {
+        match &self.0 {
+            JSONValue::String(string) => Some(string),
+            _ => None,
+        }
+    }
+
+    fn as_array(&self) -> Option<ImportsExportsSerdeArray<'a>> {
+        match &self.0 {
+            JSONValue::Array(vec) => Some(ImportsExportsSerdeArray(vec)),
+            _ => None,
+        }
+    }
+
+    fn as_map(&self) -> Option<ImportsExportsSerdeMap<'a>> {
+        match &self.0 {
+            JSONValue::Object(map) => Some(ImportsExportsSerdeMap(map)),
+            _ => None,
+        }
+    }
+}
+
+#[derive(Clone)]
+pub struct ImportsExportsSerdeArray<'a>(&'a Vec<JSONValue>);
+
+impl<'a> ImportsExportsArray<'a> for ImportsExportsSerdeArray<'a> {
+    type Entry = ImportsExportsSerdeEntry<'a>;
+
+    fn len(&self) -> usize {
+        self.0.len()
+    }
+
+    fn iter(&self) -> impl Iterator<Item = ImportsExportsSerdeEntry<'a>> {
+        ImportsExportsSerdeArrayIter { vec: self.0, index: 0 }
+    }
+}
+
+struct ImportsExportsSerdeArrayIter<'a> {
+    vec: &'a Vec<JSONValue>,
+    index: usize,
+}
+
+impl<'a> Iterator for ImportsExportsSerdeArrayIter<'a> {
+    type Item = ImportsExportsSerdeEntry<'a>;
+
+    fn next(&mut self) -> Option<Self::Item> {
+        self.vec.get(self.index).map(|value| {
+            self.index += 1;
+            ImportsExportsSerdeEntry(value)
+        })
+    }
+}
+
+#[derive(Clone)]
+pub struct ImportsExportsSerdeMap<'a>(pub(crate) &'a JSONMap);
+
+impl<'a> ImportsExportsMap<'a> for ImportsExportsSerdeMap<'a> {
+    type Entry = ImportsExportsSerdeEntry<'a>;
+
+    fn get(&self, key: &str) -> Option<Self::Entry> {
+        self.0.get(key).map(ImportsExportsSerdeEntry)
+    }
+
+    fn keys(&self) -> impl Iterator<Item = &'a str> {
+        ImportsExportsSerdeMapKeysIter { inner: self.0.keys() }
+    }
+
+    fn iter(&self) -> impl Iterator<Item = (&'a str, ImportsExportsSerdeEntry<'a>)> {
+        ImportsExportsSerdeMapIter { inner: self.0.iter() }
+    }
+}
+
+struct ImportsExportsSerdeMapIter<'a> {
+    inner: serde_json::map::Iter<'a>,
+}
+
+impl<'a> Iterator for ImportsExportsSerdeMapIter<'a> {
+    type Item = (&'a str, ImportsExportsSerdeEntry<'a>);
+
+    fn next(&mut self) -> Option<Self::Item> {
+        self.inner.next().map(|(key, value)| (key.as_str(), ImportsExportsSerdeEntry(value)))
+    }
+}
+
+struct ImportsExportsSerdeMapKeysIter<'a> {
+    inner: serde_json::map::Keys<'a>,
+}
+
+impl<'a> Iterator for ImportsExportsSerdeMapKeysIter<'a> {
+    type Item = &'a str;
+
+    fn next(&mut self) -> Option<Self::Item> {
+        self.inner.next().map(String::as_str)
+    }
+}
diff --git a/src/resolution.rs b/src/resolution.rs
index 9df58a8..63f635d 100644
--- a/src/resolution.rs
+++ b/src/resolution.rs
@@ -4,11 +4,10 @@ use std::{
     sync::Arc,
 };
 
-use crate::package_json::PackageJson;
+use crate::{Cache, PackageJson};
 
 /// The final path resolution with optional `?query` and `#fragment`
-#[derive(Clone)]
-pub struct Resolution {
+pub struct Resolution<C: Cache> {
     pub(crate) path: PathBuf,
 
     /// path query `?query`, contains `?`.
@@ -17,28 +16,39 @@ pub struct Resolution {
     /// path fragment `#query`, contains `#`.
     pub(crate) fragment: Option<String>,
 
-    pub(crate) package_json: Option<Arc<PackageJson>>,
+    pub(crate) package_json: Option<Arc<C::Pj>>,
 }
 
-impl fmt::Debug for Resolution {
+impl<C: Cache> Clone for Resolution<C> {
+    fn clone(&self) -> Self {
+        Self {
+            path: self.path.clone(),
+            query: self.query.clone(),
+            fragment: self.fragment.clone(),
+            package_json: self.package_json.clone(),
+        }
+    }
+}
+
+impl<C: Cache> fmt::Debug for Resolution<C> {
     fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
         f.debug_struct("Resolution")
             .field("path", &self.path)
             .field("query", &self.query)
             .field("fragment", &self.fragment)
-            .field("package_json", &self.package_json.as_ref().map(|p| &p.path))
+            .field("package_json", &self.package_json.as_ref().map(|p| p.path()))
             .finish()
     }
 }
 
-impl PartialEq for Resolution {
+impl<C: Cache> PartialEq for Resolution<C> {
     fn eq(&self, other: &Self) -> bool {
         self.path == other.path && self.query == other.query && self.fragment == other.fragment
     }
 }
-impl Eq for Resolution {}
+impl<C: Cache> Eq for Resolution<C> {}
 
-impl Resolution {
+impl<C: Cache> Resolution<C> {
     /// Returns the path without query and fragment
     #[must_use]
     pub fn path(&self) -> &Path {
@@ -65,7 +75,7 @@ impl Resolution {
 
     /// Returns serialized package_json
     #[must_use]
-    pub const fn package_json(&self) -> Option<&Arc<PackageJson>> {
+    pub const fn package_json(&self) -> Option<&Arc<C::Pj>> {
         self.package_json.as_ref()
     }
 
@@ -82,18 +92,3 @@ impl Resolution {
         PathBuf::from(path)
     }
 }
-
-#[test]
-fn test() {
-    let resolution = Resolution {
-        path: PathBuf::from("foo"),
-        query: Some("?query".to_string()),
-        fragment: Some("#fragment".to_string()),
-        package_json: None,
-    };
-    assert_eq!(resolution.path(), Path::new("foo"));
-    assert_eq!(resolution.query(), Some("?query"));
-    assert_eq!(resolution.fragment(), Some("#fragment"));
-    assert_eq!(resolution.full_path(), PathBuf::from("foo?query#fragment"));
-    assert_eq!(resolution.into_path_buf(), PathBuf::from("foo"));
-}
diff --git a/src/tsconfig.rs b/src/tsconfig.rs
index 3b0f165..ae0c748 100644
--- a/src/tsconfig.rs
+++ b/src/tsconfig.rs
@@ -6,111 +6,80 @@ use std::{
 
 use indexmap::IndexMap;
 use rustc_hash::FxHasher;
-use serde::Deserialize;
 
-use crate::PathUtil;
+use crate::{path::PathUtil, TsconfigReferences};
 
 pub type CompilerOptionsPathsMap = IndexMap<String, Vec<String>, BuildHasherDefault<FxHasher>>;
 
-#[derive(Debug, Clone, Eq, PartialEq, Deserialize)]
-#[serde(untagged)]
-pub enum ExtendsField {
-    Single(String),
-    Multiple(Vec<String>),
-}
+/// Abstract representation for the contents of a `tsconfig.json` file, as well
+/// as the location where it was found.
+///
+/// This representation makes no assumptions regarding how the file was
+/// deserialized.
+#[allow(clippy::missing_errors_doc)] // trait impls should be free to return any typesafe error
+pub trait TsConfig: Sized {
+    type Co: CompilerOptions;
 
-#[derive(Debug, Deserialize)]
-#[serde(rename_all = "camelCase")]
-pub struct TsConfig {
     /// Whether this is the caller tsconfig.
     /// Used for final template variable substitution when all configs are extended and merged.
-    #[serde(skip)]
-    pub root: bool,
+    fn root(&self) -> bool;
 
-    /// Path to `tsconfig.json`. Contains the `tsconfig.json` filename.
-    #[serde(skip)]
-    pub path: PathBuf,
-
-    #[serde(default)]
-    pub extends: Option<ExtendsField>,
+    /// Returns the path where the `tsconfig.json` was found.
+    ///
+    /// Contains the `tsconfig.json` filename.
+    #[must_use]
+    fn path(&self) -> &Path;
 
-    #[serde(default)]
-    pub compiler_options: CompilerOptions,
+    /// Directory to `tsconfig.json`.
+    ///
+    /// # Panics
+    ///
+    /// * When the `tsconfig.json` path is misconfigured.
+    #[must_use]
+    fn directory(&self) -> &Path;
 
-    /// Bubbled up project references with a reference to their tsconfig.
-    #[serde(default)]
-    pub references: Vec<ProjectReference>,
-}
+    /// Returns the compiler options configured in this tsconfig.
+    #[must_use]
+    fn compiler_options(&self) -> &Self::Co;
 
-/// Compiler Options
-///
-/// <https://www.typescriptlang.org/tsconfig#compilerOptions>
-#[derive(Debug, Default, Deserialize)]
-#[serde(rename_all = "camelCase")]
-pub struct CompilerOptions {
-    pub base_url: Option<PathBuf>,
+    /// Returns a mutable reference to the compiler options configured in this
+    /// tsconfig.
+    #[must_use]
+    fn compiler_options_mut(&mut self) -> &mut Self::Co;
 
-    /// Path aliases
-    pub paths: Option<CompilerOptionsPathsMap>,
+    /// Returns any paths to tsconfigs that should be extended by this tsconfig.
+    #[must_use]
+    fn extends(&self) -> impl Iterator<Item = &str>;
 
-    /// The actual base for where path aliases are resolved from.
-    #[serde(skip)]
-    paths_base: PathBuf,
-}
+    /// Loads the given references into this tsconfig.
+    ///
+    /// Returns whether any references are defined in the tsconfig.
+    fn load_references(&mut self, references: &TsconfigReferences) -> bool;
 
-/// Project Reference
-///
-/// <https://www.typescriptlang.org/docs/handbook/project-references.html>
-#[derive(Debug, Deserialize)]
-pub struct ProjectReference {
-    /// The path property of each reference can point to a directory containing a tsconfig.json file,
-    /// or to the config file itself (which may have any name).
-    pub path: PathBuf,
-
-    /// Reference to the resolved tsconfig
-    #[serde(skip)]
-    pub tsconfig: Option<Arc<TsConfig>>,
-}
+    /// Returns references to other tsconfig files.
+    #[must_use]
+    fn references(&self) -> impl Iterator<Item = &impl ProjectReference<Tc = Self>>;
 
-impl TsConfig {
-    /// Directory to `tsconfig.json`
-    ///
-    /// # Panics
-    ///
-    /// * When the `tsconfig.json` path is misconfigured.
+    /// Returns mutable references to other tsconfig files.
     #[must_use]
-    pub fn directory(&self) -> &Path {
-        debug_assert!(self.path.file_name().is_some());
-        self.path.parent().unwrap()
-    }
+    fn references_mut(&mut self) -> impl Iterator<Item = &mut impl ProjectReference<Tc = Self>>;
 
-    /// Parses the tsconfig from a JSON string.
-    ///
-    /// # Errors
+    /// Returns the base path from which to resolve aliases.
     ///
-    /// * Any error that can be returned by `serde_json::from_str()`.
-    pub fn parse(root: bool, path: &Path, json: &mut str) -> Result<Self, serde_json::Error> {
-        _ = json_strip_comments::strip(json);
-        let mut tsconfig: Self = serde_json::from_str(json)?;
-        tsconfig.root = root;
-        tsconfig.path = path.to_path_buf();
-        let directory = tsconfig.directory().to_path_buf();
-        if let Some(base_url) = tsconfig.compiler_options.base_url {
-            tsconfig.compiler_options.base_url = Some(directory.normalize_with(base_url));
-        }
-        if tsconfig.compiler_options.paths.is_some() {
-            tsconfig.compiler_options.paths_base =
-                tsconfig.compiler_options.base_url.as_ref().map_or(directory, Clone::clone);
-        }
-        Ok(tsconfig)
+    /// The base path can be configured by the user as part of the
+    /// [CompilerOptions]. If not configured, it returns the directory in which
+    /// the tsconfig itself is found.
+    #[must_use]
+    fn base_path(&self) -> &Path {
+        self.compiler_options().base_url().unwrap_or_else(|| self.directory())
     }
 
-    #[must_use]
-    pub fn build(mut self) -> Self {
-        if self.root {
+    /// Expands all template variables in this tsconfig.
+    fn expand_template_variables(&mut self) {
+        if self.root() {
             let dir = self.directory().to_path_buf();
             // Substitute template variable in `tsconfig.compilerOptions.paths`
-            if let Some(paths) = &mut self.compiler_options.paths {
+            if let Some(paths) = &mut self.compiler_options_mut().paths_mut() {
                 for paths in paths.values_mut() {
                     for path in paths {
                         Self::substitute_template_variable(&dir, path);
@@ -118,52 +87,63 @@ impl TsConfig {
                 }
             }
         }
-        self
     }
 
-    pub(crate) fn extend_tsconfig(&mut self, tsconfig: &Self) {
-        let compiler_options = &mut self.compiler_options;
-        if compiler_options.paths.is_none() {
-            compiler_options.paths_base = compiler_options
-                .base_url
-                .as_ref()
-                .map_or_else(|| tsconfig.compiler_options.paths_base.clone(), Clone::clone);
-            compiler_options.paths.clone_from(&tsconfig.compiler_options.paths);
+    /// Inherits settings from the given tsconfig into `self`.
+    fn extend_tsconfig(&mut self, tsconfig: &Self) {
+        let compiler_options = self.compiler_options_mut();
+        if compiler_options.paths().is_none() {
+            compiler_options.set_paths_base(compiler_options.base_url().map_or_else(
+                || tsconfig.compiler_options().paths_base().to_path_buf(),
+                Path::to_path_buf,
+            ));
+            compiler_options.set_paths(tsconfig.compiler_options().paths().cloned());
         }
-        if compiler_options.base_url.is_none() {
-            compiler_options.base_url.clone_from(&tsconfig.compiler_options.base_url);
+        if compiler_options.base_url().is_none() {
+            if let Some(base_url) = tsconfig.compiler_options().base_url() {
+                compiler_options.set_base_url(base_url.to_path_buf());
+            }
         }
     }
 
-    pub(crate) fn resolve(&self, path: &Path, specifier: &str) -> Vec<PathBuf> {
+    /// Resolves the given `specifier` within the project configured by this
+    /// tsconfig, relative to the given `path`.
+    ///
+    /// `specifier` can be either a real path or an alias.
+    #[must_use]
+    fn resolve(&self, path: &Path, specifier: &str) -> Vec<PathBuf> {
         if path.starts_with(self.base_path()) {
             let paths = self.resolve_path_alias(specifier);
             if !paths.is_empty() {
                 return paths;
             }
         }
-        for tsconfig in self.references.iter().filter_map(|reference| reference.tsconfig.as_ref()) {
+        for tsconfig in self.references().filter_map(ProjectReference::tsconfig) {
             if path.starts_with(tsconfig.base_path()) {
                 return tsconfig.resolve_path_alias(specifier);
             }
         }
-        vec![]
+        Vec::new()
     }
 
+    /// Resolves the given `specifier` within the project configured by this
+    /// tsconfig.
+    ///
+    /// `specifier` is expected to be a path alias.
     // Copied from parcel
     // <https://github.com/parcel-bundler/parcel/blob/b6224fd519f95e68d8b93ba90376fd94c8b76e69/packages/utils/node-resolver-rs/src/tsconfig.rs#L93>
-    pub(crate) fn resolve_path_alias(&self, specifier: &str) -> Vec<PathBuf> {
+    #[must_use]
+    fn resolve_path_alias(&self, specifier: &str) -> Vec<PathBuf> {
         if specifier.starts_with(['/', '.']) {
-            return vec![];
+            return Vec::new();
         }
 
-        let base_url_iter = self
-            .compiler_options
-            .base_url
-            .as_ref()
+        let compiler_options = self.compiler_options();
+        let base_url_iter = compiler_options
+            .base_url()
             .map_or_else(Vec::new, |base_url| vec![base_url.normalize_with(specifier)]);
 
-        let Some(paths_map) = &self.compiler_options.paths else {
+        let Some(paths_map) = compiler_options.paths() else {
             return base_url_iter;
         };
 
@@ -204,23 +184,18 @@ impl TsConfig {
 
         paths
             .into_iter()
-            .map(|p| self.compiler_options.paths_base.normalize_with(p))
+            .map(|p| compiler_options.paths_base().normalize_with(p))
             .chain(base_url_iter)
             .collect()
     }
 
-    fn base_path(&self) -> &Path {
-        self.compiler_options
-            .base_url
-            .as_ref()
-            .map_or_else(|| self.directory(), |path| path.as_ref())
-    }
-
-    /// Template variable `${configDir}` for substitution of config files directory path
+    /// Template variable `${configDir}` for substitution of config files
+    /// directory path.
     ///
-    /// NOTE: All tests cases are just a head replacement of `${configDir}`, so we are constrained as such.
+    /// NOTE: All tests cases are just a head replacement of `${configDir}`, so
+    ///       we are constrained as such.
     ///
-    /// See <https://github.com/microsoft/TypeScript/pull/58042>
+    /// See <https://github.com/microsoft/TypeScript/pull/58042>.
     fn substitute_template_variable(directory: &Path, path: &mut String) {
         const TEMPLATE_VARIABLE: &str = "${configDir}/";
         if let Some(stripped_path) = path.strip_prefix(TEMPLATE_VARIABLE) {
@@ -228,3 +203,52 @@ impl TsConfig {
         }
     }
 }
+
+/// Compiler Options.
+///
+/// <https://www.typescriptlang.org/tsconfig#compilerOptions>
+pub trait CompilerOptions {
+    /// Explicit base URL configured by the user.
+    #[must_use]
+    fn base_url(&self) -> Option<&Path>;
+
+    /// Sets the base URL.
+    fn set_base_url(&mut self, base_url: PathBuf);
+
+    /// Path aliases.
+    #[must_use]
+    fn paths(&self) -> Option<&CompilerOptionsPathsMap>;
+
+    /// Returns a mutable reference to the path aliases.
+    #[must_use]
+    fn paths_mut(&mut self) -> Option<&mut CompilerOptionsPathsMap>;
+
+    /// Sets the path aliases.
+    fn set_paths(&mut self, paths: Option<CompilerOptionsPathsMap>);
+
+    /// The actual base from where path aliases are resolved.
+    #[must_use]
+    fn paths_base(&self) -> &Path;
+
+    /// Sets the path base.
+    fn set_paths_base(&mut self, paths_base: PathBuf);
+}
+
+/// Project Reference.
+///
+/// <https://www.typescriptlang.org/docs/handbook/project-references.html>
+pub trait ProjectReference {
+    type Tc: TsConfig;
+
+    /// Returns the path to a directory containing a `tsconfig.json` file, or to
+    /// the config file itself (which may have any name).
+    #[must_use]
+    fn path(&self) -> &Path;
+
+    /// Returns the resolved tsconfig, if one has been set.
+    #[must_use]
+    fn tsconfig(&self) -> Option<Arc<Self::Tc>>;
+
+    /// Sets the resolved tsconfig.
+    fn set_tsconfig(&mut self, tsconfig: Arc<Self::Tc>);
+}
diff --git a/src/tsconfig_serde.rs b/src/tsconfig_serde.rs
new file mode 100644
index 0000000..37d965c
--- /dev/null
+++ b/src/tsconfig_serde.rs
@@ -0,0 +1,203 @@
+use std::{
+    path::{Path, PathBuf},
+    sync::Arc,
+};
+
+use serde::Deserialize;
+
+use crate::{
+    CompilerOptions, CompilerOptionsPathsMap, PathUtil, ProjectReference, TsConfig,
+    TsconfigReferences,
+};
+
+#[derive(Debug, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub struct TsConfigSerde {
+    /// Whether this is the caller tsconfig.
+    /// Used for final template variable substitution when all configs are extended and merged.
+    #[serde(skip)]
+    pub root: bool,
+
+    /// Path to `tsconfig.json`. Contains the `tsconfig.json` filename.
+    #[serde(skip)]
+    pub path: PathBuf,
+
+    #[serde(default)]
+    pub extends: Option<ExtendsField>,
+
+    #[serde(default)]
+    pub compiler_options: CompilerOptionsSerde,
+
+    /// Bubbled up project references with a reference to their tsconfig.
+    #[serde(default)]
+    pub references: Vec<ProjectReferenceSerde>,
+}
+
+impl TsConfig for TsConfigSerde {
+    type Co = CompilerOptionsSerde;
+
+    fn root(&self) -> bool {
+        self.root
+    }
+
+    fn path(&self) -> &Path {
+        &self.path
+    }
+
+    fn directory(&self) -> &Path {
+        debug_assert!(self.path.file_name().is_some());
+        self.path.parent().unwrap()
+    }
+
+    fn compiler_options(&self) -> &Self::Co {
+        &self.compiler_options
+    }
+
+    fn compiler_options_mut(&mut self) -> &mut Self::Co {
+        &mut self.compiler_options
+    }
+
+    fn extends(&self) -> impl Iterator<Item = &str> {
+        let specifiers = match &self.extends {
+            Some(ExtendsField::Single(specifier)) => {
+                vec![specifier.as_str()]
+            }
+            Some(ExtendsField::Multiple(specifiers)) => {
+                specifiers.iter().map(String::as_str).collect()
+            }
+            None => Vec::new(),
+        };
+        specifiers.into_iter()
+    }
+
+    fn load_references(&mut self, references: &TsconfigReferences) -> bool {
+        match references {
+            TsconfigReferences::Disabled => {
+                self.references.drain(..);
+            }
+            TsconfigReferences::Auto => {}
+            TsconfigReferences::Paths(paths) => {
+                self.references = paths
+                    .iter()
+                    .map(|path| ProjectReferenceSerde { path: path.clone(), tsconfig: None })
+                    .collect();
+            }
+        }
+
+        !self.references.is_empty()
+    }
+
+    fn references(&self) -> impl Iterator<Item = &impl ProjectReference<Tc = Self>> {
+        self.references.iter()
+    }
+
+    fn references_mut(&mut self) -> impl Iterator<Item = &mut impl ProjectReference<Tc = Self>> {
+        self.references.iter_mut()
+    }
+}
+
+/// Compiler Options
+///
+/// <https://www.typescriptlang.org/tsconfig#compilerOptions>
+#[derive(Debug, Default, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub struct CompilerOptionsSerde {
+    pub base_url: Option<PathBuf>,
+
+    /// Path aliases.
+    pub paths: Option<CompilerOptionsPathsMap>,
+
+    /// The actual base from where path aliases are resolved.
+    #[serde(skip)]
+    paths_base: PathBuf,
+}
+
+impl CompilerOptions for CompilerOptionsSerde {
+    fn base_url(&self) -> Option<&Path> {
+        self.base_url.as_deref()
+    }
+
+    fn set_base_url(&mut self, base_url: PathBuf) {
+        self.base_url = Some(base_url);
+    }
+
+    fn paths(&self) -> Option<&CompilerOptionsPathsMap> {
+        self.paths.as_ref()
+    }
+
+    fn paths_mut(&mut self) -> Option<&mut CompilerOptionsPathsMap> {
+        self.paths.as_mut()
+    }
+
+    fn set_paths(&mut self, paths: Option<CompilerOptionsPathsMap>) {
+        self.paths = paths;
+    }
+
+    fn paths_base(&self) -> &Path {
+        &self.paths_base
+    }
+
+    fn set_paths_base(&mut self, paths_base: PathBuf) {
+        self.paths_base = paths_base;
+    }
+}
+
+/// Value for the "extends" field.
+///
+/// <https://www.typescriptlang.org/tsconfig/#extends>
+#[derive(Clone, Debug, Deserialize, Eq, PartialEq)]
+#[serde(untagged)]
+pub enum ExtendsField {
+    Single(String),
+    Multiple(Vec<String>),
+}
+
+/// Project Reference
+///
+/// <https://www.typescriptlang.org/docs/handbook/project-references.html>
+#[derive(Debug, Deserialize)]
+pub struct ProjectReferenceSerde {
+    pub path: PathBuf,
+
+    #[serde(skip)]
+    pub tsconfig: Option<Arc<TsConfigSerde>>,
+}
+
+impl ProjectReference for ProjectReferenceSerde {
+    type Tc = TsConfigSerde;
+
+    fn path(&self) -> &Path {
+        &self.path
+    }
+
+    fn tsconfig(&self) -> Option<Arc<Self::Tc>> {
+        self.tsconfig.clone()
+    }
+
+    fn set_tsconfig(&mut self, tsconfig: Arc<Self::Tc>) {
+        self.tsconfig.replace(tsconfig);
+    }
+}
+
+impl TsConfigSerde {
+    /// Parses the tsconfig from a JSON string.
+    ///
+    /// # Errors
+    ///
+    /// * Any error that can be returned by `serde_json::from_str()`.
+    pub fn parse(root: bool, path: &Path, json: &mut str) -> Result<Self, serde_json::Error> {
+        _ = json_strip_comments::strip(json);
+        let mut tsconfig: Self = serde_json::from_str(json)?;
+        tsconfig.root = root;
+        tsconfig.path = path.to_path_buf();
+        let directory = tsconfig.directory().to_path_buf();
+        if let Some(base_url) = tsconfig.compiler_options.base_url {
+            tsconfig.compiler_options.base_url = Some(directory.normalize_with(base_url));
+        }
+        if tsconfig.compiler_options.paths.is_some() {
+            tsconfig.compiler_options.paths_base =
+                tsconfig.compiler_options.base_url.as_ref().map_or(directory, Clone::clone);
+        }
+        Ok(tsconfig)
+    }
+}


Test patch:
diff --git a/src/tests/exports_field.rs b/src/tests/exports_field.rs
index abdd6e2..a2e3dd7 100644
--- a/src/tests/exports_field.rs
+++ b/src/tests/exports_field.rs
@@ -6,7 +6,10 @@ use std::path::Path;
 
 use serde_json::json;
 
-use crate::{cache::CachedPath, Cache, Ctx, PathUtil, ResolveError, ResolveOptions, Resolver};
+use crate::{
+    cache::CachedPath, package_json_serde::ImportsExportsSerdeEntry, Cache, Ctx, PathUtil,
+    ResolveError, ResolveOptions, Resolver,
+};
 
 #[test]
 fn test_simple() {
@@ -299,14 +302,15 @@ fn extension_alias_throw_error() {
 struct TestCase {
     name: &'static str,
     expect: Option<Vec<&'static str>>,
-    exports_field: serde_json::Value,
+    exports_field: ImportsExportsSerdeEntry<'static>,
     request: &'static str,
     condition_names: Vec<&'static str>,
 }
 
-#[allow(clippy::needless_pass_by_value)]
-const fn exports_field(value: serde_json::Value) -> serde_json::Value {
-    value
+fn exports_field(value: serde_json::Value) -> ImportsExportsSerdeEntry<'static> {
+    // Don't do this at home:
+    let value = Box::leak::<'static>(Box::new(value));
+    ImportsExportsSerdeEntry(value)
 }
 
 #[test]
diff --git a/src/tests/imports_field.rs b/src/tests/imports_field.rs
index 04ae020..a37f741 100644
--- a/src/tests/imports_field.rs
+++ b/src/tests/imports_field.rs
@@ -7,7 +7,8 @@ use std::path::Path;
 use serde_json::json;
 
 use crate::{
-    cache::CachedPath, Cache, Ctx, JSONMap, PathUtil, ResolveError, ResolveOptions, Resolver,
+    cache::CachedPath, package_json_serde::ImportsExportsSerdeMap, Cache, Ctx, PathUtil,
+    ResolveError, ResolveOptions, Resolver,
 };
 
 #[test]
@@ -97,15 +98,18 @@ fn shared_resolvers() {
 struct TestCase {
     name: &'static str,
     expect: Option<Vec<&'static str>>,
-    imports_field: JSONMap,
+    imports_field: ImportsExportsSerdeMap<'static>,
     request: &'static str,
     condition_names: Vec<&'static str>,
 }
 
-#[allow(clippy::needless_pass_by_value)]
-fn imports_field(value: serde_json::Value) -> JSONMap {
-    let s = serde_json::to_string(&value).unwrap();
-    serde_json::from_str(&s).unwrap()
+fn imports_field(value: serde_json::Value) -> ImportsExportsSerdeMap<'static> {
+    let serde_json::Value::Object(map) = value else {
+        panic!("Expected an object");
+    };
+    // Don't do this at home:
+    let map = Box::leak::<'static>(Box::new(map));
+    ImportsExportsSerdeMap(map)
 }
 
 #[allow(clippy::too_many_lines)]
diff --git a/src/tests/mod.rs b/src/tests/mod.rs
index b14e6e8..c156795 100644
--- a/src/tests/mod.rs
+++ b/src/tests/mod.rs
@@ -14,6 +14,7 @@ mod memory_fs;
 mod missing;
 #[cfg(feature = "yarn_pnp")]
 mod pnp;
+mod resolution;
 mod resolve;
 mod restrictions;
 mod roots;
diff --git a/src/tests/resolution.rs b/src/tests/resolution.rs
new file mode 100644
index 0000000..d624839
--- /dev/null
+++ b/src/tests/resolution.rs
@@ -0,0 +1,18 @@
+use std::path::{Path, PathBuf};
+
+use crate::{tests::memory_fs::MemoryFS, FsCache, Resolution};
+
+#[test]
+fn test() {
+    let resolution: Resolution<FsCache<MemoryFS>> = Resolution {
+        path: PathBuf::from("foo"),
+        query: Some("?query".to_string()),
+        fragment: Some("#fragment".to_string()),
+        package_json: None,
+    };
+    assert_eq!(resolution.path(), Path::new("foo"));
+    assert_eq!(resolution.query(), Some("?query"));
+    assert_eq!(resolution.fragment(), Some("#fragment"));
+    assert_eq!(resolution.full_path(), PathBuf::from("foo?query#fragment"));
+    assert_eq!(resolution.into_path_buf(), PathBuf::from("foo"));
+}
diff --git a/src/tests/tsconfig_paths.rs b/src/tests/tsconfig_paths.rs
index dd95ffb..87c9197 100644
--- a/src/tests/tsconfig_paths.rs
+++ b/src/tests/tsconfig_paths.rs
@@ -5,7 +5,7 @@
 use std::path::{Path, PathBuf};
 
 use crate::{
-    JSONError, ResolveError, ResolveOptions, Resolver, TsConfig, TsconfigOptions,
+    JSONError, ResolveError, ResolveOptions, Resolver, TsConfig, TsConfigSerde, TsconfigOptions,
     TsconfigReferences,
 };
 
@@ -130,7 +130,7 @@ fn test_paths() {
         }
     })
     .to_string();
-    let tsconfig = TsConfig::parse(true, path, &mut tsconfig_json).unwrap();
+    let tsconfig = TsConfigSerde::parse(true, path, &mut tsconfig_json).unwrap();
 
     let data = [
         ("jquery", vec!["/foo/node_modules/jquery/dist/jquery"]),
@@ -160,7 +160,7 @@ fn test_base_url() {
         }
     })
     .to_string();
-    let tsconfig = TsConfig::parse(true, path, &mut tsconfig_json).unwrap();
+    let tsconfig = TsConfigSerde::parse(true, path, &mut tsconfig_json).unwrap();
 
     let data = [
         ("foo", vec!["/foo/src/foo"]),
@@ -191,7 +191,7 @@ fn test_paths_and_base_url() {
         }
     })
     .to_string();
-    let tsconfig = TsConfig::parse(true, path, &mut tsconfig_json).unwrap();
+    let tsconfig = TsConfigSerde::parse(true, path, &mut tsconfig_json).unwrap();
 
     let data = [
         ("test", vec!["/foo/src/generated/test", "/foo/src/test"]),
diff --git a/tests/integration_test.rs b/tests/integration_test.rs
index b548605..3451299 100644
--- a/tests/integration_test.rs
+++ b/tests/integration_test.rs
@@ -2,13 +2,16 @@
 
 use std::{env, path::PathBuf};
 
-use oxc_resolver::{EnforceExtension, Resolution, ResolveContext, ResolveOptions, Resolver};
+use oxc_resolver::{
+    EnforceExtension, FileSystemOs, FsCache, PackageJson, Resolution, ResolveContext,
+    ResolveOptions, Resolver,
+};
 
 fn dir() -> PathBuf {
     env::current_dir().unwrap()
 }
 
-fn resolve(specifier: &str) -> Resolution {
+fn resolve(specifier: &str) -> Resolution<FsCache<FileSystemOs>> {
     let path = dir();
     Resolver::new(ResolveOptions::default()).resolve(path, specifier).unwrap()
 }
@@ -36,8 +39,8 @@ fn eq() {
 fn package_json() {
     let resolution = resolve("./tests/package.json");
     let package_json = resolution.package_json().unwrap();
-    assert_eq!(package_json.name.as_ref().unwrap(), "name");
-    assert_eq!(package_json.r#type.as_ref().unwrap().as_str(), "module".into());
+    assert_eq!(package_json.name().unwrap(), "name");
+    assert_eq!(package_json.r#type().unwrap().to_string(), "module".to_string());
     assert!(package_json.side_effects.as_ref().unwrap().is_object());
 }