Skip to content

Commit

Permalink
Allow opting-in to .ts import specifiers (#1815)
Browse files Browse the repository at this point in the history
* quick impl

* fix

* update

* add a test

* add jsdoc for new option
  • Loading branch information
cspotcode committed Jul 2, 2022
1 parent ddd559d commit 91110cd
Show file tree
Hide file tree
Showing 9 changed files with 107 additions and 2 deletions.
2 changes: 2 additions & 0 deletions src/configuration.ts
Expand Up @@ -383,6 +383,7 @@ function filterRecognizedTsConfigTsNodeOptions(jsonObject: any): {
experimentalResolver,
esm,
experimentalSpecifierResolution,
experimentalTsImportSpecifiers,
...unrecognized
} = jsonObject as TsConfigOptions;
const filteredTsConfigOptions = {
Expand All @@ -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 =
Expand Down
20 changes: 20 additions & 0 deletions src/file-extensions.ts
Expand Up @@ -19,6 +19,13 @@ const nodeEquivalents = new Map<string, string>([
['.cts', '.cjs'],
]);

const tsResolverEquivalents = new Map<string, readonly string[]>([
['.ts', ['.js']],
['.tsx', ['.js', '.jsx']],
['.mts', ['.mjs']],
['.cts', ['.cjs']],
]);

// All extensions understood by vanilla node
const vanillaNodeExtensions: readonly string[] = [
'.js',
Expand Down Expand Up @@ -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.
Expand Down
23 changes: 22 additions & 1 deletion src/index.ts
Expand Up @@ -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<string, ModuleTypeOverride>;
Expand Down Expand Up @@ -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 '<specifier without ext>' instead."
]
: []),
...(options.ignoreDiagnostics || []),
].map(Number),
},
Expand Down Expand Up @@ -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<string, string>();
Expand Down Expand Up @@ -985,6 +1003,8 @@ export function createFromPreloadedConfig(
cwd,
config,
projectLocalResolveHelper,
options,
extensions,
});
serviceHost.resolveModuleNames = resolveModuleNames;
serviceHost.getResolvedModuleWithFailedLookupLocationsFromCache =
Expand Down Expand Up @@ -1143,6 +1163,8 @@ export function createFromPreloadedConfig(
ts,
getCanonicalFileName,
projectLocalResolveHelper,
options,
extensions,
});
host.resolveModuleNames = resolveModuleNames;
host.resolveTypeReferenceDirectives = resolveTypeReferenceDirectives;
Expand Down Expand Up @@ -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);
Expand Down
27 changes: 26 additions & 1 deletion 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';

Expand All @@ -13,6 +15,8 @@ export function createResolverFunctions(kwargs: {
getCanonicalFileName: (filename: string) => string;
config: TSCommon.ParsedCommandLine;
projectLocalResolveHelper: ProjectLocalResolveHelper;
options: CreateOptions;
extensions: Extensions;
}) {
const {
host,
Expand All @@ -21,6 +25,8 @@ export function createResolverFunctions(kwargs: {
cwd,
getCanonicalFileName,
projectLocalResolveHelper,
options,
extensions,
} = kwargs;
const moduleResolutionCache = ts.createModuleResolutionCache(
cwd,
Expand Down Expand Up @@ -105,7 +111,7 @@ export function createResolverFunctions(kwargs: {
i
)
: undefined;
const { resolvedModule } = ts.resolveModuleName(
let { resolvedModule } = ts.resolveModuleName(
moduleName,
containingFile,
config.options,
Expand All @@ -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);
}
Expand Down
22 changes: 22 additions & 0 deletions 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 }');
});
1 change: 1 addition & 0 deletions tests/ts-import-specifiers/bar.tsx
@@ -0,0 +1 @@
export const bar = true;
1 change: 1 addition & 0 deletions tests/ts-import-specifiers/foo.ts
@@ -0,0 +1 @@
export const foo = true;
3 changes: 3 additions & 0 deletions tests/ts-import-specifiers/index.ts
@@ -0,0 +1,3 @@
import { foo } from './foo.ts';
import { bar } from './bar.jsx';
console.log({ foo, bar });
10 changes: 10 additions & 0 deletions 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"
}
}

0 comments on commit 91110cd

Please sign in to comment.