Skip to content

Commit 91110cd

Browse files
authored
Allow opting-in to .ts import specifiers (#1815)
* quick impl * fix * update * add a test * add jsdoc for new option
1 parent ddd559d commit 91110cd

File tree

9 files changed

+107
-2
lines changed

9 files changed

+107
-2
lines changed

src/configuration.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -383,6 +383,7 @@ function filterRecognizedTsConfigTsNodeOptions(jsonObject: any): {
383383
experimentalResolver,
384384
esm,
385385
experimentalSpecifierResolution,
386+
experimentalTsImportSpecifiers,
386387
...unrecognized
387388
} = jsonObject as TsConfigOptions;
388389
const filteredTsConfigOptions = {
@@ -409,6 +410,7 @@ function filterRecognizedTsConfigTsNodeOptions(jsonObject: any): {
409410
experimentalResolver,
410411
esm,
411412
experimentalSpecifierResolution,
413+
experimentalTsImportSpecifiers,
412414
};
413415
// Use the typechecker to make sure this implementation has the correct set of properties
414416
const catchExtraneousProps: keyof TsConfigOptions =

src/file-extensions.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,13 @@ const nodeEquivalents = new Map<string, string>([
1919
['.cts', '.cjs'],
2020
]);
2121

22+
const tsResolverEquivalents = new Map<string, readonly string[]>([
23+
['.ts', ['.js']],
24+
['.tsx', ['.js', '.jsx']],
25+
['.mts', ['.mjs']],
26+
['.cts', ['.cjs']],
27+
]);
28+
2229
// All extensions understood by vanilla node
2330
const vanillaNodeExtensions: readonly string[] = [
2431
'.js',
@@ -129,6 +136,19 @@ export function getExtensions(
129136
* as far as getFormat is concerned.
130137
*/
131138
nodeEquivalents,
139+
/**
140+
* Mapping from extensions rejected by TSC in import specifiers, to the
141+
* possible alternatives that TS's resolver will accept.
142+
*
143+
* When we allow users to opt-in to .ts extensions in import specifiers, TS's
144+
* resolver requires us to replace the .ts extensions with .js alternatives.
145+
* Otherwise, resolution fails.
146+
*
147+
* Note TS's resolver is only used by, and only required for, typechecking.
148+
* This is separate from node's resolver, which we hook separately and which
149+
* does not require this mapping.
150+
*/
151+
tsResolverEquivalents,
132152
/**
133153
* Extensions that we can support if the user upgrades their typescript version.
134154
* Used when raising hints.

src/index.ts

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -373,6 +373,17 @@ export interface CreateOptions {
373373
* For details, see https://nodejs.org/dist/latest-v18.x/docs/api/esm.html#customizing-esm-specifier-resolution-algorithm
374374
*/
375375
experimentalSpecifierResolution?: 'node' | 'explicit';
376+
/**
377+
* Allow using voluntary `.ts` file extension in import specifiers.
378+
*
379+
* Typically, in ESM projects, import specifiers must hanve an emit extension, `.js`, `.cjs`, or `.mjs`,
380+
* and we automatically map to the corresponding `.ts`, `.cts`, or `.mts` source file. This is the
381+
* recommended approach.
382+
*
383+
* However, if you really want to use `.ts` in import specifiers, and are aware that this may
384+
* break tooling, you can enable this flag.
385+
*/
386+
experimentalTsImportSpecifiers?: boolean;
376387
}
377388

378389
export type ModuleTypes = Record<string, ModuleTypeOverride>;
@@ -693,6 +704,11 @@ export function createFromPreloadedConfig(
693704
6059, // "'rootDir' is expected to contain all source files."
694705
18002, // "The 'files' list in config file is empty."
695706
18003, // "No inputs were found in config file."
707+
...(options.experimentalTsImportSpecifiers
708+
? [
709+
2691, // "An import path cannot end with a '.ts' extension. Consider importing '<specifier without ext>' instead."
710+
]
711+
: []),
696712
...(options.ignoreDiagnostics || []),
697713
].map(Number),
698714
},
@@ -905,6 +921,8 @@ export function createFromPreloadedConfig(
905921
patterns: options.moduleTypes,
906922
});
907923

924+
const extensions = getExtensions(config, options, ts.version);
925+
908926
// Use full language services when the fast option is disabled.
909927
if (!transpileOnly) {
910928
const fileContents = new Map<string, string>();
@@ -985,6 +1003,8 @@ export function createFromPreloadedConfig(
9851003
cwd,
9861004
config,
9871005
projectLocalResolveHelper,
1006+
options,
1007+
extensions,
9881008
});
9891009
serviceHost.resolveModuleNames = resolveModuleNames;
9901010
serviceHost.getResolvedModuleWithFailedLookupLocationsFromCache =
@@ -1143,6 +1163,8 @@ export function createFromPreloadedConfig(
11431163
ts,
11441164
getCanonicalFileName,
11451165
projectLocalResolveHelper,
1166+
options,
1167+
extensions,
11461168
});
11471169
host.resolveModuleNames = resolveModuleNames;
11481170
host.resolveTypeReferenceDirectives = resolveTypeReferenceDirectives;
@@ -1448,7 +1470,6 @@ export function createFromPreloadedConfig(
14481470
let active = true;
14491471
const enabled = (enabled?: boolean) =>
14501472
enabled === undefined ? active : (active = !!enabled);
1451-
const extensions = getExtensions(config, options, ts.version);
14521473
const ignored = (fileName: string) => {
14531474
if (!active) return true;
14541475
const ext = extname(fileName);

src/resolver-functions.ts

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
import { resolve } from 'path';
2+
import type { CreateOptions } from '.';
3+
import type { Extensions } from './file-extensions';
24
import type { TSCommon, TSInternal } from './ts-compiler-types';
35
import type { ProjectLocalResolveHelper } from './util';
46

@@ -13,6 +15,8 @@ export function createResolverFunctions(kwargs: {
1315
getCanonicalFileName: (filename: string) => string;
1416
config: TSCommon.ParsedCommandLine;
1517
projectLocalResolveHelper: ProjectLocalResolveHelper;
18+
options: CreateOptions;
19+
extensions: Extensions;
1620
}) {
1721
const {
1822
host,
@@ -21,6 +25,8 @@ export function createResolverFunctions(kwargs: {
2125
cwd,
2226
getCanonicalFileName,
2327
projectLocalResolveHelper,
28+
options,
29+
extensions,
2430
} = kwargs;
2531
const moduleResolutionCache = ts.createModuleResolutionCache(
2632
cwd,
@@ -105,7 +111,7 @@ export function createResolverFunctions(kwargs: {
105111
i
106112
)
107113
: undefined;
108-
const { resolvedModule } = ts.resolveModuleName(
114+
let { resolvedModule } = ts.resolveModuleName(
109115
moduleName,
110116
containingFile,
111117
config.options,
@@ -114,6 +120,25 @@ export function createResolverFunctions(kwargs: {
114120
redirectedReference,
115121
mode
116122
);
123+
if (!resolvedModule && options.experimentalTsImportSpecifiers) {
124+
const lastDotIndex = moduleName.lastIndexOf('.');
125+
const ext = lastDotIndex >= 0 ? moduleName.slice(lastDotIndex) : '';
126+
if (ext) {
127+
const replacements = extensions.tsResolverEquivalents.get(ext);
128+
for (const replacementExt of replacements ?? []) {
129+
({ resolvedModule } = ts.resolveModuleName(
130+
moduleName.slice(0, -ext.length) + replacementExt,
131+
containingFile,
132+
config.options,
133+
host,
134+
moduleResolutionCache,
135+
redirectedReference,
136+
mode
137+
));
138+
if (resolvedModule) break;
139+
}
140+
}
141+
}
117142
if (resolvedModule) {
118143
fixupResolvedModule(resolvedModule);
119144
}

src/test/ts-import-specifiers.spec.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import { context } from './testlib';
2+
import * as expect from 'expect';
3+
import { createExec } from './exec-helpers';
4+
import {
5+
TEST_DIR,
6+
ctxTsNode,
7+
CMD_TS_NODE_WITHOUT_PROJECT_FLAG,
8+
} from './helpers';
9+
10+
const exec = createExec({
11+
cwd: TEST_DIR,
12+
});
13+
14+
const test = context(ctxTsNode);
15+
16+
test('Supports .ts extensions in import specifiers with typechecking, even though vanilla TS checker does not', async () => {
17+
const { err, stdout } = await exec(
18+
`${CMD_TS_NODE_WITHOUT_PROJECT_FLAG} ts-import-specifiers/index.ts`
19+
);
20+
expect(err).toBe(null);
21+
expect(stdout.trim()).toBe('{ foo: true, bar: true }');
22+
});

tests/ts-import-specifiers/bar.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export const bar = true;

tests/ts-import-specifiers/foo.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export const foo = true;

tests/ts-import-specifiers/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
import { foo } from './foo.ts';
2+
import { bar } from './bar.jsx';
3+
console.log({ foo, bar });
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
{
2+
"ts-node": {
3+
// Can eventually make this a stable feature. For now, `experimental` flag allows me to iterate quickly
4+
"experimentalTsImportSpecifiers": true,
5+
"experimentalResolver": true
6+
},
7+
"compilerOptions": {
8+
"jsx": "react"
9+
}
10+
}

0 commit comments

Comments
 (0)