Skip to content

Instantly share code, notes, and snippets.

Show Gist options
  • Star 2 You must be signed in to star a gist
  • Fork 1 You must be signed in to fork a gist
  • Save vladima/725949fd9464e6a94771 to your computer and use it in GitHub Desktop.
Save vladima/725949fd9464e6a94771 to your computer and use it in GitHub Desktop.

Proposed module resolution strategy

Proposed set of rules is an extension to baseUrl based mechanics used by RequireJS or SystemJS.

Core elements of the system are baseUrl and path mappings. Resolution process describes how they are used together.

BaseUrl

All non-rooted paths are computed relative to baseUrl. Value of baseUrl is determined as either:

  • value of baseUrl command line argument (if given path is relative it is computed based on current directory)
  • value of baseUrl propery in 'tsconfig.json' (if given path is relative it is computed based on location of 'tsconfig.json')
  • location of 'tsconfig.json' file

Path mappings

Sometimes modules are not directly located under baseUrl. It is possible to control how locations are computed in such cases using path mappings. Path mappings are specified using the following JSON structure:

{
    "paths": {
        "pattern-1": "substitution" | ["list of substitutions"],
        "pattern-2": "substitution" | ["list of substitutions"],
        ...
        "pattern-N": "substitution" | ["list of substitutions"]
    }
}

Patterns and substitutions are strings that can have zero or one asteriks ('*'). Interpretation of both patterns and substitutions will be described in Resolution process section.

Resolution process

Non-relative module names are resolved slightly differently comparing to relative (start with "./" or "../") and rooted module names (start with "/", drive name or schema).

Resolution of non-relative module names (mostly matches SystemJS)

// mimics path mappings in SystemJS
// NOTE: fileExists checks if file with any supported extension exists on disk
function resolveNonRelativeModuleName(moduleName: string): string {
   // check if module name should be used as-is or it should be mapped to different value
   let longestMatchedPrefixLength = 0;
   let matchedPattern: string;
   let matchedWildcard: string;

   for (let pattern in config.paths) {
       assert(pattern.countOf('*') <= 1);
       let indexOfWildcard = pattern.indexOf('*'); 
       if (indexOfWildcard !== -1) {
           // if pattern contains asterisk then asterisk acts as a capture group with a greedy matching
           // i.e. for the string 'abbb' pattern 'a*b' will get 'bb' as '*'

           // check if module name starts with prefix, ends with suffix and these two don't overlap
           let prefix = pattern.substr(0, indexOfWildcard);
           let suffix = pattern.substr(indexOfWildcard + 1);
           if (moduleName.length >= prefix.length + suffix.length && 
               moduleName.startsWith(prefix) &&
               moduleName.endsWith(suffix)) {

               // use length of matched prefix as betterness criteria
               if (longestMatchedPrefixLength < prefix.length) {
                   // save length of the prefix
                   longestMatchedPrefixLength = prefix.length;
                   // save matched pattern
                   matchedPattern = pattern;
                   // save matched wildcard content 
                   matchedWildcard = moduleName.substr(prefix.length, moduleName.length - suffix.length);
               }
           }
       }
       else {
           // pattern does not contain asterisk - module name should exactly match pattern to succeed
           if (pattern === moduleName) {
               // save pattern
               matchedPattern = pattern;
               // drop saved wildcard match 
               matchedWildcard = undefined;
               // exact match is found - can exit early 
               break;
           }
       }
   }

   if (!matchedPattern) {
       // no pattern was matched so module name can be used as-is
       let path = combine(baseUrl, moduleName);
       return fileExists(path) ? path : undefined;
   }

   // some pattern was matched - module name needs to be substituted
   let substitutions = config.paths[matchedPattern].asArray();
   for (let subst of substitutions) {
       assert(substs.countOf('*') <= 1);
       // replace * in substitution with matched wildcard
       let path = matchedWildcard ? subst.replace("*", matchedWildcard) : subst;
       // if substituion is a relative path - combine it with baseUrl
       path = isRelative(path) ? combine(baseUrl, path) : path;
       if (fileExists(path)) {
           return path;
       }
   }

   return undefined;	
}

Resolution of relative module names

Default resolution logic (matches SystemJS)

Relative module names are computed treating location of source file that contains the import as base folder. Path mappings are not applied.

function resolveRelativeModuleName(moduleName: string, containingFile: string): string {
    let path = combine(getDirectoryName(containingFile), moduleName);
    return fileExists(path) ? path : undefined;
}

Mapping relative module names

In some cases it also will be useful to apply path mappings to relative module names, for example when project uses source files that are spreaded across file system (not under the same root) but still prefers to use relative module names because in runtime such names can be successfully resolved due to bundling. For example consider this project structure:

userFiles
  |-project
      |-src
         |-views
             |-view1.ts (imports './view2')
shared
  |-projects
      |-project
          |-src
             |-views
             |  |-view2.ts (imports './view1')
             |-viewManager.ts (imports './views/view1')

Logically files in userFiles/project and shared/projects/project belong to the same project and after build they indeed will be bundled together.

In order to support this we'll add configuration property "rootDirs":

{
    "rootDirs": [
        "rootDir-1/",
        "rootDir-2/",
        ...
        "rootDir-n/"
    ]
}

Elements in rootDirs that represent non-absolute paths will be converted to absolute using location of tsconfig.json as a base folder - this is the common approach for all paths defined in tsconfig.json

This property stores list of base folders, every folder name can be either absolute or relative (then it will be computed using baseUrl).

///Algorithm for resolving relative module name
function resolveRelativeModuleName(moduleName: string, containingFile: string): string {
    // convert relative module name to absolute using location of containing file
    // this step is exactly the same as when doing resolution without path mapping
    let path = combine(getDirectoryName(containingFile), moduleName);
    if (fileExists(path)) {
        // target module is located next to containingFile
        return path;
    }
    // convert absolute module name to non-relative
    // try to find element in 'rootDirs' that is the longest prefix for "path' and return path.substr(prefix.length) as non-relative name
    let nonRelativeName = tryFindLongestPrefixAndReturnSuffix(rootDirs, path);
    if (!nonRelativeName) {
        // cannot extract non relative name
        return undefined;
    }
    // run normal resolution for non-relative module names
    return resolveNonRelativeModuleName(nonRelativeName);
}

Configuration for the example above:

{
    "rootDirs": [
        "userFiles/project/",
        "/shared/projects/project/"
    ],
    "paths": {
        "*": [
            "userFiles/project/*",
            "/shared/projects/project/*"
        ]
    }
}

Example 1

projectRoot
 |-folder1
     |-file1.ts // imports 'folder2/file2'
 |-folder2
     |-file2.ts // imports './file3'
     |-file3.ts
 |-tsconfig.json // has no path mappings/baseUrl/rootDirs
  • import 'folder2/file2'

    1. baseUrl is not specified in configuration -> baseUrl = location of tsconfig.json
    2. path mappings are not available -> path = moduleName
    3. resolved module file name = combine(baseUrl, path) -> projectRoot/folder2/file2.ts
  • import './file3'

    1. moduleName is relative and rootDirs are not specified in configuration - compute module name relative to the location of containing file: resolved module file name = projectRoot/folder2/file3.ts

Example 2

projectRoot
  |-folder1
      |-file1.ts // imports 'folder1/file2' and 'folder2/file3'
      |-file2.ts
  |-generated
      |-folder1
          |-...
      |-folder2
          |-file3.ts
  |-tsconfig.json
// configuration in tsconfig.json
{
    "paths": {
    "*": [
            "*",
            "generated/*" 
        ]
    }
}
  • import 'folder1/file2'

    1. baseUrl is not specified in configuration -> baseUrl = location of tsconfig.json
    2. configuration contains path mappings.
    3. pattern '*' is matched and wildcard captures the whole module name
    4. try first substitution in the list: '*' -> folder1/file2
    5. result of substitution is relative name - combine it with baseUrl -> projectRoot/folder1/file2.ts. This file exists.
  • import 'folder2/file2'

    1. baseUrl is not specified in configuration -> baseUrl = location of tsconfig.json
    2. configuration contains path mappings.
    3. pattern '*' is matched and wildcard captures the whole module name
    4. try first substitution in the list: '*' -> folder2/file3
    5. result of substitution is relative name - combine it with baseUrl -> projectRoot/folder2/file3.ts. File does not exists, move to the second substitution
    6. second substitution 'generated/*' -> generated/folder2/file3
    7. result of substitution is relative name - combine it with baseUrl -> projectRoot/generated/folder2/file3.ts. File exists

Example 3

projectRoot
  |-folder1
      |-file1.ts // imports './file2'
  |-generated
      |-folder1
          |-file2.ts
      |-folder2
          |-file3.ts // imports '../folder1/file1'
  |-tsconfig.json
// configuration in tsconfig.json
{
    "rootDirs": [
        "./",
        "./generated/" 
    ],
    "paths": {
        "*": [
            "*",
            "generated/*" 
        ]
    }
}
  • import './file2'

    1. baseUrl is not specified in configuration -> baseUrl = location of tsconfig.json projectRoot
    2. non-absolute rootDirs are converted to absolute using location of tsconfig.json as base folder ->
    "rootDirs": ["projectRoot/", "projectRoot/generated"]
    1. module name is relative and configuration contains set of rootDirs to convert relative name to non-relative.
    2. convert relative module name to absolute: projectRoot/folder1/file2
    3. use rootDirs to obtain non-relative module name: folder1/file2 (matches projectRoot/)
    4. resolve non-relative module name: projectRoot/generated/folder1/file2.ts (by matching '*' and applying second substitution)
  • import '../folder1/file1'

    1. baseUrl is not specified in configuration -> baseUrl = location of tsconfig.json projectRoot
    2. non-absolute rootDirs are converted to absolute using location of tsconfig.json as base folder ->
    "rootDirs": ["projectRoot/", "projectRoot/generated"]
    1. module name is relative and configuration contains set of rootDirs to convert relative name to non-relative.
    2. convert relative module name to absolute: projectRoot/generated/folder1/file1
    3. use rootDirs to obtain non-relative module name: folder1/file1 (matches projectRoot/generated/)
    4. resolve non-relative module name: projectRoot/folder1/file1.ts (by matching '*' and applying first substitution)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment