Repository: microsoft/typescript-go (go)

Issue:
types: [] in tsconfig.json is treated as "all visible types"
In tsc:

1. Unspecified `types`: all "visible" types (from node_modules)
2. `types: []`: no extra types

in tsgo (tsgo.exe)

1. Unspecified `types`: all "visible" types (from node_modules)
2. `types: []`: also all visible types, no difference from unspecified

The problem is here:

https://github.com/microsoft/typescript-go/blob/main/internal/compiler/module/resolver.go#L1799

```go
if options.Types != nil {
    return options.Types
}
```

Because here https://github.com/microsoft/typescript-go/blob/main/internal/tsoptions/parsinghelpers.go#L26

```go
func parseStringArray(value any) []string {
	if arr, ok := value.([]any); ok {
		var result []string
		for _, v := range arr {
			if str, ok := v.(string); ok {
				result = append(result, str)
			}
		}
		return result
	}
	return nil
}
```

`var result []string` is a nil slice, which is returned if there are no elements in an array.

I have never programmed in Go so I didn't submit a pull request since I don't know if replacing it with a `result := []string{}` is a good way to do it, hopefully it's an easy fix.

Pull request:
Fix empty array tsconfig parsing, ensure helpers are nil-preserving
Fixes #623
Replaces #624

The issues here were somewhat wide reaching:

- `core.Map` and `core.MapIndex` were not nil-preserving. This led to parsing turning empty slices into nil slices, which is a problem when `nil` means "was not provided".
- `parseStringArray` was not nil-preserving.
- `setPropertyValue` was not nil-preserving.
- `getPropFromRaw` was not nil-preserving.

Fixing these removes a bunch of extra code from `parseJsonToStringKey`, too.

Annoyingly, `core.Pool` _also_ returns `nil` instead of an empty slice, so we can end up with `nil` lists in the tree rather than empty slices. I've not changed this yet, since we can just say "the parser may produce nil slices for NodeLists", but I suspect this will be a problem for API use given `nil` should really mean `undefined` and that's what the JSON package may do depending on the tags.

Patch:
diff --git a/internal/core/core.go b/internal/core/core.go
index 0b4154d6b..53ce832db 100644
--- a/internal/core/core.go
+++ b/internal/core/core.go
@@ -48,7 +48,7 @@ func FilterIndex[T any](slice []T, f func(T, int, []T) bool) []T {
 }
 
 func Map[T, U any](slice []T, f func(T) U) []U {
-	if len(slice) == 0 {
+	if slice == nil {
 		return nil
 	}
 	result := make([]U, len(slice))
@@ -59,7 +59,7 @@ func Map[T, U any](slice []T, f func(T) U) []U {
 }
 
 func MapIndex[T, U any](slice []T, f func(T, int) U) []U {
-	if len(slice) == 0 {
+	if slice == nil {
 		return nil
 	}
 	result := make([]U, len(slice))
diff --git a/internal/tsoptions/parsinghelpers.go b/internal/tsoptions/parsinghelpers.go
index 9c490958f..589d3fe6c 100644
--- a/internal/tsoptions/parsinghelpers.go
+++ b/internal/tsoptions/parsinghelpers.go
@@ -25,7 +25,10 @@ func parseTristate(value any) core.Tristate {
 
 func parseStringArray(value any) []string {
 	if arr, ok := value.([]any); ok {
-		var result []string
+		if arr == nil {
+			return nil
+		}
+		result := make([]string, 0, len(arr))
 		for _, v := range arr {
 			if str, ok := v.(string); ok {
 				result = append(result, str)
@@ -83,41 +86,22 @@ func parseJsonToStringKey(json any) *collections.OrderedMap[string, any] {
 	result := collections.NewOrderedMapWithSizeHint[string, any](6)
 	if m, ok := json.(*collections.OrderedMap[string, any]); ok {
 		if v, ok := m.Get("include"); ok {
-			if arr, ok := v.([]string); ok && len(arr) == 0 {
-				result.Set("include", []any{})
-			} else {
-				result.Set("include", v)
-			}
+			result.Set("include", v)
 		}
 		if v, ok := m.Get("exclude"); ok {
-			if arr, ok := v.([]string); ok && len(arr) == 0 {
-				result.Set("exclude", []any{})
-			} else {
-				result.Set("exclude", v)
-			}
+			result.Set("exclude", v)
 		}
 		if v, ok := m.Get("files"); ok {
-			if arr, ok := v.([]string); ok && len(arr) == 0 {
-				result.Set("files", []any{})
-			} else {
-				result.Set("files", v)
-			}
+			result.Set("files", v)
 		}
 		if v, ok := m.Get("references"); ok {
-			if arr, ok := v.([]string); ok && len(arr) == 0 {
-				result.Set("references", []any{})
-			} else {
-				result.Set("references", v)
-			}
+			result.Set("references", v)
 		}
 		if v, ok := m.Get("extends"); ok {
-			if arr, ok := v.([]string); ok && len(arr) == 0 {
-				result.Set("extends", []any{})
-			} else if str, ok := v.(string); ok {
+			if str, ok := v.(string); ok {
 				result.Set("extends", []any{str})
-			} else {
-				result.Set("extends", v)
 			}
+			result.Set("extends", v)
 		}
 		if v, ok := m.Get("compilerOptions"); ok {
 			result.Set("compilerOptions", v)
diff --git a/internal/tsoptions/tsconfigparsing.go b/internal/tsoptions/tsconfigparsing.go
index a57260ef1..8fa275803 100644
--- a/internal/tsoptions/tsconfigparsing.go
+++ b/internal/tsoptions/tsconfigparsing.go
@@ -304,8 +304,8 @@ func convertJsonOptionOfListType(
 ) ([]any, []*ast.Diagnostic) {
 	var expression *ast.Node
 	var errors []*ast.Diagnostic
-	if _, ok := values.([]any); ok {
-		mappedValues := core.MapIndex(values.([]any), func(v any, index int) any {
+	if values, ok := values.([]any); ok {
+		mappedValues := core.MapIndex(values, func(v any, index int) any {
 			if valueExpression != nil {
 				expression = valueExpression.AsArrayLiteralExpression().Elements.Nodes[index]
 			}
@@ -545,6 +545,8 @@ func convertArrayLiteralExpressionToJson(
 	}
 	// Filter out invalid values
 	if len(elements) == 0 {
+		// Always return an empty array, even if elements is nil.
+		// The parser will produce nil slices instead of allocating empty ones.
 		return []any{}, nil
 	}
 	var errors []*ast.Diagnostic
@@ -909,26 +911,28 @@ func parseConfig(
 				}
 				if propertyName == "include" || propertyName == "exclude" || propertyName == "files" {
 					if rawMap, ok := extendsRaw.(*collections.OrderedMap[string, any]); ok && rawMap.Has(propertyName) {
-						value := core.Map(rawMap.GetOrZero(propertyName).([]any), func(path any) any {
-							if startsWithConfigDirTemplate(path) || tspath.IsRootedDiskPath(path.(string)) {
-								return path.(string)
-							} else {
-								if relativeDifference == "" {
-									t := tspath.ComparePathsOptions{
-										UseCaseSensitiveFileNames: host.FS().UseCaseSensitiveFileNames(),
-										CurrentDirectory:          host.GetCurrentDirectory(),
+						if slice, _ := rawMap.GetOrZero(propertyName).([]any); slice != nil {
+							value := core.Map(slice, func(path any) any {
+								if startsWithConfigDirTemplate(path) || tspath.IsRootedDiskPath(path.(string)) {
+									return path.(string)
+								} else {
+									if relativeDifference == "" {
+										t := tspath.ComparePathsOptions{
+											UseCaseSensitiveFileNames: host.FS().UseCaseSensitiveFileNames(),
+											CurrentDirectory:          host.GetCurrentDirectory(),
+										}
+										relativeDifference = tspath.ConvertToRelativePath(basePath, t)
 									}
-									relativeDifference = tspath.ConvertToRelativePath(basePath, t)
+									return tspath.CombinePaths(relativeDifference, path.(string))
 								}
-								return tspath.CombinePaths(relativeDifference, path.(string))
+							})
+							if propertyName == "include" {
+								result.include = value
+							} else if propertyName == "exclude" {
+								result.exclude = value
+							} else if propertyName == "files" {
+								result.files = value
 							}
-						})
-						if propertyName == "include" {
-							result.include = value
-						} else if propertyName == "exclude" {
-							result.exclude = value
-						} else if propertyName == "files" {
-							result.files = value
 						}
 					}
 				}
@@ -1028,7 +1032,7 @@ func parseJsonConfigFileContentWorker(
 	}
 	getPropFromRaw := func(prop string, validateElement func(value any) bool, elementTypeName string) propOfRaw {
 		value, exists := rawConfig.Get(prop)
-		if exists {
+		if exists && value != nil {
 			if reflect.TypeOf(value).Kind() == reflect.Slice {
 				result := rawConfig.GetOrZero(prop)
 				if _, ok := result.([]any); ok {
@@ -1075,7 +1079,7 @@ func parseJsonConfigFileContentWorker(
 		outDir := parsedConfig.options.OutDir
 		declarationDir := parsedConfig.options.DeclarationDir
 		if outDir != "" || declarationDir != "" {
-			values := []any{}
+			var values []any
 			if outDir != "" {
 				values = append(values, outDir)
 			}


Test patch:
diff --git a/internal/tsoptions/tsconfigparsing_test.go b/internal/tsoptions/tsconfigparsing_test.go
index 7712e1bbe..9eeeef505 100644
--- a/internal/tsoptions/tsconfigparsing_test.go
+++ b/internal/tsoptions/tsconfigparsing_test.go
@@ -495,6 +495,20 @@ var parseJsonConfigFileTests = []struct {
 			allFileList:    map[string]string{"/app.ts": ""},
 		}},
 	},
+	{
+		title:               "handles empty types array",
+		noSubmoduleBaseline: true,
+		input: []testConfig{{
+			jsonText: `{
+			    "compilerOptions": {
+					"types": []
+				}
+			}`,
+			configFileName: "tsconfig.json",
+			basePath:       "/",
+			allFileList:    map[string]string{"/app.ts": ""},
+		}},
+	},
 }
 
 var tsconfigWithExtends = `{
diff --git a/testdata/baselines/reference/config/tsconfigParsing/handles empty types array with json api.js b/testdata/baselines/reference/config/tsconfigParsing/handles empty types array with json api.js
new file mode 100644
index 000000000..7e43406ec
--- /dev/null
+++ b/testdata/baselines/reference/config/tsconfigParsing/handles empty types array with json api.js
@@ -0,0 +1,23 @@
+Fs::
+//// [/app.ts]
+
+
+//// [/tsconfig.json]
+{
+			    "compilerOptions": {
+					"types": []
+				}
+			}
+
+
+configFileName:: tsconfig.json
+CompilerOptions::
+{
+  "types": [],
+  "configFilePath": "/tsconfig.json"
+}
+
+FileNames::
+/app.ts
+Errors::
+
diff --git a/testdata/baselines/reference/config/tsconfigParsing/handles empty types array with jsonSourceFile api.js b/testdata/baselines/reference/config/tsconfigParsing/handles empty types array with jsonSourceFile api.js
new file mode 100644
index 000000000..7e43406ec
--- /dev/null
+++ b/testdata/baselines/reference/config/tsconfigParsing/handles empty types array with jsonSourceFile api.js
@@ -0,0 +1,23 @@
+Fs::
+//// [/app.ts]
+
+
+//// [/tsconfig.json]
+{
+			    "compilerOptions": {
+					"types": []
+				}
+			}
+
+
+configFileName:: tsconfig.json
+CompilerOptions::
+{
+  "types": [],
+  "configFilePath": "/tsconfig.json"
+}
+
+FileNames::
+/app.ts
+Errors::
+
diff --git a/testdata/baselines/reference/submodule/conformance/typingsSuggestion1.errors.txt b/testdata/baselines/reference/submodule/conformance/typingsSuggestion1.errors.txt
index ac184751c..d5b66e6a9 100644
--- a/testdata/baselines/reference/submodule/conformance/typingsSuggestion1.errors.txt
+++ b/testdata/baselines/reference/submodule/conformance/typingsSuggestion1.errors.txt
@@ -1,4 +1,4 @@
-a.ts(1,1): error TS2580: Cannot find name 'module'. Do you need to install type definitions for node? Try `npm i --save-dev @types/node`.
+a.ts(1,1): error TS2591: Cannot find name 'module'. Do you need to install type definitions for node? Try `npm i --save-dev @types/node` and then add 'node' to the types field in your tsconfig.
 
 
 ==== tsconfig.json (0 errors) ====
@@ -7,5 +7,5 @@ a.ts(1,1): error TS2580: Cannot find name 'module'. Do you need to install type
 ==== a.ts (1 errors) ====
     module.exports = 1;
     ~~~~~~
-!!! error TS2580: Cannot find name 'module'. Do you need to install type definitions for node? Try `npm i --save-dev @types/node`.
+!!! error TS2591: Cannot find name 'module'. Do you need to install type definitions for node? Try `npm i --save-dev @types/node` and then add 'node' to the types field in your tsconfig.
     
\ No newline at end of file
diff --git a/testdata/baselines/reference/submodule/conformance/typingsSuggestion1.errors.txt.diff b/testdata/baselines/reference/submodule/conformance/typingsSuggestion1.errors.txt.diff
deleted file mode 100644
index cb2a04ab0..000000000
--- a/testdata/baselines/reference/submodule/conformance/typingsSuggestion1.errors.txt.diff
+++ /dev/null
@@ -1,15 +0,0 @@
---- old.typingsSuggestion1.errors.txt
-+++ new.typingsSuggestion1.errors.txt
-@@= skipped -0, +0 lines =@@
--a.ts(1,1): error TS2591: Cannot find name 'module'. Do you need to install type definitions for node? Try `npm i --save-dev @types/node` and then add 'node' to the types field in your tsconfig.
-+a.ts(1,1): error TS2580: Cannot find name 'module'. Do you need to install type definitions for node? Try `npm i --save-dev @types/node`.
- 
- 
- ==== tsconfig.json (0 errors) ====
-@@= skipped -6, +6 lines =@@
- ==== a.ts (1 errors) ====
-     module.exports = 1;
-     ~~~~~~
--!!! error TS2591: Cannot find name 'module'. Do you need to install type definitions for node? Try `npm i --save-dev @types/node` and then add 'node' to the types field in your tsconfig.
-+!!! error TS2580: Cannot find name 'module'. Do you need to install type definitions for node? Try `npm i --save-dev @types/node`.
-     
diff --git a/testdata/baselines/reference/submodule/conformance/typingsSuggestionBun1.errors.txt b/testdata/baselines/reference/submodule/conformance/typingsSuggestionBun1.errors.txt
index 392afb94b..c2ab614b5 100644
--- a/testdata/baselines/reference/submodule/conformance/typingsSuggestionBun1.errors.txt
+++ b/testdata/baselines/reference/submodule/conformance/typingsSuggestionBun1.errors.txt
@@ -1,4 +1,4 @@
-a.ts(1,14): error TS2867: Cannot find name 'Bun'. Do you need to install type definitions for Bun? Try `npm i --save-dev @types/bun`.
+a.ts(1,14): error TS2868: Cannot find name 'Bun'. Do you need to install type definitions for Bun? Try `npm i --save-dev @types/bun` and then add 'bun' to the types field in your tsconfig.
 
 
 ==== tsconfig.json (0 errors) ====
@@ -7,5 +7,5 @@ a.ts(1,14): error TS2867: Cannot find name 'Bun'. Do you need to install type de
 ==== a.ts (1 errors) ====
     const file = Bun.file("/a.ts");
                  ~~~
-!!! error TS2867: Cannot find name 'Bun'. Do you need to install type definitions for Bun? Try `npm i --save-dev @types/bun`.
+!!! error TS2868: Cannot find name 'Bun'. Do you need to install type definitions for Bun? Try `npm i --save-dev @types/bun` and then add 'bun' to the types field in your tsconfig.
     
\ No newline at end of file
diff --git a/testdata/baselines/reference/submodule/conformance/typingsSuggestionBun1.errors.txt.diff b/testdata/baselines/reference/submodule/conformance/typingsSuggestionBun1.errors.txt.diff
deleted file mode 100644
index 79c1f4eb4..000000000
--- a/testdata/baselines/reference/submodule/conformance/typingsSuggestionBun1.errors.txt.diff
+++ /dev/null
@@ -1,15 +0,0 @@
---- old.typingsSuggestionBun1.errors.txt
-+++ new.typingsSuggestionBun1.errors.txt
-@@= skipped -0, +0 lines =@@
--a.ts(1,14): error TS2868: Cannot find name 'Bun'. Do you need to install type definitions for Bun? Try `npm i --save-dev @types/bun` and then add 'bun' to the types field in your tsconfig.
-+a.ts(1,14): error TS2867: Cannot find name 'Bun'. Do you need to install type definitions for Bun? Try `npm i --save-dev @types/bun`.
- 
- 
- ==== tsconfig.json (0 errors) ====
-@@= skipped -6, +6 lines =@@
- ==== a.ts (1 errors) ====
-     const file = Bun.file("/a.ts");
-                  ~~~
--!!! error TS2868: Cannot find name 'Bun'. Do you need to install type definitions for Bun? Try `npm i --save-dev @types/bun` and then add 'bun' to the types field in your tsconfig.
-+!!! error TS2867: Cannot find name 'Bun'. Do you need to install type definitions for Bun? Try `npm i --save-dev @types/bun`.
-