diff --git a/src/configuration.ts b/src/configuration.ts index 5142a3584..266f2d920 100644 --- a/src/configuration.ts +++ b/src/configuration.ts @@ -383,6 +383,7 @@ function filterRecognizedTsConfigTsNodeOptions(jsonObject: any): { experimentalResolver, esm, experimentalSpecifierResolution, + experimentalTsImportSpecifiers, ...unrecognized } = jsonObject as TsConfigOptions; const filteredTsConfigOptions = { @@ -409,6 +410,7 @@ function filterRecognizedTsConfigTsNodeOptions(jsonObject: any): { experimentalResolver, esm, experimentalSpecifierResolution, + experimentalTsImportSpecifiers, }; // Use the typechecker to make sure this implementation has the correct set of properties const catchExtraneousProps: keyof TsConfigOptions = diff --git a/src/file-extensions.ts b/src/file-extensions.ts index 87e8be1c6..b5fd03552 100644 --- a/src/file-extensions.ts +++ b/src/file-extensions.ts @@ -19,6 +19,13 @@ const nodeEquivalents = new Map([ ['.cts', '.cjs'], ]); +const tsResolverEquivalents = new Map([ + ['.ts', ['.js']], + ['.tsx', ['.js', '.jsx']], + ['.mts', ['.mjs']], + ['.cts', ['.cjs']], +]); + // All extensions understood by vanilla node const vanillaNodeExtensions: readonly string[] = [ '.js', @@ -129,6 +136,19 @@ export function getExtensions( * as far as getFormat is concerned. */ nodeEquivalents, + /** + * Mapping from extensions rejected by TSC in import specifiers, to the + * possible alternatives that TS's resolver will accept. + * + * When we allow users to opt-in to .ts extensions in import specifiers, TS's + * resolver requires us to replace the .ts extensions with .js alternatives. + * Otherwise, resolution fails. + * + * Note TS's resolver is only used by, and only required for, typechecking. + * This is separate from node's resolver, which we hook separately and which + * does not require this mapping. + */ + tsResolverEquivalents, /** * Extensions that we can support if the user upgrades their typescript version. * Used when raising hints. diff --git a/src/index.ts b/src/index.ts index 607d5976d..7167dbe1b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -373,6 +373,17 @@ export interface CreateOptions { * For details, see https://nodejs.org/dist/latest-v18.x/docs/api/esm.html#customizing-esm-specifier-resolution-algorithm */ experimentalSpecifierResolution?: 'node' | 'explicit'; + /** + * Allow using voluntary `.ts` file extension in import specifiers. + * + * Typically, in ESM projects, import specifiers must hanve an emit extension, `.js`, `.cjs`, or `.mjs`, + * and we automatically map to the corresponding `.ts`, `.cts`, or `.mts` source file. This is the + * recommended approach. + * + * However, if you really want to use `.ts` in import specifiers, and are aware that this may + * break tooling, you can enable this flag. + */ + experimentalTsImportSpecifiers?: boolean; } export type ModuleTypes = Record; @@ -693,6 +704,11 @@ export function createFromPreloadedConfig( 6059, // "'rootDir' is expected to contain all source files." 18002, // "The 'files' list in config file is empty." 18003, // "No inputs were found in config file." + ...(options.experimentalTsImportSpecifiers + ? [ + 2691, // "An import path cannot end with a '.ts' extension. Consider importing '' instead." + ] + : []), ...(options.ignoreDiagnostics || []), ].map(Number), }, @@ -905,6 +921,8 @@ export function createFromPreloadedConfig( patterns: options.moduleTypes, }); + const extensions = getExtensions(config, options, ts.version); + // Use full language services when the fast option is disabled. if (!transpileOnly) { const fileContents = new Map(); @@ -985,6 +1003,8 @@ export function createFromPreloadedConfig( cwd, config, projectLocalResolveHelper, + options, + extensions, }); serviceHost.resolveModuleNames = resolveModuleNames; serviceHost.getResolvedModuleWithFailedLookupLocationsFromCache = @@ -1143,6 +1163,8 @@ export function createFromPreloadedConfig( ts, getCanonicalFileName, projectLocalResolveHelper, + options, + extensions, }); host.resolveModuleNames = resolveModuleNames; host.resolveTypeReferenceDirectives = resolveTypeReferenceDirectives; @@ -1448,7 +1470,6 @@ export function createFromPreloadedConfig( let active = true; const enabled = (enabled?: boolean) => enabled === undefined ? active : (active = !!enabled); - const extensions = getExtensions(config, options, ts.version); const ignored = (fileName: string) => { if (!active) return true; const ext = extname(fileName); diff --git a/src/resolver-functions.ts b/src/resolver-functions.ts index afe13b463..83568669c 100644 --- a/src/resolver-functions.ts +++ b/src/resolver-functions.ts @@ -1,4 +1,6 @@ import { resolve } from 'path'; +import type { CreateOptions } from '.'; +import type { Extensions } from './file-extensions'; import type { TSCommon, TSInternal } from './ts-compiler-types'; import type { ProjectLocalResolveHelper } from './util'; @@ -13,6 +15,8 @@ export function createResolverFunctions(kwargs: { getCanonicalFileName: (filename: string) => string; config: TSCommon.ParsedCommandLine; projectLocalResolveHelper: ProjectLocalResolveHelper; + options: CreateOptions; + extensions: Extensions; }) { const { host, @@ -21,6 +25,8 @@ export function createResolverFunctions(kwargs: { cwd, getCanonicalFileName, projectLocalResolveHelper, + options, + extensions, } = kwargs; const moduleResolutionCache = ts.createModuleResolutionCache( cwd, @@ -105,7 +111,7 @@ export function createResolverFunctions(kwargs: { i ) : undefined; - const { resolvedModule } = ts.resolveModuleName( + let { resolvedModule } = ts.resolveModuleName( moduleName, containingFile, config.options, @@ -114,6 +120,25 @@ export function createResolverFunctions(kwargs: { redirectedReference, mode ); + if (!resolvedModule && options.experimentalTsImportSpecifiers) { + const lastDotIndex = moduleName.lastIndexOf('.'); + const ext = lastDotIndex >= 0 ? moduleName.slice(lastDotIndex) : ''; + if (ext) { + const replacements = extensions.tsResolverEquivalents.get(ext); + for (const replacementExt of replacements ?? []) { + ({ resolvedModule } = ts.resolveModuleName( + moduleName.slice(0, -ext.length) + replacementExt, + containingFile, + config.options, + host, + moduleResolutionCache, + redirectedReference, + mode + )); + if (resolvedModule) break; + } + } + } if (resolvedModule) { fixupResolvedModule(resolvedModule); } diff --git a/src/test/ts-import-specifiers.spec.ts b/src/test/ts-import-specifiers.spec.ts new file mode 100644 index 000000000..39c4cc294 --- /dev/null +++ b/src/test/ts-import-specifiers.spec.ts @@ -0,0 +1,22 @@ +import { context } from './testlib'; +import * as expect from 'expect'; +import { createExec } from './exec-helpers'; +import { + TEST_DIR, + ctxTsNode, + CMD_TS_NODE_WITHOUT_PROJECT_FLAG, +} from './helpers'; + +const exec = createExec({ + cwd: TEST_DIR, +}); + +const test = context(ctxTsNode); + +test('Supports .ts extensions in import specifiers with typechecking, even though vanilla TS checker does not', async () => { + const { err, stdout } = await exec( + `${CMD_TS_NODE_WITHOUT_PROJECT_FLAG} ts-import-specifiers/index.ts` + ); + expect(err).toBe(null); + expect(stdout.trim()).toBe('{ foo: true, bar: true }'); +}); diff --git a/tests/ts-import-specifiers/bar.tsx b/tests/ts-import-specifiers/bar.tsx new file mode 100644 index 000000000..3a850c17c --- /dev/null +++ b/tests/ts-import-specifiers/bar.tsx @@ -0,0 +1 @@ +export const bar = true; diff --git a/tests/ts-import-specifiers/foo.ts b/tests/ts-import-specifiers/foo.ts new file mode 100644 index 000000000..62d968e82 --- /dev/null +++ b/tests/ts-import-specifiers/foo.ts @@ -0,0 +1 @@ +export const foo = true; diff --git a/tests/ts-import-specifiers/index.ts b/tests/ts-import-specifiers/index.ts new file mode 100644 index 000000000..2f1444fb5 --- /dev/null +++ b/tests/ts-import-specifiers/index.ts @@ -0,0 +1,3 @@ +import { foo } from './foo.ts'; +import { bar } from './bar.jsx'; +console.log({ foo, bar }); diff --git a/tests/ts-import-specifiers/tsconfig.json b/tests/ts-import-specifiers/tsconfig.json new file mode 100644 index 000000000..098594e5f --- /dev/null +++ b/tests/ts-import-specifiers/tsconfig.json @@ -0,0 +1,10 @@ +{ + "ts-node": { + // Can eventually make this a stable feature. For now, `experimental` flag allows me to iterate quickly + "experimentalTsImportSpecifiers": true, + "experimentalResolver": true + }, + "compilerOptions": { + "jsx": "react" + } +}