Skip to content

Commit 9ad213f

Browse files
authored
Validate JSON imports into ESM in --module nodenext (#60019)
1 parent 278f260 commit 9ad213f

15 files changed

+474
-53
lines changed

src/compiler/checker.ts

Lines changed: 25 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -163,7 +163,6 @@ import {
163163
EmitResolver,
164164
EmitTextWriter,
165165
emptyArray,
166-
endsWith,
167166
EntityName,
168167
EntityNameExpression,
169168
EntityNameOrEntityNameExpression,
@@ -265,6 +264,7 @@ import {
265264
getContainingClassStaticBlock,
266265
getContainingFunction,
267266
getContainingFunctionOrClassStaticBlock,
267+
getDeclarationFileExtension,
268268
getDeclarationModifierFlagsFromSymbol,
269269
getDeclarationOfKind,
270270
getDeclarationsOfKind,
@@ -3702,11 +3702,15 @@ export function createTypeChecker(host: TypeCheckerHost): TypeChecker {
37023702
return usageMode === ModuleKind.ESNext && targetMode === ModuleKind.CommonJS;
37033703
}
37043704

3705-
function isOnlyImportableAsDefault(usage: Expression) {
3705+
function isOnlyImportableAsDefault(usage: Expression, resolvedModule?: Symbol) {
37063706
// In Node.js, JSON modules don't get named exports
37073707
if (ModuleKind.Node16 <= moduleKind && moduleKind <= ModuleKind.NodeNext) {
37083708
const usageMode = getEmitSyntaxForModuleSpecifierExpression(usage);
3709-
return usageMode === ModuleKind.ESNext && endsWith((usage as StringLiteralLike).text, Extension.Json);
3709+
if (usageMode === ModuleKind.ESNext) {
3710+
resolvedModule ??= resolveExternalModuleName(usage, usage, /*ignoreErrors*/ true);
3711+
const targetFile = resolvedModule && getSourceFileOfModule(resolvedModule);
3712+
return targetFile && (isJsonSourceFile(targetFile) || getDeclarationFileExtension(targetFile.fileName) === ".d.json.ts");
3713+
}
37103714
}
37113715
return false;
37123716
}
@@ -3776,7 +3780,7 @@ export function createTypeChecker(host: TypeCheckerHost): TypeChecker {
37763780
if (!specifier) {
37773781
return exportDefaultSymbol;
37783782
}
3779-
const hasDefaultOnly = isOnlyImportableAsDefault(specifier);
3783+
const hasDefaultOnly = isOnlyImportableAsDefault(specifier, moduleSymbol);
37803784
const hasSyntheticDefault = canHaveSyntheticDefault(file, moduleSymbol, dontResolveAlias, specifier);
37813785
if (!exportDefaultSymbol && !hasSyntheticDefault && !hasDefaultOnly) {
37823786
if (hasExportAssignmentSymbol(moduleSymbol) && !allowSyntheticDefaultImports) {
@@ -3961,15 +3965,19 @@ export function createTypeChecker(host: TypeCheckerHost): TypeChecker {
39613965
let symbolFromModule = getExportOfModule(targetSymbol, nameText, specifier, dontResolveAlias);
39623966
if (symbolFromModule === undefined && nameText === InternalSymbolName.Default) {
39633967
const file = moduleSymbol.declarations?.find(isSourceFile);
3964-
if (isOnlyImportableAsDefault(moduleSpecifier) || canHaveSyntheticDefault(file, moduleSymbol, dontResolveAlias, moduleSpecifier)) {
3968+
if (isOnlyImportableAsDefault(moduleSpecifier, moduleSymbol) || canHaveSyntheticDefault(file, moduleSymbol, dontResolveAlias, moduleSpecifier)) {
39653969
symbolFromModule = resolveExternalModuleSymbol(moduleSymbol, dontResolveAlias) || resolveSymbol(moduleSymbol, dontResolveAlias);
39663970
}
39673971
}
39683972

39693973
const symbol = symbolFromModule && symbolFromVariable && symbolFromModule !== symbolFromVariable ?
39703974
combineValueAndTypeSymbols(symbolFromVariable, symbolFromModule) :
39713975
symbolFromModule || symbolFromVariable;
3972-
if (!symbol) {
3976+
3977+
if (isImportOrExportSpecifier(specifier) && isOnlyImportableAsDefault(moduleSpecifier, moduleSymbol) && nameText !== InternalSymbolName.Default) {
3978+
error(name, Diagnostics.Named_imports_from_a_JSON_file_into_an_ECMAScript_module_are_not_allowed_when_module_is_set_to_0, ModuleKind[moduleKind]);
3979+
}
3980+
else if (!symbol) {
39733981
errorNoModuleMemberSymbol(moduleSymbol, targetSymbol, node, name);
39743982
}
39753983
return symbol;
@@ -47779,6 +47787,7 @@ export function createTypeChecker(host: TypeCheckerHost): TypeChecker {
4777947787
grammarErrorOnFirstToken(node, Diagnostics.An_import_declaration_cannot_have_modifiers);
4778047788
}
4778147789
if (checkExternalImportOrExportDeclaration(node)) {
47790+
let resolvedModule;
4778247791
const importClause = node.importClause;
4778347792
if (importClause && !checkGrammarImportClause(importClause)) {
4778447793
if (importClause.name) {
@@ -47793,12 +47802,16 @@ export function createTypeChecker(host: TypeCheckerHost): TypeChecker {
4779347802
}
4779447803
}
4779547804
else {
47796-
const moduleExisted = resolveExternalModuleName(node, node.moduleSpecifier);
47797-
if (moduleExisted) {
47805+
resolvedModule = resolveExternalModuleName(node, node.moduleSpecifier);
47806+
if (resolvedModule) {
4779847807
forEach(importClause.namedBindings.elements, checkImportBinding);
4779947808
}
4780047809
}
4780147810
}
47811+
47812+
if (isOnlyImportableAsDefault(node.moduleSpecifier, resolvedModule) && !hasTypeJsonImportAttribute(node)) {
47813+
error(node.moduleSpecifier, Diagnostics.Importing_a_JSON_file_into_an_ECMAScript_module_requires_a_type_Colon_json_import_attribute_when_module_is_set_to_0, ModuleKind[moduleKind]);
47814+
}
4780247815
}
4780347816
else if (noUncheckedSideEffectImports && !importClause) {
4780447817
void resolveExternalModuleName(node, node.moduleSpecifier);
@@ -47807,6 +47820,10 @@ export function createTypeChecker(host: TypeCheckerHost): TypeChecker {
4780747820
checkImportAttributes(node);
4780847821
}
4780947822

47823+
function hasTypeJsonImportAttribute(node: ImportDeclaration) {
47824+
return !!node.attributes && node.attributes.elements.some(attr => getTextOfIdentifierOrLiteral(attr.name) === "type" && tryCast(attr.value, isStringLiteralLike)?.text === "json");
47825+
}
47826+
4781047827
function checkImportEqualsDeclaration(node: ImportEqualsDeclaration) {
4781147828
if (checkGrammarModuleElementContext(node, isInJSFile(node) ? Diagnostics.An_import_declaration_can_only_be_used_at_the_top_level_of_a_module : Diagnostics.An_import_declaration_can_only_be_used_at_the_top_level_of_a_namespace_or_module)) {
4781247829
// If we hit an import declaration in an illegal context, just bail out to avoid cascading errors.

src/compiler/diagnosticMessages.json

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1826,6 +1826,14 @@
18261826
"category": "Error",
18271827
"code": 1542
18281828
},
1829+
"Importing a JSON file into an ECMAScript module requires a 'type: \"json\"' import attribute when 'module' is set to '{0}'.": {
1830+
"category": "Error",
1831+
"code": 1543
1832+
},
1833+
"Named imports from a JSON file into an ECMAScript module are not allowed when 'module' is set to '{0}'.": {
1834+
"category": "Error",
1835+
"code": 1544
1836+
},
18291837

18301838
"The types of '{0}' are incompatible between these types.": {
18311839
"category": "Error",

src/compiler/parser.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10517,9 +10517,10 @@ export function getDeclarationFileExtension(fileName: string): string | undefine
1051710517
return standardExtension;
1051810518
}
1051910519
if (fileExtensionIs(fileName, Extension.Ts)) {
10520-
const index = getBaseFileName(fileName).lastIndexOf(".d.");
10520+
const baseName = getBaseFileName(fileName);
10521+
const index = baseName.lastIndexOf(".d.");
1052110522
if (index >= 0) {
10522-
return fileName.substring(index);
10523+
return baseName.substring(index);
1052310524
}
1052410525
}
1052510526
return undefined;
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
/loosey.cts(1,36): error TS2856: Import attributes are not allowed on statements that compile to CommonJS 'require' calls.
2+
/loosey.cts(6,9): error TS2339: Property 'default' does not exist on type '{ version: number; }'.
3+
/main.mts(2,22): error TS1543: Importing a JSON file into an ECMAScript module requires a 'type: "json"' import attribute when 'module' is set to 'NodeNext'.
4+
/main.mts(3,19): error TS1543: Importing a JSON file into an ECMAScript module requires a 'type: "json"' import attribute when 'module' is set to 'NodeNext'.
5+
/main.mts(7,21): error TS1543: Importing a JSON file into an ECMAScript module requires a 'type: "json"' import attribute when 'module' is set to 'NodeNext'.
6+
/main.mts(8,10): error TS1544: Named imports from a JSON file into an ECMAScript module are not allowed when 'module' is set to 'NodeNext'.
7+
/main.mts(10,9): error TS2339: Property 'version' does not exist on type '{ default: { version: number; }; }'.
8+
9+
10+
==== /node_modules/not.json/package.json (0 errors) ====
11+
{
12+
"name": "not.json",
13+
"version": "1.0.0",
14+
"type": "module",
15+
"exports": "./index.js"
16+
}
17+
18+
==== /node_modules/not.json/index.d.ts (0 errors) ====
19+
export function oops(json: string): any;
20+
21+
==== /node_modules/actually-json/package.json (0 errors) ====
22+
{
23+
"name": "actually-json",
24+
"version": "1.0.0",
25+
"type": "module",
26+
"exports": {
27+
".": "./index.json",
28+
"./typed": "./typed.d.json.ts"
29+
}
30+
}
31+
32+
==== /node_modules/actually-json/index.json (0 errors) ====
33+
{}
34+
35+
==== /node_modules/actually-json/typed.d.json.ts (0 errors) ====
36+
declare const _default: {};
37+
export default _default;
38+
39+
==== /config.json (0 errors) ====
40+
{
41+
"version": 1
42+
}
43+
44+
==== /main.mts (5 errors) ====
45+
import { oops } from "not.json"; // Ok
46+
import moreOops from "actually-json"; // Error
47+
~~~~~~~~~~~~~~~
48+
!!! error TS1543: Importing a JSON file into an ECMAScript module requires a 'type: "json"' import attribute when 'module' is set to 'NodeNext'.
49+
import typed from "actually-json/typed"; // Error
50+
~~~~~~~~~~~~~~~~~~~~~
51+
!!! error TS1543: Importing a JSON file into an ECMAScript module requires a 'type: "json"' import attribute when 'module' is set to 'NodeNext'.
52+
53+
import config from "./config.json" with { type: "json" }; // Ok
54+
import { default as config1 } from "./config.json" with { type: "json" }; // Ok
55+
import config2 from "./config.json"; // Error, no attribute
56+
~~~~~~~~~~~~~~~
57+
!!! error TS1543: Importing a JSON file into an ECMAScript module requires a 'type: "json"' import attribute when 'module' is set to 'NodeNext'.
58+
import { version } from "./config.json" with { type: "json" }; // Error, named import
59+
~~~~~~~
60+
!!! error TS1544: Named imports from a JSON file into an ECMAScript module are not allowed when 'module' is set to 'NodeNext'.
61+
import * as config3 from "./config.json" with { type: "json" };
62+
config3.version; // Error
63+
~~~~~~~
64+
!!! error TS2339: Property 'version' does not exist on type '{ default: { version: number; }; }'.
65+
config3.default; // Ok
66+
67+
==== /loosey.cts (2 errors) ====
68+
import config from "./config.json" with { type: "json" }; // Error
69+
~~~~~~~~~~~~~~~~~~~~~
70+
!!! error TS2856: Import attributes are not allowed on statements that compile to CommonJS 'require' calls.
71+
import config2 from "./config.json"; // Ok
72+
import { version } from "./config.json"; // Ok
73+
import * as config3 from "./config.json";
74+
config3.version; // Ok
75+
config3.default; // Error
76+
~~~~~~~
77+
!!! error TS2339: Property 'default' does not exist on type '{ version: number; }'.
78+
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
//// [tests/cases/conformance/node/nodeModulesJson.ts] ////
2+
3+
=== /node_modules/not.json/index.d.ts ===
4+
export function oops(json: string): any;
5+
>oops : Symbol(oops, Decl(index.d.ts, 0, 0))
6+
>json : Symbol(json, Decl(index.d.ts, 0, 21))
7+
8+
=== /node_modules/actually-json/index.json ===
9+
10+
{}
11+
12+
=== /node_modules/actually-json/typed.d.json.ts ===
13+
declare const _default: {};
14+
>_default : Symbol(_default, Decl(typed.d.json.ts, 0, 13))
15+
16+
export default _default;
17+
>_default : Symbol(_default, Decl(typed.d.json.ts, 0, 13))
18+
19+
=== /config.json ===
20+
{
21+
"version": 1
22+
>"version" : Symbol("version", Decl(config.json, 0, 1))
23+
}
24+
25+
=== /main.mts ===
26+
import { oops } from "not.json"; // Ok
27+
>oops : Symbol(oops, Decl(main.mts, 0, 8))
28+
29+
import moreOops from "actually-json"; // Error
30+
>moreOops : Symbol(moreOops, Decl(main.mts, 1, 6))
31+
32+
import typed from "actually-json/typed"; // Error
33+
>typed : Symbol(typed, Decl(main.mts, 2, 6))
34+
35+
import config from "./config.json" with { type: "json" }; // Ok
36+
>config : Symbol(config, Decl(main.mts, 4, 6))
37+
38+
import { default as config1 } from "./config.json" with { type: "json" }; // Ok
39+
>default : Symbol(config, Decl(config.json, 0, 0))
40+
>config1 : Symbol(config1, Decl(main.mts, 5, 8))
41+
42+
import config2 from "./config.json"; // Error, no attribute
43+
>config2 : Symbol(config2, Decl(main.mts, 6, 6))
44+
45+
import { version } from "./config.json" with { type: "json" }; // Error, named import
46+
>version : Symbol(version, Decl(main.mts, 7, 8))
47+
48+
import * as config3 from "./config.json" with { type: "json" };
49+
>config3 : Symbol(config3, Decl(main.mts, 8, 6))
50+
51+
config3.version; // Error
52+
>config3 : Symbol(config3, Decl(main.mts, 8, 6))
53+
54+
config3.default; // Ok
55+
>config3.default : Symbol("/config")
56+
>config3 : Symbol(config3, Decl(main.mts, 8, 6))
57+
>default : Symbol("/config")
58+
59+
=== /loosey.cts ===
60+
import config from "./config.json" with { type: "json" }; // Error
61+
>config : Symbol(config, Decl(loosey.cts, 0, 6))
62+
63+
import config2 from "./config.json"; // Ok
64+
>config2 : Symbol(config2, Decl(loosey.cts, 1, 6))
65+
66+
import { version } from "./config.json"; // Ok
67+
>version : Symbol(version, Decl(loosey.cts, 2, 8))
68+
69+
import * as config3 from "./config.json";
70+
>config3 : Symbol(config3, Decl(loosey.cts, 3, 6))
71+
72+
config3.version; // Ok
73+
>config3.version : Symbol(version, Decl(config.json, 0, 1))
74+
>config3 : Symbol(config3, Decl(loosey.cts, 3, 6))
75+
>version : Symbol(version, Decl(config.json, 0, 1))
76+
77+
config3.default; // Error
78+
>config3 : Symbol(config3, Decl(loosey.cts, 3, 6))
79+

0 commit comments

Comments
 (0)