Repository: prettier/prettier (JavaScript)
Date: 2023-04-11 07:41:00+00:00
Task type: Bug Fix

Issue:
Required Comma on Unconstrained Generic Removed in .mts Files
<!--

BEFORE SUBMITTING AN ISSUE:

1.  Search for your issue on GitHub: https://github.com/prettier/prettier/issues
    A large number of opened issues are duplicates of existing issues.
    If someone has already opened an issue for what you are experiencing,
    you do not need to open a new issue — please add a 👍 reaction to the
    existing issue instead.

2.  We get a lot of requests for adding options, but Prettier is
    built on the principle of being opinionated about code formatting.
    This means we add options only in the case of strict technical necessity.
    Find out more: https://prettier.io/docs/en/option-philosophy.html

Don't fill the form below manually! Let a program create a report for you:

1. Go to https://prettier.io/playground
2. Paste your code and set options
3. Press the "Report issue" button in the lower right

-->

**Prettier 2.8.4**
[Playground link](https://prettier.io/playground/#N4Igxg9gdgLgprEAuEcAeAHCAnGACSKAZ3wFdiBDAMzgGEI5sw48BePAHgBUAaAPgAUpJHnIBrKBADuUAJQiubPqLwUieLgB0oIHiAgYYAS2hFkoCtmzSACpYRmUFADZSKATzN6ARtgpgxOBgAZQoAWzgAGSMoOGQqFyI4Hz8AoOCMfxiAc2QYbFJk1DDvOAATMvLIiihs0gpsuAAxHDCKGGNa5BAKUhgIXRAACxgw5wB1IaN4IkzmYIdpowA3afdusCIvEBik3Bs-bLb4xKKAKyI0YJznOABFUgh4E+ckvUzsPe6Ydww4IjA2CMhkGGCBsHGRjKMCGyAAHAAGd7WJLjPwYbpg-6MZZxPQAR0e8AOBkcPSIAFpYuVyoNsHBCUZ6QcGsckAlXkUkmEjHkClybnAAIIdIHePpwGyMaKxF5vEBEQUPJ5xdmnPQwCjeSHQ2FIABMGr8RmcOXoYTZPWczkGpCSXC1jg58uWhQAklBKrBgoDgTAhZ7gj9bnK4ABfMNAA)

```sh
# Options (if any):
tabWidth: 4
semi: false
singleQuote: true
trailingComma: all
```

**Input:**

```ts
export const unsafeCoerce = <T,>(u: unknown): T => u as T
```

**Output:**

```ts
export const unsafeCoerce = <T>(u: unknown): T => u as T
```

**Expected behavior:**

I expect the trailing comma in the generic arguments list to remain because it is a requirement from the compiler.
![image](https://user-images.githubusercontent.com/362449/225954934-36172bbe-687f-4c0c-b040-0b6283c65b8c.png)



Issue discussion:
Hmmm, interesting. Do you know how to reproduce this issue in https://www.typescriptlang.org/play? This could well be a bug, I’m just trying to see the error somewhere. AFAIR, Prettier retains `<T,>` in `tsx` files to avoid confusion with `<JsxTags>`. We might need to apply the same rule to `mts` extension and maybe even some other ESM ones?
Hmm, looking around I can't... I don't know if you can set the file type to .mts in the TS Playground, because the issue does not happen with a `.ts` extension. Also, it doesn't seem to matter what any other settings concerning `"module": true` in the package.json or "module"/"moduleResolution" in the tsconfig. If it has a .mts extension it throws the error. I am surprised I hadn't found this issue mentioned much because .mts extensions have been supported since typescript 4.7.
@kachkaev TS doesn't introduce 'x' counterparts for .mts / .cts (they are just a kind of workaround), so it's impossible to detect JSX syntax by file extension. And TS team decided to make those files universal, with all existing .tsx restrictions.
mts/cts files don't actually support JSX syntax - instead TS just bans ambiguous syntax that would be disallowed if the file had JSX.

From the perspective of prettier - it should treat the file as a `.tsx` file.
I see. If Prettier formats all files (`.ts`, `.mts`, `.cts`) as `.tsx` (i.e. with `,`), what problems can this cause?
People will complain. 😄 
Maybe we should change this https://github.com/prettier/prettier/blob/78b0a6bb80b73553c1bfe5b003e51e7946086b35/src/language-js/print/type-parameters.js#L77-L80

to 

```diff
        getFunctionParameters(node).length === 1 &&
-       isTSXFile(options) &&
+       !isTsFile() &&
+       hasTrailingComma() &&
        !node[paramsKey][0].constraint &&
        path.parent.type === "ArrowFunctionExpression"
```
What if we replace `isTSXFile(options)` with `doesTsFileRequireTrailingCommasInGenerics(options)`? It will return `false` for `ts` but `true` for all others (`tsx`, `mts` and `cts`).
That's what I mean ` !isTsFile()`, but I want to do an additional check to make sure there was a trailing comma originally.
But maybe `<T,>` can write as `< T>` to omit comma? （I mean before format, user may do that, if possible.）
> but I want to do an additional check to make sure there was a trailing comma originally

It's a bit hard to exactly test this - but I believe that:
In a `tsx` file, `<T>() => {}` is a hard fatal TS parser error.
In a `mts`/`cts` file, the same code is a soft TS semantic error.

Which means if you check for a trailing comma and only insert it if it was there - you could format "broken" code to remain "broken". IDK if prettier specifically cares about that though.

> But maybe <T,> can write as < T> to omit comma?

You cannot - the TS parser ignores the leading space
> you could format "broken" code to remain "broken". 

I think we can accept that. Unless we can get an earlier error from parser. Is it possible to check such syntax in `@typescript-eslint/typescript-estree` if we pass the filename?
The main plan we had for our parser errors was really just to enforce that the AST doesn't violate the types we provide, and expose semantic errors where it is easy enough to do.

We hadn't thought about enforcing punctuator-level syntax with our errors.

Solution patch:
diff --git a/changelog_unreleased/typescript/14688.md b/changelog_unreleased/typescript/14688.md
new file mode 100644
index 000000000000..7dd93092ef84
--- /dev/null
+++ b/changelog_unreleased/typescript/14688.md
@@ -0,0 +1,15 @@
+#### Fix missing required comma in type parameters (#14688 by @fisker, @sosukesuzuki)
+
+Previously, we only print trailing comma when file extension is `.tsx`, turns out `.mts`, `.cts` files requires it to parse too.
+
+<!-- prettier-ignore -->
+```tsx
+// Input
+export const unsafeCoerce = <T,>(u: unknown): T => u as T
+
+// Prettier stable
+export const unsafeCoerce = <T>(u: unknown): T => u as T;
+
+// Prettier main
+export const unsafeCoerce = <T,>(u: unknown): T => u as T;
+```
diff --git a/scripts/build/config.js b/scripts/build/config.js
index 1cba3ead75b4..68897550642d 100644
--- a/scripts/build/config.js
+++ b/scripts/build/config.js
@@ -185,7 +185,7 @@ const pluginFiles = [
         process: (text) =>
           text.replace(
             'require("path")',
-            "{extname: file => file.split('.').pop()}"
+            '{extname: file => "." + file.split(".").pop()}'
           ),
       },
       {
diff --git a/src/language-js/parse/typescript.js b/src/language-js/parse/typescript.js
index 3784071d43d3..d4c9349efe7f 100644
--- a/src/language-js/parse/typescript.js
+++ b/src/language-js/parse/typescript.js
@@ -7,7 +7,7 @@ import postprocess from "./postprocess/index.js";
 import { throwErrorForInvalidNodes } from "./postprocess/typescript.js";
 
 /** @type {import("@typescript-eslint/typescript-estree").TSESTreeOptions} */
-const parseOptions = {
+const baseParseOptions = {
   // `jest@<=26.4.2` rely on `loc`
   // https://github.com/facebook/jest/issues/10444
   // Set `loc` and `range` to `true` also prevent AST traverse
@@ -15,7 +15,6 @@ const parseOptions = {
   loc: true,
   range: true,
   comment: true,
-  jsx: true,
   tokens: true,
   loggerFn: false,
   project: [],
@@ -37,18 +36,34 @@ function createParseError(error) {
   });
 }
 
-function parse(text) {
+// https://typescript-eslint.io/architecture/parser/#jsx
+const isKnownFileType = (filepath) =>
+  /\.(?:js|mjs|cjs|jsx|ts|mts|cts|tsx)$/i.test(filepath);
+
+function getParseOptionsCombinations(text, options) {
+  const filepath = options?.filepath;
+  if (filepath && isKnownFileType(filepath)) {
+    return [{ ...baseParseOptions, filePath: filepath }];
+  }
+
+  const shouldEnableJsx = isProbablyJsx(text);
+  return [
+    { ...baseParseOptions, jsx: shouldEnableJsx },
+    { ...baseParseOptions, jsx: !shouldEnableJsx },
+  ];
+}
+
+function parse(text, options) {
   const textToParse = replaceHashbang(text);
-  const jsx = isProbablyJsx(text);
+  const parseOptionsCombinations = getParseOptionsCombinations(text, options);
 
   let result;
   try {
-    result = tryCombinations([
-      // Try passing with our best guess first.
-      () => parseWithNodeMaps(textToParse, { ...parseOptions, jsx }),
-      // But if we get it wrong, try the opposite.
-      () => parseWithNodeMaps(textToParse, { ...parseOptions, jsx: !jsx }),
-    ]);
+    result = tryCombinations(
+      parseOptionsCombinations.map(
+        (parseOptions) => () => parseWithNodeMaps(textToParse, parseOptions)
+      )
+    );
   } catch ({
     errors: [
       // Suppose our guess is correct, throw the first error
diff --git a/src/language-js/print/type-parameters.js b/src/language-js/print/type-parameters.js
index c681f9a79f9b..b0dcad8c2f2e 100644
--- a/src/language-js/print/type-parameters.js
+++ b/src/language-js/print/type-parameters.js
@@ -12,7 +12,6 @@ import {
   isTestCall,
   hasComment,
   CommentCheckFlags,
-  isTSXFile,
   shouldPrintComma,
   getFunctionParameters,
   isObjectType,
@@ -27,6 +26,20 @@ import { printTypeScriptMappedTypeModifier } from "./mapped-type.js";
 
 const getTypeParametersGroupId = createGroupIdMapper("typeParameters");
 
+// Keep comma if the file extension not `.ts` and
+// has one type parameter that isn't extend with any types.
+// Because, otherwise formatted result will be invalid as tsx.
+function shouldForceTrailingComma(path, options, paramsKey) {
+  const { node } = path;
+  return (
+    getFunctionParameters(node).length === 1 &&
+    node.type.startsWith("TS") &&
+    !node[paramsKey][0].constraint &&
+    path.parent.type === "ArrowFunctionExpression" &&
+    !(options.filepath && /\.ts$/.test(options.filepath))
+  );
+}
+
 function printTypeParameters(path, options, print, paramsKey) {
   const { node } = path;
 
@@ -68,16 +81,10 @@ function printTypeParameters(path, options, print, paramsKey) {
     ];
   }
 
-  // Keep comma if the file extension is .tsx and
-  // has one type parameter that isn't extend with any types.
-  // Because, otherwise formatted result will be invalid as tsx.
   const trailingComma =
     node.type === "TSTypeParameterInstantiation" // https://github.com/microsoft/TypeScript/issues/21984
       ? ""
-      : getFunctionParameters(node).length === 1 &&
-        isTSXFile(options) &&
-        !node[paramsKey][0].constraint &&
-        path.parent.type === "ArrowFunctionExpression"
+      : shouldForceTrailingComma(path, options, paramsKey)
       ? ","
       : shouldPrintComma(options)
       ? ifBreak(",")
diff --git a/src/language-js/utils/index.js b/src/language-js/utils/index.js
index 4dc06fd12031..a75d6f8ced6f 100644
--- a/src/language-js/utils/index.js
+++ b/src/language-js/utils/index.js
@@ -730,10 +730,6 @@ function identity(x) {
   return x;
 }
 
-function isTSXFile(options) {
-  return options.filepath && /\.tsx$/i.test(options.filepath);
-}
-
 /**
  * @param {any} options
  * @param {("es5" | "all")} [level]
@@ -1151,7 +1147,6 @@ export {
   isStringPropSafeToUnquote,
   isTemplateOnItsOwnLine,
   isTestCall,
-  isTSXFile,
   isTypeAnnotationAFunction,
   isNextLineEmpty,
   needsHardlineAfterDanglingComment,


Test patch:
diff --git a/tests/format/misc/errors/typescript/__snapshots__/jsfmt.spec.js.snap b/tests/format/misc/errors/typescript/__snapshots__/jsfmt.spec.js.snap
index 09287ad25493..87806c395393 100644
--- a/tests/format/misc/errors/typescript/__snapshots__/jsfmt.spec.js.snap
+++ b/tests/format/misc/errors/typescript/__snapshots__/jsfmt.spec.js.snap
@@ -32,7 +32,7 @@ exports[`export-declare.ts [typescript] format 1`] = `
   2 |"
 `;
 
-exports[`invalid-jsx-1.ts [typescript] format 1`] = `
+exports[`invalid-jsx-1.tsx [typescript] format 1`] = `
 "Unexpected token. Did you mean \`{'>'}\` or \`&gt;\`? (3:45)
   1 | // https://www.typescriptlang.org/docs/handbook/release-notes/typescript-3-9.html#-and--are-now-invalid-jsx-text-characters
   2 |
diff --git a/tests/format/misc/errors/typescript/invalid-jsx-1.ts b/tests/format/misc/errors/typescript/invalid-jsx-1.tsx
similarity index 100%
rename from tests/format/misc/errors/typescript/invalid-jsx-1.ts
rename to tests/format/misc/errors/typescript/invalid-jsx-1.tsx
diff --git a/tests/format/typescript/comments/__snapshots__/jsfmt.spec.js.snap b/tests/format/typescript/comments/__snapshots__/jsfmt.spec.js.snap
index 855ae3735ccd..2874d77865fc 100644
--- a/tests/format/typescript/comments/__snapshots__/jsfmt.spec.js.snap
+++ b/tests/format/typescript/comments/__snapshots__/jsfmt.spec.js.snap
@@ -62,7 +62,7 @@ abstract class AbstractFoo {
 ================================================================================
 `;
 
-exports[`after_jsx_generic.ts format 1`] = `
+exports[`after_jsx_generic.tsx format 1`] = `
 ====================================options=====================================
 parsers: ["typescript"]
 printWidth: 80
@@ -293,7 +293,7 @@ export type AsyncExecuteOptions = child_process$execFileOpts & {
 ================================================================================
 `;
 
-exports[`jsx.ts format 1`] = `
+exports[`jsx.tsx format 1`] = `
 ====================================options=====================================
 parsers: ["typescript"]
 printWidth: 80
diff --git a/tests/format/typescript/comments/after_jsx_generic.ts b/tests/format/typescript/comments/after_jsx_generic.tsx
similarity index 100%
rename from tests/format/typescript/comments/after_jsx_generic.ts
rename to tests/format/typescript/comments/after_jsx_generic.tsx
diff --git a/tests/format/typescript/comments/jsx.ts b/tests/format/typescript/comments/jsx.tsx
similarity index 100%
rename from tests/format/typescript/comments/jsx.ts
rename to tests/format/typescript/comments/jsx.tsx
diff --git a/tests/format/typescript/last-argument-expansion/__snapshots__/jsfmt.spec.js.snap b/tests/format/typescript/last-argument-expansion/__snapshots__/jsfmt.spec.js.snap
index f08c7734746d..3b7f037b84b9 100644
--- a/tests/format/typescript/last-argument-expansion/__snapshots__/jsfmt.spec.js.snap
+++ b/tests/format/typescript/last-argument-expansion/__snapshots__/jsfmt.spec.js.snap
@@ -38,7 +38,7 @@ export default class AddAssetHtmlPlugin {
 ================================================================================
 `;
 
-exports[`decorated-function.ts format 1`] = `
+exports[`decorated-function.tsx format 1`] = `
 ====================================options=====================================
 parsers: ["typescript"]
 printWidth: 80
@@ -198,7 +198,7 @@ var listener = DOM.listen(
 ================================================================================
 `;
 
-exports[`forward-ref.ts format 1`] = `
+exports[`forward-ref.tsx format 1`] = `
 ====================================options=====================================
 parsers: ["typescript"]
 printWidth: 80
diff --git a/tests/format/typescript/last-argument-expansion/decorated-function.ts b/tests/format/typescript/last-argument-expansion/decorated-function.tsx
similarity index 100%
rename from tests/format/typescript/last-argument-expansion/decorated-function.ts
rename to tests/format/typescript/last-argument-expansion/decorated-function.tsx
diff --git a/tests/format/typescript/last-argument-expansion/forward-ref.ts b/tests/format/typescript/last-argument-expansion/forward-ref.tsx
similarity index 100%
rename from tests/format/typescript/last-argument-expansion/forward-ref.ts
rename to tests/format/typescript/last-argument-expansion/forward-ref.tsx
diff --git a/tests/format/typescript/tsx/comma/__snapshots__/jsfmt.spec.js.snap b/tests/format/typescript/tsx/comma/__snapshots__/jsfmt.spec.js.snap
new file mode 100644
index 000000000000..98a3127bf3df
--- /dev/null
+++ b/tests/format/typescript/tsx/comma/__snapshots__/jsfmt.spec.js.snap
@@ -0,0 +1,131 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`snippet: test.cjs format 1`] = `
+====================================options=====================================
+parsers: ["typescript"]
+printWidth: 80
+                                                                                | printWidth
+=====================================input======================================
+const A = <T,>() => {}
+=====================================output=====================================
+const A = <T,>() => {};
+
+================================================================================
+`;
+
+exports[`snippet: test.cts format 1`] = `
+====================================options=====================================
+parsers: ["typescript"]
+printWidth: 80
+                                                                                | printWidth
+=====================================input======================================
+const A = <T,>() => {}
+=====================================output=====================================
+const A = <T,>() => {};
+
+================================================================================
+`;
+
+exports[`snippet: test.js format 1`] = `
+====================================options=====================================
+parsers: ["typescript"]
+printWidth: 80
+                                                                                | printWidth
+=====================================input======================================
+const A = <T,>() => {}
+=====================================output=====================================
+const A = <T,>() => {};
+
+================================================================================
+`;
+
+exports[`snippet: test.jsx format 1`] = `
+====================================options=====================================
+parsers: ["typescript"]
+printWidth: 80
+                                                                                | printWidth
+=====================================input======================================
+const A = <T,>() => {}
+=====================================output=====================================
+const A = <T,>() => {};
+
+================================================================================
+`;
+
+exports[`snippet: test.mjs format 1`] = `
+====================================options=====================================
+parsers: ["typescript"]
+printWidth: 80
+                                                                                | printWidth
+=====================================input======================================
+const A = <T,>() => {}
+=====================================output=====================================
+const A = <T,>() => {};
+
+================================================================================
+`;
+
+exports[`snippet: test.mts format 1`] = `
+====================================options=====================================
+parsers: ["typescript"]
+printWidth: 80
+                                                                                | printWidth
+=====================================input======================================
+const A = <T,>() => {}
+=====================================output=====================================
+const A = <T,>() => {};
+
+================================================================================
+`;
+
+exports[`snippet: test.ts format 1`] = `
+====================================options=====================================
+parsers: ["typescript"]
+printWidth: 80
+                                                                                | printWidth
+=====================================input======================================
+const A = <T,>() => {}
+=====================================output=====================================
+const A = <T>() => {};
+
+================================================================================
+`;
+
+exports[`snippet: test.tsx format 1`] = `
+====================================options=====================================
+parsers: ["typescript"]
+printWidth: 80
+                                                                                | printWidth
+=====================================input======================================
+const A = <T,>() => {}
+=====================================output=====================================
+const A = <T,>() => {};
+
+================================================================================
+`;
+
+exports[`snippet: test.unknown format 1`] = `
+====================================options=====================================
+parsers: ["typescript"]
+printWidth: 80
+                                                                                | printWidth
+=====================================input======================================
+const A = <T,>() => {}
+=====================================output=====================================
+const A = <T,>() => {};
+
+================================================================================
+`;
+
+exports[`snippet: unnamed format 1`] = `
+====================================options=====================================
+parsers: ["typescript"]
+printWidth: 80
+                                                                                | printWidth
+=====================================input======================================
+const A = <T,>() => {}
+=====================================output=====================================
+const A = <T,>() => {};
+
+================================================================================
+`;
diff --git a/tests/format/typescript/tsx/comma/jsfmt.spec.js b/tests/format/typescript/tsx/comma/jsfmt.spec.js
new file mode 100644
index 000000000000..8e6ac14169d3
--- /dev/null
+++ b/tests/format/typescript/tsx/comma/jsfmt.spec.js
@@ -0,0 +1,20 @@
+const code = "const A = <T,>() => {}";
+
+run_spec(
+  {
+    importMeta: import.meta,
+    snippets: [
+      "test.js",
+      "test.cjs",
+      "test.mjs",
+      "test.ts",
+      "test.jsx",
+      "test.mts",
+      "test.cts",
+      "test.tsx",
+      "test.unknown",
+      undefined,
+    ].map((filename) => ({ code, filename, name: filename ?? "unnamed" })),
+  },
+  ["typescript"]
+);
diff --git a/tests/format/typescript/typeparams/print-width-120/__snapshots__/jsfmt.spec.js.snap b/tests/format/typescript/typeparams/print-width-120/__snapshots__/jsfmt.spec.js.snap
index 666c9bb037c4..f90f92763eeb 100644
--- a/tests/format/typescript/typeparams/print-width-120/__snapshots__/jsfmt.spec.js.snap
+++ b/tests/format/typescript/typeparams/print-width-120/__snapshots__/jsfmt.spec.js.snap
@@ -1,6 +1,6 @@
 // Jest Snapshot v1, https://goo.gl/fbAQLP
 
-exports[`issue-7542.ts - {"printWidth":120} format 1`] = `
+exports[`issue-7542.tsx - {"printWidth":120} format 1`] = `
 ====================================options=====================================
 parsers: ["typescript"]
 printWidth: 120
diff --git a/tests/format/typescript/typeparams/print-width-120/issue-7542.ts b/tests/format/typescript/typeparams/print-width-120/issue-7542.tsx
similarity index 100%
rename from tests/format/typescript/typeparams/print-width-120/issue-7542.ts
rename to tests/format/typescript/typeparams/print-width-120/issue-7542.tsx
diff --git a/tests/integration/__tests__/format.js b/tests/integration/__tests__/format.js
index 543d44d55e43..80342eb0b0e0 100644
--- a/tests/integration/__tests__/format.js
+++ b/tests/integration/__tests__/format.js
@@ -24,7 +24,7 @@ test("typescript parser should throw the first error when both JSX and non-JSX m
     label:
   `;
   await expect(
-    prettier.format(input, { parser: "typescript" })
+    prettier.format(input, { parser: "typescript", filepath: "foo.unknown" })
   ).rejects.toThrowErrorMatchingSnapshot();
 });