Repository: lingui/js-lingui (ts)

Issue:
Allow .ts extension for compiled messages file
**Is your feature request related to a problem? Please describe.**
I am always frustrated when I have to allow ts to import js messages (eslint) or when I have to reconfigure the ts config to serve .mjs files.

Having imported
```import { messages } from './locales/en/messages.mjs';```
I need to:
- in `*.mjs` case allow typescript to serve multiple extensions
- in `*.js` case disable import extensions check

**Describe proposed solution**
I would like to configure the extension like this:
```
.linguirc
{
 ...
 "compileNamespace": "es",
 "compileExtension": "ts",
}
```
**Describe alternatives you've considered**
There is always alternative to setup eslint but it does not sound like a big deal to have it in config.
I have also tried to rename the file to `*.ts` extension and it works like charm, (thank you @laszbalo). Example below.
```
    "lingui:compile": "lingui compile && for f in $(find ./src/locales/* -type f -name '*.mjs'); do mv \"$f\" \"${f%.mjs}.ts\"; done"
```

**Additional context**
I am using standard Create-React-App bundler with default settings, with airbnb and recommended eslint plugins.

Pull request:
feat: Lingui compileNamespace "ts"
Introduced namespace of "typescript", now --typescript and "compileNamespace": "ts", works in the same way :)

Will close #934 

Patch:
diff --git a/docs/guides/typescript.rst b/docs/guides/typescript.rst
index 1bd560b6..62e5a04a 100644
--- a/docs/guides/typescript.rst
+++ b/docs/guides/typescript.rst
@@ -53,13 +53,13 @@ In your ``webpack.config.js``, use both ``babel-loader`` and ``ts-loader`` for T
    To supply babel options for the extraction process use :conf:`extractBabelOptions` configuration option.
 
 
-:conf:`compileNamespace` must be set to ``es`` (ES6 default export) in the Lingui config
+:conf:`compileNamespace` must be set to ``ts`` (ES6 default export) in the Lingui config
 otherwise compiled catalogs can't be imported using ES ``import``, but rather CommonJS ``require``:
 
 .. code:: js
 
   {
-    "compileNamespace": "es"
+    "compileNamespace": "ts"
   }
 
 Type definitions
diff --git a/docs/ref/cli.rst b/docs/ref/cli.rst
index a0d359df..ca8a9002 100644
--- a/docs/ref/cli.rst
+++ b/docs/ref/cli.rst
@@ -139,3 +139,8 @@ Prints additional information.
 
 Specify namespace for compiled message catalogs (also see :conf:`compileNamespace` for
 global configuration).
+
+.. lingui-cli-option:: --typescript
+
+Is the same as using :conf:`compileNamespace` with the value "ts".
+Generates a {compiledFile}.d.ts and the compiled file is generated using the extension .ts
diff --git a/docs/ref/conf.rst b/docs/ref/conf.rst
index 360a3a95..3a7ab454 100644
--- a/docs/ref/conf.rst
+++ b/docs/ref/conf.rst
@@ -199,6 +199,20 @@ Use ES6 named export:
 
    /* eslint-disable */export const messages = {"..."}
 
+ts
+^^
+
+Use ES6 named export + .ts file with an additional {compiledFile}.d.ts file:
+
+.. code-block:: js
+
+   /* eslint-disable */export const messages = {"..."}
+
+.. code-block:: js
+      import { Messages } from '@lingui/core';
+      declare const messages: Messages;
+      export { messages };
+
 (window|global)\.(.*)
 ^^^^^^^^^^^^^^^^^^^^^
 
diff --git a/packages/cli/src/api/catalog.ts b/packages/cli/src/api/catalog.ts
index 3d826db8..0ecf0710 100644
--- a/packages/cli/src/api/catalog.ts
+++ b/packages/cli/src/api/catalog.ts
@@ -14,6 +14,7 @@ import extract from "./extractors"
 import { prettyOrigin, removeDirectory } from "./utils"
 import { CliExtractOptions } from "../lingui-extract"
 import { CliExtractTemplateOptions } from "../lingui-extract-template"
+import { CompiledCatalogNamespace } from "./compile"
 
 const NAME = "{name}"
 const LOCALE = "{locale}"
@@ -343,9 +344,17 @@ export class Catalog {
     poFormat.write(filename, messages, options)
   }
 
-  writeCompiled(locale: string, compiledCatalog: string, namespace?: string) {
-    const ext = `.${namespace === "es" ? "mjs" : "js"}`
-    const filename = this.path.replace(LOCALE, locale) + ext
+  writeCompiled(locale: string, compiledCatalog: string, namespace?: CompiledCatalogNamespace) {
+    let ext;
+    if (namespace === "es") {
+      ext = "mjs"
+    } else if (namespace === "ts") {
+      ext = "ts"
+    } else {
+      ext = "js"
+    }
+
+    const filename = `${this.path.replace(LOCALE, locale)}.${ext}`
 
     const basedir = path.dirname(filename)
     if (!fs.existsSync(basedir)) {
diff --git a/packages/cli/src/api/compile.ts b/packages/cli/src/api/compile.ts
index a4babd72..fdbdf196 100644
--- a/packages/cli/src/api/compile.ts
+++ b/packages/cli/src/api/compile.ts
@@ -5,7 +5,7 @@ import * as R from "ramda"
 
 import pseudoLocalize from "./pseudoLocalize"
 
-type CompiledCatalogNamespace = "cjs" | "es" | string
+export type CompiledCatalogNamespace = "cjs" | "es" | "ts" | string
 
 type CompiledCatalogType = {
   [msgId: string]: string
@@ -53,7 +53,7 @@ export function createCompiledCatalog(
 }
 
 function buildExportStatement(expression, namespace: CompiledCatalogNamespace) {
-  if (namespace === "es") {
+  if (namespace === "es" || namespace === "ts") {
     // export const messages = { message: "Translation" }
     return t.exportNamedDeclaration(
       t.variableDeclaration("const", [
diff --git a/packages/cli/src/lingui-compile.ts b/packages/cli/src/lingui-compile.ts
index 27057750..79c1ea64 100644
--- a/packages/cli/src/lingui-compile.ts
+++ b/packages/cli/src/lingui-compile.ts
@@ -82,7 +82,7 @@ function command(config: LinguiConfig, options) {
       if (doMerge) {
         mergedCatalogs = { ...mergedCatalogs, ...messages }
       } else {
-        const namespace = options.namespace || config.compileNamespace
+        const namespace = options.typescript ? "ts" : options.namespace || config.compileNamespace
         const compiledCatalog = createCompiledCatalog(locale, messages, {
           strict: false,
           namespace,
@@ -97,7 +97,7 @@ function command(config: LinguiConfig, options) {
         )
 
         if (options.typescript) {
-          const typescriptPath = compiledPath.replace(/\.jsx?$/, "") + ".d.ts"
+          const typescriptPath = compiledPath.replace(/\.ts?$/, "") + ".d.ts"
           fs.writeFileSync(
             typescriptPath,
             `import { Messages } from '@lingui/core';
@@ -156,7 +156,7 @@ if (require.main === module) {
   const results = command(config, {
     verbose: program.verbose || false,
     allowEmpty: !program.strict,
-    typescript: program.typescript || false,
+    typescript: program.typescript || config.compileNamespace === "ts" || false,
     namespace: program.namespace, // we want this to be undefined if user does not specify so default can be used
   })
 
diff --git a/packages/conf/index.d.ts b/packages/conf/index.d.ts
index 0b49e511..3d8b266d 100644
--- a/packages/conf/index.d.ts
+++ b/packages/conf/index.d.ts
@@ -24,7 +24,7 @@ export declare type FallbackLocales = LocaleObject | DefaultLocaleObject
 
 export declare type LinguiConfig = {
     catalogs: CatalogConfig[];
-    compileNamespace: string;
+    compileNamespace: "es" | "cjs" | "ts" | string;
     extractBabelOptions: Record<string, unknown>;
     compilerBabelOptions: GeneratorOptions;
     fallbackLocales: FallbackLocales;
@@ -56,7 +56,7 @@ export declare const configValidation: {
         };
         compilerBabelOptions: GeneratorOptions;
         catalogs: CatalogConfig[];
-        compileNamespace: string;
+        compileNamespace: "es" | "ts" | "cjs" | string;
         fallbackLocales: FallbackLocales;
         format: CatalogFormat;
         formatOptions: CatalogFormatOptions;
diff --git a/packages/conf/src/index.ts b/packages/conf/src/index.ts
index 29af1271..a7a0e941 100644
--- a/packages/conf/src/index.ts
+++ b/packages/conf/src/index.ts
@@ -33,7 +33,7 @@ type ModuleSource = [string, string?]
 
 export type LinguiConfig = {
   catalogs: CatalogConfig[]
-  compileNamespace: string
+  compileNamespace: "es" | "ts" | "cjs" | string
   extractBabelOptions: Record<string, unknown>
   compilerBabelOptions: GeneratorOptions
   fallbackLocales?: FallbackLocales


Test patch:
diff --git a/packages/cli/src/api/__snapshots__/compile.test.ts.snap b/packages/cli/src/api/__snapshots__/compile.test.ts.snap
index 5ad75d26..1a47502a 100644
--- a/packages/cli/src/api/__snapshots__/compile.test.ts.snap
+++ b/packages/cli/src/api/__snapshots__/compile.test.ts.snap
@@ -10,6 +10,8 @@ exports[`createCompiledCatalog options.namespace should compile with es 1`] = `/
 
 exports[`createCompiledCatalog options.namespace should compile with global 1`] = `/*eslint-disable*/global.test={messages:{}};`;
 
+exports[`createCompiledCatalog options.namespace should compile with ts 1`] = `/*eslint-disable*/export const messages={};`;
+
 exports[`createCompiledCatalog options.namespace should compile with window 1`] = `/*eslint-disable*/window.test={messages:{}};`;
 
 exports[`createCompiledCatalog options.namespace should error with invalid value 1`] = `Invalid namespace param: "global"`;
diff --git a/packages/cli/src/api/catalog.test.ts b/packages/cli/src/api/catalog.test.ts
index 48ff884c..88547feb 100644
--- a/packages/cli/src/api/catalog.test.ts
+++ b/packages/cli/src/api/catalog.test.ts
@@ -969,6 +969,25 @@ describe("writeCompiled", function () {
     )
   })
 
+  it("saves TS modules to .ts files", function () {
+    const localeDir = copyFixture(fixture("locales", "initial/"))
+    const catalog = new Catalog(
+      {
+        name: "messages",
+        path: path.join(localeDir, "{locale}", "messages"),
+        include: [],
+        exclude: [],
+      },
+      mockConfig()
+    )
+
+    const namespace = "ts"
+    const compiledCatalog = createCompiledCatalog("en", {}, { namespace })
+    expect(catalog.writeCompiled("en", compiledCatalog, namespace)).toMatch(
+      /\.ts$/
+    )
+  })
+
   it("saves anything else than ES modules to .js files", function () {
     const localeDir = copyFixture(fixture("locales", "initial/"))
     const catalog = new Catalog(
diff --git a/packages/cli/src/api/compile.test.ts b/packages/cli/src/api/compile.test.ts
index 6dac4faf..ffa02386 100644
--- a/packages/cli/src/api/compile.test.ts
+++ b/packages/cli/src/api/compile.test.ts
@@ -89,6 +89,10 @@ describe("createCompiledCatalog", () => {
       expect(getCompiledCatalog("es")).toMatchSnapshot()
     })
 
+    it("should compile with ts", () => {
+      expect(getCompiledCatalog("ts")).toMatchSnapshot()
+    })
+
     it("should compile with window", () => {
       expect(getCompiledCatalog("window.test")).toMatchSnapshot()
     })