Repository: lingui/js-lingui
Pull request #1321 (2023-01-04 11:20:27)

refactor(macro): make code strongly typed
No logic changes. Added types wherever possible and fix issues. Also added few more tests for "black zones". 

vercel[bot]: **The latest updates on your projects**. Learn more about [Vercel for Git ↗︎](https://vercel.link/github-learn-more)

| Name | Status | Preview | Updated |
| :--- | :----- | :------ | :------ |
| **js-lingui** | 🔄 Building ([Inspect](https://vercel.com/lingui/js-lingui/6reMTmyhg2JETADHvuJ535hXr8jQ)) |  | Jan 4, 2023 at 11:20AM (UTC) |


thekip: @andrii-bodnar FYI
codecov[bot]: # [Codecov](https://codecov.io/gh/lingui/js-lingui/pull/1321?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=lingui) Report
Base: **82.52**% // Head: **82.88**% // Increases project coverage by **`+0.35%`** :tada:
> Coverage data is based on head [(`d9b9268`)](https://codecov.io/gh/lingui/js-lingui/pull/1321?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=lingui) compared to base [(`7c85cb7`)](https://codecov.io/gh/lingui/js-lingui/commit/7c85cb70b3b791951a9239c037aa73bb890c26e6?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=lingui).
> Patch coverage: 91.13% of modified lines in pull request are covered.

<details><summary>Additional details and impacted files</summary>


```diff
@@            Coverage Diff             @@
##             main    #1321      +/-   ##
==========================================
+ Coverage   82.52%   82.88%   +0.35%     
==========================================
  Files          56       66      +10     
  Lines        1785     1805      +20     
  Branches      500      505       +5     
==========================================
+ Hits         1473     1496      +23     
+ Misses        184      179       -5     
- Partials      128      130       +2     
```


| [Impacted Files](https://codecov.io/gh/lingui/js-lingui/pull/1321?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=lingui) | Coverage Δ | |
|---|---|---|
| [packages/macro/src/utils.ts](https://codecov.io/gh/lingui/js-lingui/pull/1321/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=lingui#diff-cGFja2FnZXMvbWFjcm8vc3JjL3V0aWxzLnRz) | `100.00% <ø> (ø)` | |
| [packages/cli/src/api/catalog.ts](https://codecov.io/gh/lingui/js-lingui/pull/1321/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=lingui#diff-cGFja2FnZXMvY2xpL3NyYy9hcGkvY2F0YWxvZy50cw==) | `84.95% <75.00%> (-0.44%)` | :arrow_down: |
| [packages/cli/src/api/extractors/index.ts](https://codecov.io/gh/lingui/js-lingui/pull/1321/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=lingui#diff-cGFja2FnZXMvY2xpL3NyYy9hcGkvZXh0cmFjdG9ycy9pbmRleC50cw==) | `57.89% <75.00%> (ø)` | |
| [...ackages/babel-plugin-extract-messages/src/index.ts](https://codecov.io/gh/lingui/js-lingui/pull/1321/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=lingui#diff-cGFja2FnZXMvYmFiZWwtcGx1Z2luLWV4dHJhY3QtbWVzc2FnZXMvc3JjL2luZGV4LnRz) | `80.83% <83.33%> (+1.46%)` | :arrow_up: |
| [packages/macro/src/macroJsx.ts](https://codecov.io/gh/lingui/js-lingui/pull/1321/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=lingui#diff-cGFja2FnZXMvbWFjcm8vc3JjL21hY3JvSnN4LnRz) | `88.96% <84.84%> (-2.34%)` | :arrow_down: |
| [packages/macro/src/icu.ts](https://codecov.io/gh/lingui/js-lingui/pull/1321/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=lingui#diff-cGFja2FnZXMvbWFjcm8vc3JjL2ljdS50cw==) | `94.87% <94.59%> (-0.26%)` | :arrow_down: |
| [packages/macro/src/macroJs.ts](https://codecov.io/gh/lingui/js-lingui/pull/1321/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=lingui#diff-cGFja2FnZXMvbWFjcm8vc3JjL21hY3JvSnMudHM=) | `97.87% <97.61%> (+5.20%)` | :arrow_up: |
| [packages/macro/src/index.ts](https://codecov.io/gh/lingui/js-lingui/pull/1321/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=lingui#diff-cGFja2FnZXMvbWFjcm8vc3JjL2luZGV4LnRz) | `83.60% <100.00%> (ø)` | |
| [packages/macro/test/js-arg.ts](https://codecov.io/gh/lingui/js-lingui/pull/1321/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=lingui#diff-cGFja2FnZXMvbWFjcm8vdGVzdC9qcy1hcmcudHM=) | `100.00% <100.00%> (ø)` | |
| [packages/macro/test/js-defineMessage.ts](https://codecov.io/gh/lingui/js-lingui/pull/1321/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=lingui#diff-cGFja2FnZXMvbWFjcm8vdGVzdC9qcy1kZWZpbmVNZXNzYWdlLnRz) | `100.00% <100.00%> (ø)` | |
| ... and [9 more](https://codecov.io/gh/lingui/js-lingui/pull/1321/diff?src=pr&el=tree-more&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=lingui) | |

Help us with your feedback. Take ten seconds to tell us [how you rate us](https://about.codecov.io/nps?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=lingui). Have a feature suggestion? [Share it here.](https://app.codecov.io/gh/feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=lingui)

</details>

[:umbrella: View full report at Codecov](https://codecov.io/gh/lingui/js-lingui/pull/1321?src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=lingui).   
:loudspeaker: Do you have feedback about the report comment? [Let us know in this issue](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=lingui).

github-actions[bot]: ## size-limit report 📦 
| Path                                                               | Size          |
| ------------------------------------------------------------------ | ------------- |
| ./packages/core/build/cjs/core.production.min.js                   | 2.9 KB (0%)   |
| ./packages/detect-locale/build/cjs/detect-locale.production.min.js | 798 B (0%)    |
| ./packages/react/build/cjs/react.production.min.js                 | 4.91 KB (0%)  |
| ./packages/remote-loader/build/cjs/remote-loader.production.min.js | 92.08 KB (0%) |
thekip: messed up things with rebase, looking why commits from main is showed as my
thekip: Done
andrii-bodnar: @thekip @Martin005 is this PR ready to merge?
thekip: Nothing to add from my side

Patch:
diff --git a/packages/macro/package.json b/packages/macro/package.json
index 68a4e5c5..c332c9f3 100644
--- a/packages/macro/package.json
+++ b/packages/macro/package.json
@@ -36,6 +36,9 @@
   "peerDependencies": {
     "@lingui/core": "^3.13.0",
     "@lingui/react": "^3.13.0",
-    "babel-plugin-macros": "2 || 3"
+    "babel-plugin-macros": "2 || 3"
+  },
+  "devDependencies": {
+    "@types/babel-plugin-macros": "^2.8.5"
   }
 }
diff --git a/packages/macro/src/icu.ts b/packages/macro/src/icu.ts
index 04f478d3..5ab1e065 100644
--- a/packages/macro/src/icu.ts
+++ b/packages/macro/src/icu.ts
@@ -1,112 +1,146 @@
+import {Expression, isJSXEmptyExpression, JSXElement, Node} from "@babel/types"
+
 const metaOptions = ["id", "comment", "props"]
 
 const escapedMetaOptionsRe = new RegExp(`^_(${metaOptions.join("|")})$`)
 
-export default function ICUMessageFormat() {}
-
-ICUMessageFormat.prototype.fromTokens = function (tokens) {
-  return (Array.isArray(tokens) ? tokens : [tokens])
-    .map((token) => this.processToken(token))
-    .filter(Boolean)
-    .reduce(
-      (props, message) => ({
-        ...message,
-        message: props.message + message.message,
-        values: { ...props.values, ...message.values },
-        jsxElements: { ...props.jsxElements, ...message.jsxElements },
-      }),
-      {
-        message: "",
-        values: {},
-        jsxElements: {},
-      }
-    )
+export type ParsedResult = {
+  message: string,
+  values?: Record<string, Expression>,
+  jsxElements?:  Record<string, JSXElement>,
 }
 
-ICUMessageFormat.prototype.processToken = function (token) {
-  const jsxElements = {}
+export type TextToken = {
+  type: "text",
+  value: string;
+}
+export type ArgToken = {
+  type: "arg",
+  value: Expression;
+  name?: string;
 
-  if (token.type === "text") {
-    return {
-      message: token.value,
-    }
-  } else if (token.type === "arg") {
-    if (token.value !== undefined && token.value.type === 'JSXEmptyExpression') {
-      return null;
-    }
-    const values =
-      token.value !== undefined
-        ? {
-            [token.name]: token.value,
-          }
-        : {}
+  /**
+   * plural
+   * select
+   * selectordinal
+   */
+  format?: string,
+  options?: {
+    offset: string,
+    [icuChoice: string]: string | Tokens,
+  },
+}
+export type ElementToken = {
+  type: "element",
+  value: JSXElement;
+  name?: string | number;
+  children?: Token[],
+}
+export type Tokens = Token | Token[];
+export type Token = TextToken | ArgToken | ElementToken
 
-    switch (token.format) {
-      case "plural":
-      case "select":
-      case "selectordinal":
-        const formatOptions = Object.keys(token.options)
-          .filter((key) => token.options[key] != null)
-          .map((key) => {
-            let value = token.options[key]
-            key = key.replace(escapedMetaOptionsRe, "$1")
-
-            if (key === "offset") {
-              // offset has special syntax `offset:number`
-              return `offset:${value}`
+export default class ICUMessageFormat {
+  public fromTokens(tokens: Tokens): ParsedResult {
+    return (Array.isArray(tokens) ? tokens : [tokens])
+        .map((token) => this.processToken(token))
+        .filter(Boolean)
+        .reduce(
+            (props, message) => ({
+              ...message,
+              message: props.message + message.message,
+              values: { ...props.values, ...message.values },
+              jsxElements: { ...props.jsxElements, ...message.jsxElements },
+            }),
+            {
+              message: "",
+              values: {},
+              jsxElements: {},
             }
+        )
+  }
 
-            if (typeof value !== "string") {
-              // process tokens from nested formatters
-              const {
-                message,
-                values: childValues,
-                jsxElements: childJsxElements,
-              } = this.fromTokens(value)
-
-              Object.assign(values, childValues)
-              Object.assign(jsxElements, childJsxElements)
-              value = message
-            }
+  public processToken(token: Token): ParsedResult {
+    const jsxElements: ParsedResult['jsxElements'] = {}
 
-            return `${key} {${value}}`
-          })
-          .join(" ")
-
-        return {
-          message: `{${token.name}, ${token.format}, ${formatOptions}}`,
-          values,
-          jsxElements,
-        }
-      default:
-        return {
-          message: `{${token.name}}`,
-          values,
-        }
-    }
-  } else if (token.type === "element") {
-    let message = ""
-    let elementValues = {}
-    Object.assign(jsxElements, { [token.name]: token.value })
-    token.children.forEach((child) => {
-      const {
-        message: childMessage,
-        values: childValues,
-        jsxElements: childJsxElements,
-      } = this.fromTokens(child)
-
-      message += childMessage
-      Object.assign(elementValues, childValues)
-      Object.assign(jsxElements, childJsxElements)
-    })
-    return {
-      message: token.children.length
-        ? `<${token.name}>${message}</${token.name}>`
-        : `<${token.name}/>`,
-      values: elementValues,
-      jsxElements,
+    if (token.type === "text") {
+      return {
+        message: token.value as string,
+      }
+    } else if (token.type === "arg") {
+      if (token.value !== undefined && isJSXEmptyExpression(token.value as Node)) {
+        return null;
+      }
+      const values = token.value !== undefined
+        ? { [token.name]: token.value }
+        : {}
+
+      switch (token.format) {
+        case "plural":
+        case "select":
+        case "selectordinal":
+          const formatOptions = Object.keys(token.options)
+              .filter((key) => token.options[key] != null)
+              .map((key) => {
+                let value = token.options[key]
+                key = key.replace(escapedMetaOptionsRe, "$1")
+
+                if (key === "offset") {
+                  // offset has special syntax `offset:number`
+                  return `offset:${value}`
+                }
+
+                if (typeof value !== "string") {
+                  // process tokens from nested formatters
+                  const {
+                    message,
+                    values: childValues,
+                    jsxElements: childJsxElements,
+                  } = this.fromTokens(value)
+
+                  Object.assign(values, childValues)
+                  Object.assign(jsxElements, childJsxElements)
+                  value = message
+                }
+
+                return `${key} {${value}}`
+              })
+              .join(" ")
+
+          return {
+            message: `{${token.name}, ${token.format}, ${formatOptions}}`,
+            values,
+            jsxElements,
+          }
+        default:
+          return {
+            message: `{${token.name}}`,
+            values,
+          }
+      }
+    } else if (token.type === "element") {
+      let message = ""
+      let elementValues: ParsedResult['values'] = {}
+      Object.assign(jsxElements, { [token.name]: token.value })
+      token.children.forEach((child) => {
+        const {
+          message: childMessage,
+          values: childValues,
+          jsxElements: childJsxElements,
+        } = this.fromTokens(child)
+
+        message += childMessage
+        Object.assign(elementValues, childValues)
+        Object.assign(jsxElements, childJsxElements)
+      })
+      return {
+        message: token.children.length
+            ? `<${token.name}>${message}</${token.name}>`
+            : `<${token.name}/>`,
+        values: elementValues,
+        jsxElements,
+      }
     }
-  }
 
-  throw new Error(`Unknown token type ${token.type}`)
+    throw new Error(`Unknown token type ${(token as any).type}`)
+  }
 }
diff --git a/packages/macro/src/index.ts b/packages/macro/src/index.ts
index 55acefbd..e9726fbd 100644
--- a/packages/macro/src/index.ts
+++ b/packages/macro/src/index.ts
@@ -1,8 +1,10 @@
-import { createMacro } from "babel-plugin-macros"
+import {createMacro, MacroParams} from "babel-plugin-macros"
 import { getConfig } from "@lingui/conf"
 
 import MacroJS from "./macroJs"
 import MacroJSX from "./macroJsx"
+import {NodePath} from "@babel/traverse"
+import {ImportDeclaration, isImportSpecifier, isIdentifier} from "@babel/types"
 
 const config = getConfig({ configPath: process.env.LINGUI_CONFIG })
 
@@ -25,13 +27,13 @@ const getSymbolSource = (name: 'i18n' | 'Trans'): [source: string, identifier?:
 const [i18nImportModule, i18nImportName = "i18n"] = getSymbolSource("i18n")
 const [TransImportModule, TransImportName = "Trans"] = getSymbolSource("Trans")
 
-function macro({ references, state, babel }) {
-  const jsxNodes = []
-  const jsNodes = []
+function macro({ references, state, babel }: MacroParams) {
+  const jsxNodes: NodePath[] = []
+  const jsNodes: NodePath[] = []
   let needsI18nImport = false
 
   const alreadyVisitedCache = new WeakSet()
-  const alreadyVisited = (path) => {
+  const alreadyVisited = (path: NodePath) => {
     if (alreadyVisitedCache.has(path)) {
       return true
     } else {
@@ -86,7 +88,7 @@ function macro({ references, state, babel }) {
   }
 }
 
-function addImport(babel, state, module, importName) {
+function addImport(babel: MacroParams["babel"], state: MacroParams["state"], module: string, importName: string) {
   const { types: t } = babel
 
   const linguiImport = state.file.path.node.body.find(
@@ -95,7 +97,7 @@ function addImport(babel, state, module, importName) {
       importNode.source.value === module &&
       // https://github.com/lingui/js-lingui/issues/777
       importNode.importKind !== "type"
-  )
+  ) as ImportDeclaration
 
   const tIdentifier = t.identifier(importName)
   // Handle adding the import or altering the existing import
@@ -103,7 +105,7 @@ function addImport(babel, state, module, importName) {
     if (
       linguiImport.specifiers.findIndex(
         (specifier) =>
-          specifier.imported && specifier.imported.name === importName
+          isImportSpecifier(specifier) && isIdentifier(specifier.imported, {name: importName})
       ) === -1
     ) {
       linguiImport.specifiers.push(t.importSpecifier(tIdentifier, tIdentifier))
@@ -118,9 +120,9 @@ function addImport(babel, state, module, importName) {
   }
 }
 
-function isRootPath(allPath) {
-  return (node) =>
-    (function traverse(path) {
+function isRootPath(allPath: NodePath[]) {
+  return (node: NodePath) =>
+    (function traverse(path): boolean {
       if (!path.parentPath) {
         return true
       } else {
@@ -129,7 +131,7 @@ function isRootPath(allPath) {
     })(node)
 }
 
-function getMacroType(tagName) {
+function getMacroType(tagName: string): string {
   switch (tagName) {
     case "defineMessage":
     case "arg":
diff --git a/packages/macro/src/macroJs.ts b/packages/macro/src/macroJs.ts
index 189bdbd0..18cd9b12 100644
--- a/packages/macro/src/macroJs.ts
+++ b/packages/macro/src/macroJs.ts
@@ -1,15 +1,16 @@
 import * as R from "ramda"
 import * as babelTypes from "@babel/types"
+import {Expression, Node, CallExpression, ObjectExpression, isObjectProperty, ObjectProperty, Identifier, StringLiteral} from "@babel/types"
 import { NodePath } from "@babel/traverse"
 
-import ICUMessageFormat from "./icu"
+import ICUMessageFormat, {ArgToken, ParsedResult, TextToken, Tokens} from "./icu"
 import { zip, makeCounter } from "./utils"
 import { COMMENT, ID, MESSAGE, EXTRACT_MARK } from "./constants"
 
 const keepSpaceRe = /(?:\\(?:\r\n|\r|\n))+\s+/g
 const keepNewLineRe = /(?:\r\n|\r|\n)+\s+/g
 
-function normalizeWhitespace(text) {
+function normalizeWhitespace(text: string): string {
   return text.replace(keepSpaceRe, " ").replace(keepNewLineRe, "\n").trim()
 }
 
@@ -21,48 +22,26 @@ export default class MacroJs {
   i18nImportName: string
 
   // Positional expressions counter (e.g. for placeholders `Hello {0}, today is {1}`)
-  _expressionIndex: () => Number
+  _expressionIndex = makeCounter()
 
-  constructor({ types }, { i18nImportName }) {
+  constructor({ types }: {types: typeof babelTypes}, { i18nImportName }: { i18nImportName: string }) {
     this.types = types
     this.i18nImportName = i18nImportName
-    this._expressionIndex = makeCounter()
   }
 
   replacePathWithMessage = (
     path: NodePath,
-    { id, message, values, comment },
+    {message, values}: {message: ParsedResult['message'], values: ParsedResult['values']},
     linguiInstance?: babelTypes.Identifier
   ) => {
     const args = []
-    const options = []
 
-    const messageNode = isString(message)
+    args.push(isString(message)
       ? this.types.stringLiteral(message)
-      : message
-
-    if (id) {
-      args.push(this.types.stringLiteral(id))
-
-      if (process.env.NODE_ENV !== "production") {
-        options.push(
-          this.types.objectProperty(this.types.identifier(MESSAGE), messageNode)
-        )
-      }
-    } else {
-      args.push(messageNode)
-    }
+      : message)
 
-    if (comment) {
-      options.push(
-        this.types.objectProperty(
-          this.types.identifier(COMMENT),
-          this.types.stringLiteral(comment)
-        )
-      )
-    }
 
-    if (Object.keys(values).length || options.length) {
+    if (Object.keys(values).length) {
       const valuesObject = Object.keys(values).map((key) =>
         this.types.objectProperty(this.types.identifier(key), values[key])
       )
@@ -70,10 +49,6 @@ export default class MacroJs {
       args.push(this.types.objectExpression(valuesObject))
     }
 
-    if (options.length) {
-      args.push(this.types.objectExpression(options))
-    }
-
     const newNode = this.types.callExpression(
       this.types.memberExpression(
         linguiInstance ?? this.types.identifier(this.i18nImportName),
@@ -86,7 +61,6 @@ export default class MacroJs {
     newNode.loc = path.node.loc
 
     path.addComment("leading", EXTRACT_MARK)
-    // @ts-ignore
     path.replaceWith(newNode)
   }
 
@@ -96,7 +70,7 @@ export default class MacroJs {
     this._expressionIndex = makeCounter()
 
     if (this.isDefineMessage(path.node)) {
-      this.replaceDefineMessage(path)
+      this.replaceDefineMessage(path as NodePath<CallExpression>)
       return true
     }
 
@@ -115,14 +89,12 @@ export default class MacroJs {
       const {
         message: messageRaw,
         values,
-        id,
-        comment,
       } = messageFormat.fromTokens(tokens)
       const message = normalizeWhitespace(messageRaw)
 
       this.replacePathWithMessage(
         path.parentPath,
-        { id, message, values, comment },
+        { message, values },
         i18nInstance
       )
       return false
@@ -136,7 +108,7 @@ export default class MacroJs {
       this.isIdentifier(path.node.callee, "t")
     ) {
       const i18nInstance = path.node.arguments[0]
-      this.replaceTAsFunction(path.parentPath, i18nInstance)
+      this.replaceTAsFunction(path.parentPath as NodePath<CallExpression>, i18nInstance)
       return false
     }
 
@@ -144,7 +116,7 @@ export default class MacroJs {
       this.types.isCallExpression(path.node) &&
       this.isIdentifier(path.node.callee, "t")
     ) {
-      this.replaceTAsFunction(path)
+      this.replaceTAsFunction(path as NodePath<CallExpression>)
       return true
     }
 
@@ -154,12 +126,10 @@ export default class MacroJs {
     const {
       message: messageRaw,
       values,
-      id,
-      comment,
     } = messageFormat.fromTokens(tokens)
     const message = normalizeWhitespace(messageRaw)
 
-    this.replacePathWithMessage(path, { id, message, values, comment })
+    this.replacePathWithMessage(path, { message, values })
 
     return true
   }
@@ -185,7 +155,7 @@ export default class MacroJs {
    * }
    *
    */
-  replaceDefineMessage = (path) => {
+  replaceDefineMessage = (path: NodePath<CallExpression>) => {
     // reset the expression counter
     this._expressionIndex = makeCounter()
 
@@ -197,7 +167,7 @@ export default class MacroJs {
    * macro `t` is called with MessageDescriptor, after that
    * we create a new node to append it to i18n._
    */
-  replaceTAsFunction = (path, linguiInstance?: babelTypes.Identifier) => {
+  replaceTAsFunction = (path: NodePath<CallExpression>, linguiInstance?: babelTypes.Identifier) => {
     const descriptor = this.processDescriptor(path.node.arguments[0])
     const newNode = this.types.callExpression(
       this.types.memberExpression(
@@ -210,7 +180,7 @@ export default class MacroJs {
   }
 
   /**
-   * `processDescriptor` expand macros inside messsage descriptor.
+   * `processDescriptor` expand macros inside message descriptor.
    * Message descriptor is used in `defineMessage`.
    *
    * {
@@ -226,17 +196,19 @@ export default class MacroJs {
    * }
    *
    */
-  processDescriptor = (descriptor) => {
+  processDescriptor = (descriptor_: Node) => {
+    const descriptor = descriptor_ as ObjectExpression;
+
     this.types.addComment(descriptor, "leading", EXTRACT_MARK)
     const messageIndex = descriptor.properties.findIndex(
-      (property) => property.key.name === MESSAGE
+      (property) => isObjectProperty(property) && this.isIdentifier(property.key, MESSAGE)
     )
     if (messageIndex === -1) {
       return descriptor
     }
 
     // if there's `message` property, replace macros with formatted message
-    const node = descriptor.properties[messageIndex]
+    const node = (descriptor.properties[messageIndex]) as ObjectProperty;
 
     // Inside message descriptor the `t` macro in `message` prop is optional.
     // Template strings are always processed as if they were wrapped by `t`.
@@ -257,7 +229,7 @@ export default class MacroJs {
     // Don't override custom ID
     const hasId =
       descriptor.properties.findIndex(
-        (property) => property.key.name === ID
+        (property) => isObjectProperty(property) && this.isIdentifier(property.key, ID)
       ) !== -1
 
     descriptor.properties[messageIndex] = this.types.objectProperty(
@@ -268,7 +240,7 @@ export default class MacroJs {
     return descriptor
   }
 
-  addValues = (obj, values) => {
+  addValues = (obj: ObjectExpression['properties'], values: ParsedResult["values"]) => {
     const valuesObject = Object.keys(values).map((key) =>
       this.types.objectProperty(this.types.identifier(key), values[key])
     )
@@ -283,13 +255,13 @@ export default class MacroJs {
     )
   }
 
-  tokenizeNode = (node, ignoreExpression = false) => {
+  tokenizeNode = (node: Node, ignoreExpression = false) => {
     if (this.isI18nMethod(node)) {
       // t
-      return this.tokenizeTemplateLiteral(node)
+      return this.tokenizeTemplateLiteral(node as Expression)
     } else if (this.isChoiceMethod(node)) {
       // plural, select and selectOrdinal
-      return [this.tokenizeChoiceComponent(node)]
+      return [this.tokenizeChoiceComponent(node as CallExpression)]
       // } else if (isFormatMethod(node.callee)) {
       //   // date, number
       //   return transformFormatMethod(node, file, props, root)
@@ -303,10 +275,10 @@ export default class MacroJs {
    * text chunks and node.expressions contains expressions.
    * Both arrays must be zipped together to get the final list of tokens.
    */
-  tokenizeTemplateLiteral = (node: babelTypes.Expression) => {
+  tokenizeTemplateLiteral = (node: babelTypes.Expression): Tokens => {
     const tokenize = R.pipe(
       R.evolve({
-        quasis: R.map((text: babelTypes.TemplateElement) => {
+        quasis: R.map((text: babelTypes.TemplateElement): TextToken => {
           // Don't output tokens without text.
           // if it's an unicode we keep the cooked value because it's the parsed value by babel (without unicode chars)
           // This regex will detect if a string contains unicode chars, when they're we should interpolate them
@@ -339,10 +311,10 @@ export default class MacroJs {
     )
   }
 
-  tokenizeChoiceComponent = (node) => {
-    const format = node.callee.name.toLowerCase()
+  tokenizeChoiceComponent = (node: CallExpression): ArgToken => {
+    const format = (node.callee as Identifier).name.toLowerCase()
 
-    const token = {
+    const token: ArgToken = {
       ...this.tokenizeExpression(node.arguments[0]),
       format,
       options: {
@@ -350,30 +322,30 @@ export default class MacroJs {
       },
     }
 
-    const props = node.arguments[1].properties
+    const props = (node.arguments[1] as ObjectExpression).properties
 
     for (const attr of props) {
-      const { key } = attr
+      const { key, value: attrValue } = attr as ObjectProperty
 
       // name is either:
       // NumericLiteral => convert to `={number}`
       // StringLiteral => key.value
-      // Literal => key.name
+      // Identifier => key.name
       const name = this.types.isNumericLiteral(key)
         ? `=${key.value}`
-        : key.name || key.value
+        : (key as Identifier).name || (key as StringLiteral).value
 
       if (format !== "select" && name === "offset") {
-        token.options.offset = attr.value.value
+        token.options.offset = (attrValue as StringLiteral).value
       } else {
-        let value
+        let value: ArgToken['options'][string]
 
-        if (this.types.isTemplateLiteral(attr.value)) {
-          value = this.tokenizeTemplateLiteral(attr.value)
-        } else if (this.types.isCallExpression(attr.value)) {
-          value = this.tokenizeNode(attr.value)
+        if (this.types.isTemplateLiteral(attrValue)) {
+          value = this.tokenizeTemplateLiteral(attrValue)
+        } else if (this.types.isCallExpression(attrValue)) {
+          value = this.tokenizeNode(attrValue)
         } else {
-          value = attr.value.value
+          value = (attrValue as StringLiteral).value
         }
         token.options[name] = value
       }
@@ -382,27 +354,28 @@ export default class MacroJs {
     return token
   }
 
-  tokenizeExpression = (node) => {
-    if (this.isArg(node)) {
+  tokenizeExpression = (node: Node | Expression): ArgToken => {
+    if (this.isArg(node) && this.types.isCallExpression(node)) {
       return {
         type: "arg",
-        name: node.arguments[0].value,
+        name: (node.arguments[0] as StringLiteral).value,
+        value: undefined
       }
     }
     return {
       type: "arg",
-      name: this.expressionToArgument(node),
-      value: node,
+      name: this.expressionToArgument(node as Expression),
+      value: node as Expression,
     }
   }
 
-  expressionToArgument = (exp) => {
+  expressionToArgument = (exp: Expression): string => {
     if (this.types.isIdentifier(exp)) {
       return exp.name
     } else if (this.types.isStringLiteral(exp)) {
       return exp.value
     } else {
-      return this._expressionIndex()
+      return String(this._expressionIndex())
     }
   }
 
@@ -417,33 +390,32 @@ export default class MacroJs {
   /**
    * Custom matchers
    */
-
-  isIdentifier = (node, name) => {
+  isIdentifier = (node: Node | Expression, name: string) => {
     return this.types.isIdentifier(node, { name })
   }
 
-  isDefineMessage = (node) => {
+  isDefineMessage = (node: Node): boolean => {
     return (
       this.types.isCallExpression(node) &&
       this.isIdentifier(node.callee, "defineMessage")
     )
   }
 
-  isArg = (node) => {
+  isArg = (node: Node) => {
     return (
       this.types.isCallExpression(node) && this.isIdentifier(node.callee, "arg")
     )
   }
 
-  isI18nMethod = (node) => {
+  isI18nMethod = (node: Node) => {
     return (
-      this.isIdentifier(node.tag, "t") ||
-      (this.types.isCallExpression(node.tag) &&
-        this.isIdentifier(node.tag.callee, "t"))
+      this.types.isTaggedTemplateExpression(node) &&
+      (this.isIdentifier(node.tag, "t")
+        || (this.types.isCallExpression(node.tag) && this.isIdentifier(node.tag.callee, "t")))
     )
   }
 
-  isChoiceMethod = (node) => {
+  isChoiceMethod = (node: Node) => {
     return (
       this.types.isCallExpression(node) &&
       (this.isIdentifier(node.callee, "plural") ||
@@ -453,4 +425,4 @@ export default class MacroJs {
   }
 }
 
-const isString = (s): s is string => typeof s === "string"
+const isString = (s: unknown): s is string => typeof s === "string"
diff --git a/packages/macro/src/macroJsx.ts b/packages/macro/src/macroJsx.ts
index 4d60d95a..2c87c0b7 100644
--- a/packages/macro/src/macroJsx.ts
+++ b/packages/macro/src/macroJsx.ts
@@ -1,13 +1,24 @@
 import * as R from "ramda"
 import * as babelTypes from "@babel/types"
-import { NodePath } from "@babel/traverse"
-
-import ICUMessageFormat from "./icu"
-import { zip, makeCounter } from "./utils"
-import { ID, COMMENT, MESSAGE, CONTEXT } from "./constants"
+import {
+  Expression,
+  Identifier,
+  JSXAttribute,
+  JSXElement,
+  JSXExpressionContainer,
+  JSXIdentifier,
+  JSXSpreadAttribute,
+  Node,
+  StringLiteral
+} from "@babel/types"
+import {NodePath} from "@babel/traverse"
+
+import ICUMessageFormat, {ArgToken, ElementToken, TextToken, Token, Tokens} from "./icu"
+import {makeCounter, zip} from "./utils"
+import {COMMENT, CONTEXT, ID, MESSAGE} from "./constants"
 
 const pluralRuleRe = /(_[\d\w]+|zero|one|two|few|many|other)/
-const jsx2icuExactChoice = (value) =>
+const jsx2icuExactChoice = (value: string) =>
   value.replace(/_(\d+)/, "=$1").replace(/_(\w+)/, "$1")
 
 // replace whitespace before/after newline with single space
@@ -15,7 +26,7 @@ const keepSpaceRe = /\s*(?:\r\n|\r|\n)+\s*/g
 // remove whitespace before/after tag or expression
 const stripAroundTagsRe = /(?:([>}])(?:\r\n|\r|\n)+\s*|(?:\r\n|\r|\n)+\s*(?=[<{]))/g
 
-function maybeNodeValue(node) {
+function maybeNodeValue(node: Node): string {
   if (!node) return null
   if (node.type === "StringLiteral") return node.value
   if (node.type === "JSXAttribute") return maybeNodeValue(node.value)
@@ -26,7 +37,7 @@ function maybeNodeValue(node) {
   return null
 }
 
-function normalizeWhitespace(text) {
+export function normalizeWhitespace(text: string): string {
   return (
     text
       .replace(stripAroundTagsRe, "$1")
@@ -42,13 +53,11 @@ function normalizeWhitespace(text) {
 
 export default class MacroJSX {
   types: typeof babelTypes
-  expressionIndex: () => Number
-  elementIndex: () => Number
+  expressionIndex = makeCounter()
+  elementIndex = makeCounter()
 
-  constructor({ types }) {
+  constructor({ types }: {types: typeof babelTypes}) {
     this.types = types
-    this.expressionIndex = makeCounter()
-    this.elementIndex = makeCounter()
   }
 
   safeJsxAttribute = (name: string, value: string) => {
@@ -70,7 +79,7 @@ export default class MacroJSX {
     } = messageFormat.fromTokens(tokens)
     const message = normalizeWhitespace(messageRaw)
 
-    const { attributes, id, comment, context } = this.stripMacroAttributes(path.node)
+    const { attributes, id, comment, context } = this.stripMacroAttributes(path.node as JSXElement)
 
     if (!id && !message) {
       return
@@ -159,17 +168,20 @@ export default class MacroJSX {
       /*selfClosing*/ true
     )
     newNode.loc = path.node.loc
-    // @ts-ignore
+
     path.replaceWith(newNode)
   }
 
-  attrName = (names, exclude = false) => {
+  attrName = (names: string[], exclude = false) => {
     const namesRe = new RegExp("^(" + names.join("|") + ")$")
-    return (attr) =>
-      exclude ? !namesRe.test(attr.name.name) : namesRe.test(attr.name.name)
+    return (attr: JSXAttribute | JSXSpreadAttribute) => {
+      const name = (((attr as JSXAttribute).name) as JSXIdentifier).name
+      return exclude ? !namesRe.test(name) : namesRe.test(name)
+    }
+
   }
 
-  stripMacroAttributes = (node) => {
+  stripMacroAttributes = (node: JSXElement) => {
     const { attributes } = node.openingElement
     const id = attributes.filter(this.attrName([ID]))[0]
     const message = attributes.filter(this.attrName([MESSAGE]))[0]
@@ -204,7 +216,7 @@ export default class MacroJSX {
     }
   }
 
-  tokenizeNode = (node) => {
+  tokenizeNode = (node: Node): Tokens => {
     if (this.isI18nComponent(node)) {
       // t
       return this.tokenizeTrans(node)
@@ -218,13 +230,13 @@ export default class MacroJSX {
     }
   }
 
-  tokenizeTrans = (node) => {
+  tokenizeTrans = (node: JSXElement): Token[] => {
     return R.flatten(
       node.children.map((child) => this.tokenizeChildren(child)).filter(Boolean)
     )
   }
 
-  tokenizeChildren = (node) => {
+  tokenizeChildren = (node: JSXElement['children'][number]): Tokens => {
     if (this.types.isJSXExpressionContainer(node)) {
       const exp = node.expression
 
@@ -254,7 +266,6 @@ export default class MacroJSX {
             ),
           }),
           (exp) => zip(exp.quasis, exp.expressions),
-          // @ts-ignore
           R.flatten,
           R.filter(Boolean)
         )
@@ -272,13 +283,14 @@ export default class MacroJSX {
     } else if (this.types.isJSXText(node)) {
       return this.tokenizeText(node.value)
     } else {
-      return this.tokenizeText(node.value)
+      // impossible path
+      // return this.tokenizeText(node.value)
     }
   }
 
-  tokenizeChoiceComponent = (node) => {
+  tokenizeChoiceComponent = (node: JSXElement): Token => {
     const element = node.openingElement
-    const format = element.name.name.toLowerCase()
+    const format = this.getJsxTagName(node).toLowerCase()
     const props = element.attributes.filter(this.attrName([
       ID,
       COMMENT,
@@ -291,7 +303,7 @@ export default class MacroJSX {
       "components"
     ], true))
 
-    const token = {
+    const token: Token = {
       type: "arg",
       format,
       name: null,
@@ -302,23 +314,32 @@ export default class MacroJSX {
     }
 
     for (const attr of props) {
+      if (this.types.isJSXSpreadAttribute(attr)) {
+        continue;
+      }
+
+      if (this.types.isJSXNamespacedName(attr.name)) {
+        continue;
+      }
+
       const name = attr.name.name
 
       if (name === "value") {
         const exp = this.types.isLiteral(attr.value)
           ? attr.value
-          : attr.value.expression
+          : (attr.value as JSXExpressionContainer).expression
         token.name = this.expressionToArgument(exp)
-        token.value = exp
+        token.value = exp as Expression
       } else if (format !== "select" && name === "offset") {
         // offset is static parameter, so it must be either string or number
         token.options.offset = this.types.isStringLiteral(attr.value)
           ? attr.value.value
-          : attr.value.expression.value
+          : ((attr.value as JSXExpressionContainer).expression as StringLiteral).value
       } else {
-        let value
+        let value: ArgToken['options'][number]
+
         if (this.types.isStringLiteral(attr.value)) {
-          value = attr.value.extra.raw.replace(/(["'])(.*)\1/, "$2")
+          value = (attr.value.extra.raw as string).replace(/(["'])(.*)\1/, "$2")
         } else {
           value = this.tokenizeChildren(attr.value)
         }
@@ -333,13 +354,13 @@ export default class MacroJSX {
     return token
   }
 
-  tokenizeElement = (node) => {
+  tokenizeElement = (node: JSXElement): ElementToken => {
     // !!! Important: Calculate element index before traversing children.
     // That way outside elements are numbered before inner elements. (...and it looks pretty).
     const name = this.elementIndex()
-    const children = node.children
+    const children = R.flatten(node.children
       .map((child) => this.tokenizeChildren(child))
-      .filter(Boolean)
+      .filter(Boolean))
 
     node.children = []
     node.openingElement.selfClosing = true
@@ -352,29 +373,29 @@ export default class MacroJSX {
     }
   }
 
-  tokenizeExpression = (node) => {
+  tokenizeExpression = (node: Expression | Node): ArgToken => {
     return {
       type: "arg",
       name: this.expressionToArgument(node),
-      value: node,
+      value: node as Expression,
     }
   }
 
-  tokenizeText = (value) => {
+  tokenizeText = (value: string): TextToken => {
     return {
       type: "text",
       value,
     }
   }
 
-  expressionToArgument = (exp) => {
-    return this.types.isIdentifier(exp) ? exp.name : this.expressionIndex()
+  expressionToArgument(exp: Expression | Node): string {
+    return this.types.isIdentifier(exp) ? exp.name : String(this.expressionIndex())
   }
 
   /**
    * We clean '//\` ' to just '`'
-   * */
-  clearBackslashes(value: string) {
+   **/
+  clearBackslashes(value: string): string {
     // if not we replace the extra scaped literals
     return value.replace(/\\`/g, "`")
   }
@@ -382,12 +403,11 @@ export default class MacroJSX {
   /**
    * Custom matchers
    */
-
-  isIdentifier = (node, name) => {
+  isIdentifier = (node: Node, name: string): node is Identifier => {
     return this.types.isIdentifier(node, { name })
   }
 
-  isI18nComponent = (node, name = "Trans") => {
+  isI18nComponent = (node: Node, name = "Trans"): node is JSXElement => {
     return (
       this.types.isJSXElement(node) &&
       this.types.isJSXIdentifier(node.openingElement.name, {
@@ -396,11 +416,17 @@ export default class MacroJSX {
     )
   }
 
-  isChoiceComponent = (node) => {
+  isChoiceComponent = (node: Node): node is JSXElement => {
     return (
       this.isI18nComponent(node, "Plural") ||
       this.isI18nComponent(node, "Select") ||
       this.isI18nComponent(node, "SelectOrdinal")
     )
   }
+
+  getJsxTagName = (node: JSXElement): string => {
+    if ( this.types.isJSXIdentifier(node.openingElement.name)) {
+      return node.openingElement.name.name;
+    }
+  }
 }
diff --git a/packages/macro/src/utils.ts b/packages/macro/src/utils.ts
index 276c8a05..e9873882 100644
--- a/packages/macro/src/utils.ts
+++ b/packages/macro/src/utils.ts
@@ -4,7 +4,7 @@ import * as R from "ramda"
  * Custom zip method which takes length of the larger array
  * (usually zip functions use the `smaller` length, discarding values in larger array)
  */
-export function zip(a, b) {
+export function zip<A, B>(a: A[], b: B[]): [A, B][] {
   return R.range(0, Math.max(a.length, b.length)).map((index) => [
     a[index],
     b[index],
diff --git a/packages/macro/tsconfig.json b/packages/macro/tsconfig.json
new file mode 100644
index 00000000..8965e0cb
--- /dev/null
+++ b/packages/macro/tsconfig.json
@@ -0,0 +1,7 @@
+{
+  "extends": "../../tsconfig.json",
+  "compilerOptions": {
+    "strict": true,
+    "strictNullChecks": false,
+  }
+}
diff --git a/yarn.lock b/yarn.lock
index 5014d966..61297cbf 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -2499,11 +2499,29 @@
   resolved "https://registry.yarnpkg.com/@types/aria-query/-/aria-query-4.2.0.tgz#14264692a9d6e2fa4db3df5e56e94b5e25647ac0"
   integrity sha512-iIgQNzCm0v7QMhhe4Jjn9uRh+I6GoPmt03CbEtwx3ao8/EfoQcmgtqH4vQ5Db/lxiIGaWDv6nwvunuh0RyX0+A==
 
+"@types/babel-plugin-macros@^2.8.5":
+  version "2.8.5"
+  resolved "https://registry.yarnpkg.com/@types/babel-plugin-macros/-/babel-plugin-macros-2.8.5.tgz#04474f9898aa9112afc22fa0e7e53a898fcaba4c"
+  integrity sha512-+NcIm/VBaSb4xaycov9f4Vmk4hMVPrgISoHEk+kalgVK6BlkwDZbXkW9kt1jCXVczTKXldL5RHIacExaWzxAkA==
+  dependencies:
+    "@types/babel__core" "*"
+
 "@types/babel-types@^7.0.9":
   version "7.0.9"
   resolved "https://registry.yarnpkg.com/@types/babel-types/-/babel-types-7.0.9.tgz#01d7b86949f455402a94c788883fe4ba574cad41"
   integrity sha512-qZLoYeXSTgQuK1h7QQS16hqLGdmqtRmN8w/rl3Au/l5x/zkHx+a4VHrHyBsi1I1vtK2oBHxSzKIu0R5p6spdOA==
 
+"@types/babel__core@*":
+  version "7.1.20"
+  resolved "https://registry.yarnpkg.com/@types/babel__core/-/babel__core-7.1.20.tgz#e168cdd612c92a2d335029ed62ac94c95b362359"
+  integrity sha512-PVb6Bg2QuscZ30FvOU7z4guG6c926D9YRvOxEaelzndpMsvP+YM74Q/dAFASpg2l6+XLalxSGxcq/lrgYWZtyQ==
+  dependencies:
+    "@babel/parser" "^7.1.0"
+    "@babel/types" "^7.0.0"
+    "@types/babel__generator" "*"
+    "@types/babel__template" "*"
+    "@types/babel__traverse" "*"
+
 "@types/babel__core@^7.0.0", "@types/babel__core@^7.1.7":
   version "7.1.9"
   resolved "https://registry.yarnpkg.com/@types/babel__core/-/babel__core-7.1.9.tgz#77e59d438522a6fb898fa43dc3455c6e72f3963d"


Test patch:
diff --git a/packages/macro/src/icu.test.ts b/packages/macro/src/icu.test.ts
index 9e38112d..f526730c 100644
--- a/packages/macro/src/icu.test.ts
+++ b/packages/macro/src/icu.test.ts
@@ -1,9 +1,10 @@
-import ICUMessageFormat from "./icu"
+import ICUMessageFormat, {Token} from "./icu"
+import {Identifier} from "@babel/types"
 
 describe("ICU MessageFormat", function () {
   it("should collect text message", function () {
     const messageFormat = new ICUMessageFormat()
-    const tokens = [
+    const tokens: Token[] = [
       {
         type: "text",
         value: "Hello World",
@@ -19,7 +20,7 @@ describe("ICU MessageFormat", function () {
 
   it("should collect text message with arguments", function () {
     const messageFormat = new ICUMessageFormat()
-    const tokens = [
+    const tokens: Token[] = [
       {
         type: "text",
         value: "Hello ",
@@ -27,14 +28,20 @@ describe("ICU MessageFormat", function () {
       {
         type: "arg",
         name: "name",
-        value: "Joe",
+        value: {
+          type: "Identifier",
+          name: "Joe",
+        } as Identifier,
       },
     ]
     expect(messageFormat.fromTokens(tokens)).toEqual(
       expect.objectContaining({
         message: "Hello {name}",
         values: {
-          name: "Joe",
+          name: {
+            type: "Identifier",
+            name: "Joe",
+          },
         },
       })
     )
diff --git a/packages/macro/src/macroJs.test.ts b/packages/macro/src/macroJs.test.ts
index 6284e7db..a63a35c2 100644
--- a/packages/macro/src/macroJs.test.ts
+++ b/packages/macro/src/macroJs.test.ts
@@ -1,6 +1,7 @@
 import { parseExpression } from "@babel/parser"
 import * as types from "@babel/types"
 import MacroJs from "./macroJs"
+import {CallExpression} from "@babel/types"
 
 function createMacro() {
   return new MacroJs({ types }, { i18nImportName: "i18n" })
@@ -63,7 +64,7 @@ describe("js macro", () => {
         },
         {
           type: "arg",
-          name: 0,
+          name: "0",
           value: expect.objectContaining({
             type: "MemberExpression",
           }),
@@ -163,7 +164,7 @@ describe("js macro", () => {
       const exp = parseExpression(
         "plural(count, { one: '# book', other: '# books'})"
       )
-      const tokens = macro.tokenizeChoiceComponent(exp)
+      const tokens = macro.tokenizeChoiceComponent(exp as CallExpression)
       expect(tokens).toEqual({
         type: "arg",
         name: "count",
@@ -189,7 +190,7 @@ describe("js macro", () => {
           other: '# books'
          })`
       )
-      const tokens = macro.tokenizeChoiceComponent(exp)
+      const tokens = macro.tokenizeChoiceComponent(exp as CallExpression)
       expect(tokens).toEqual({
         type: "arg",
         name: "count",
@@ -212,7 +213,7 @@ describe("js macro", () => {
       const exp = parseExpression(
         "plural(count, { one: `# glass of ${drink}`, other: `# glasses of ${drink}`})"
       )
-      const tokens = macro.tokenizeChoiceComponent(exp)
+      const tokens = macro.tokenizeChoiceComponent(exp as CallExpression)
       expect(tokens).toEqual({
         type: "arg",
         name: "count",
@@ -265,7 +266,7 @@ describe("js macro", () => {
           })
         })`
       )
-      const tokens = macro.tokenizeChoiceComponent(exp)
+      const tokens = macro.tokenizeChoiceComponent(exp as CallExpression)
       expect(tokens).toEqual({
         type: "arg",
         name: "count",
@@ -304,7 +305,7 @@ describe("js macro", () => {
           other: "they"
         })`
       )
-      const tokens = macro.tokenizeChoiceComponent(exp)
+      const tokens = macro.tokenizeChoiceComponent(exp as CallExpression)
       expect(tokens).toEqual({
         format: "select",
         name: "gender",
diff --git a/packages/macro/src/macroJsx.test.ts b/packages/macro/src/macroJsx.test.ts
index 8c6e4bf1..7a3782be 100644
--- a/packages/macro/src/macroJsx.test.ts
+++ b/packages/macro/src/macroJsx.test.ts
@@ -1,17 +1,69 @@
 import { parseExpression as _parseExpression } from "@babel/parser"
 import * as types from "@babel/types"
-import MacroJSX from "./macroJsx"
+import MacroJSX, {normalizeWhitespace} from "./macroJsx"
+import {JSXElement} from "@babel/types"
 
-const parseExpression = (expression) =>
+const parseExpression = (expression: string) =>
   _parseExpression(expression, {
     plugins: ["jsx"],
-  })
+  }) as JSXElement
 
 function createMacro() {
   return new MacroJSX({ types })
 }
 
 describe("jsx macro", () => {
+  describe('normalizeWhitespace', () => {
+    it('should remove whitespace before/after expression', () => {
+      const actual = normalizeWhitespace(
+          `You have
+
+          {count, plural, one {Message} other {Messages}}`)
+
+      expect(actual).toBe(`You have{count, plural, one {Message} other {Messages}}`)
+    })
+
+    it('should remove whitespace before/after tag', () => {
+      const actual = normalizeWhitespace(
+          `    Hello <strong>World!</strong><br />
+    <p>
+     My name is <a href="/about">{{" "}}
+      <em>{{name}}</em></a>
+    </p>`)
+
+      expect(actual).toBe(`Hello <strong>World!</strong><br /><p>My name is <a href="/about">{{" "}}<em>{{name}}</em></a></p>`)
+    })
+
+    it('should remove whitespace before/after tag', () => {
+      const actual = normalizeWhitespace(
+          `Property {0},
+          function {1},
+          array {2},
+          constant {3},
+          object {4},
+          everything {5}`)
+
+      expect(actual).toBe(`Property {0}, function {1}, array {2}, constant {3}, object {4}, everything {5}`)
+    })
+
+    it('should remove trailing whitespaces in icu expressions', () => {
+      const actual = normalizeWhitespace(
+          `{count, plural, one {
+
+              <0>#</0> slot added
+
+            } other {
+
+              <1>#</1> slots added
+
+            }}
+`)
+
+      expect(actual).toBe(`{count, plural, one {<0>#</0> slot added} other {<1>#</1> slots added}}`)
+    })
+
+  })
+
   describe("tokenizeTrans", () => {
     it("simple message without arguments", () => {
       const macro = createMacro()
@@ -20,8 +72,8 @@ describe("jsx macro", () => {
       expect(tokens).toEqual([
         {
           type: "text",
-          value: "Message",
-        },
+          value: "Message"
+        }
       ])
     })
 
@@ -56,7 +108,7 @@ describe("jsx macro", () => {
         },
         {
           type: "arg",
-          name: 0,
+          name: "0",
           value: expect.objectContaining({
             type: "MemberExpression",
           }),
diff --git a/packages/macro/test/index.ts b/packages/macro/test/index.ts
index 266e3125..7698d6bd 100644
--- a/packages/macro/test/index.ts
+++ b/packages/macro/test/index.ts
@@ -3,7 +3,18 @@ import path from "path"
 import { transformFileSync, TransformOptions, transformSync } from "@babel/core"
 import prettier from "prettier"
 
-const testCases = {
+export type TestCase = {
+  name?: string
+  input?: string
+  expected?: string
+  filename?: string
+  production?: boolean
+  useTypescriptPreset?: boolean
+  only?: boolean
+  skip?: boolean
+}
+
+const testCases: Record<string, TestCase[]> = {
   "js-arg": require("./js-arg").default,
   "js-t": require("./js-t").default,
   "js-plural": require("./js-plural").default,
@@ -25,7 +36,7 @@ describe("macro", function () {
   }
 
   // return function, so we can test exceptions
-  const transformCode = (code) => () => {
+  const transformCode = (code: string) => () => {
     try {
       return transformSync(code, babelOptions).code.trim()
     } catch (e) {
@@ -38,7 +49,7 @@ describe("macro", function () {
     describe(suiteName, () => {
       const cases = testCases[suiteName]
 
-      const clean = (value) =>
+      const clean = (value: string) =>
         prettier.format(value, { parser: "babel" }).replace(/\n+/, "\n")
 
       cases.forEach(
diff --git a/packages/macro/test/js-arg.ts b/packages/macro/test/js-arg.ts
index 2d5be641..6936d0aa 100644
--- a/packages/macro/test/js-arg.ts
+++ b/packages/macro/test/js-arg.ts
@@ -1,4 +1,6 @@
-export default [
+import { TestCase } from "./index"
+
+const cases: TestCase[] = [
   {
     name: "Arg macro should be exluded from values",
     input: `
@@ -13,3 +15,5 @@ export default [
     `,
   },
 ]
+
+export default cases;
diff --git a/packages/macro/test/js-defineMessage.ts b/packages/macro/test/js-defineMessage.ts
index bf4efa47..ecbf1910 100644
--- a/packages/macro/test/js-defineMessage.ts
+++ b/packages/macro/test/js-defineMessage.ts
@@ -1,4 +1,6 @@
-export default [
+import {TestCase} from "./index"
+
+const cases: TestCase[] = [
   {
     name: "should expand macros in message property",
     input: `
@@ -95,3 +97,5 @@ export default [
     `,
   },
 ]
+
+export default cases;
diff --git a/packages/macro/test/js-plural.ts b/packages/macro/test/js-plural.ts
index 90dfeccc..03a4b509 100644
--- a/packages/macro/test/js-plural.ts
+++ b/packages/macro/test/js-plural.ts
@@ -1,4 +1,6 @@
-export default [
+import {TestCase} from "./index"
+
+const cases: TestCase[] = [
   {
     name: "Macro is used in expression assignment",
     input: `
@@ -37,3 +39,4 @@ export default [
       `,
   },
 ]
+export default cases;
diff --git a/packages/macro/test/js-select.ts b/packages/macro/test/js-select.ts
index d9b61a29..ce4a908c 100644
--- a/packages/macro/test/js-select.ts
+++ b/packages/macro/test/js-select.ts
@@ -1,4 +1,6 @@
-export default [
+import {TestCase} from "./index"
+
+const cases: TestCase[] = [
   {
     name: "Nested macros",
     input: `
@@ -39,3 +41,4 @@ export default [
       `,
   },
 ]
+export default cases;
diff --git a/packages/macro/test/js-selectOrdinal.ts b/packages/macro/test/js-selectOrdinal.ts
index 0257c573..fd69a6f4 100644
--- a/packages/macro/test/js-selectOrdinal.ts
+++ b/packages/macro/test/js-selectOrdinal.ts
@@ -1,4 +1,6 @@
-export default [
+import {TestCase} from "./index"
+
+const cases: TestCase[] = [
   {
     input: `
         import { t, selectOrdinal } from '@lingui/macro'
@@ -17,3 +19,4 @@ export default [
       `,
   },
 ]
+export default cases;
diff --git a/packages/macro/test/js-t.ts b/packages/macro/test/js-t.ts
index 60884191..9e0745af 100644
--- a/packages/macro/test/js-t.ts
+++ b/packages/macro/test/js-t.ts
@@ -1,4 +1,6 @@
-export default [
+import {TestCase} from "./index"
+
+const cases: TestCase[] = [
   {
     name: "Macro is used in expression assignment",
     input: `
@@ -217,3 +219,4 @@ export default [
     filename: "js-t-var/js-t-var.js",
   },
 ]
+export default cases;
diff --git a/packages/macro/test/jsx-plural.ts b/packages/macro/test/jsx-plural.ts
index 714a0dbf..d2ef8140 100644
--- a/packages/macro/test/jsx-plural.ts
+++ b/packages/macro/test/jsx-plural.ts
@@ -1,4 +1,6 @@
-export default [
+import {TestCase} from "./index"
+
+const cases: TestCase[] = [
   {
     input: `
         import { Plural } from '@lingui/macro';
@@ -22,6 +24,41 @@ export default [
         }} />;
       `,
   },
+  {
+    name: 'Should preserve reserved props: `comment`, `context`, `render`, `id`',
+    input: `
+        import { Plural } from '@lingui/macro';
+        <Plural
+          comment="Comment for translator"
+          context="translation context"
+          id="custom.id"
+          render={() => {}}
+          value={count}
+          offset="1"
+          _0="Zero items"
+          few={\`\${count} items\`}
+          other={<a href="/more">A lot of them</a>}
+        />;
+      `,
+    expected: `
+        import { Trans } from "@lingui/react";
+        <Trans
+          render={() => {}}
+          id="custom.id"
+          message={
+            "{count, plural, offset:1 =0 {Zero items} few {{count} items} other {<0>A lot of them</0>}}"
+          }
+          comment="Comment for translator"
+          context="translation context"
+          values={{
+            count: count
+          }}
+          components={{
+            0: <a href="/more" />
+          }}
+        />;
+      `,
+  },
   {
     input: `
         import { Trans, Plural } from '@lingui/macro';
@@ -106,3 +143,4 @@ export default [
     filename: `jsx-plural-select-nested.js`,
   },
 ]
+export default cases;
diff --git a/packages/macro/test/jsx-select.ts b/packages/macro/test/jsx-select.ts
index 07f114c9..1d1ce8c7 100644
--- a/packages/macro/test/jsx-select.ts
+++ b/packages/macro/test/jsx-select.ts
@@ -1,4 +1,6 @@
-export default [
+import {TestCase} from "./index"
+
+const cases: TestCase[] = [
   {
     input: `
         import { Select } from '@lingui/macro';
@@ -40,3 +42,4 @@ export default [
       `,
   },
 ]
+export default cases;
diff --git a/packages/macro/test/jsx-selectOrdinal.ts b/packages/macro/test/jsx-selectOrdinal.ts
index e3f84606..6e47cf3e 100644
--- a/packages/macro/test/jsx-selectOrdinal.ts
+++ b/packages/macro/test/jsx-selectOrdinal.ts
@@ -1,4 +1,6 @@
-export default [
+import {TestCase} from "./index"
+
+const cases: TestCase[] = [
   {
     input: `
         import { Trans, SelectOrdinal } from '@lingui/macro';
@@ -23,6 +25,32 @@ export default [
         }} />;
       `,
   },
+  {
+    // without trailing whitespace ICU expression on the next line will not have a space
+    input: `
+        import { Trans, SelectOrdinal } from '@lingui/macro';
+        <Trans>
+          This is my
+          <SelectOrdinal
+            value={count}
+            one="#st"
+            two={\`#nd\`}
+            other={<strong>#rd</strong>}
+          /> cat.
+        </Trans>;
+      `,
+    expected: `
+        import { Trans } from "@lingui/react";
+        <Trans id={
+          "This is my{count, selectordinal, one {#st} two {#nd} other {<0>#rd</0>}} cat."
+         }
+         values={{
+          count: count
+        }} components={{
+          0: <strong />
+        }} />;
+      `,
+  },
   {
     input: `
         import { Trans, SelectOrdinal } from '@lingui/macro';
@@ -48,3 +76,4 @@ export default [
       `,
   },
 ]
+export default cases;
diff --git a/packages/macro/test/jsx-trans.ts b/packages/macro/test/jsx-trans.ts
index 97e70493..d5ced3ab 100644
--- a/packages/macro/test/jsx-trans.ts
+++ b/packages/macro/test/jsx-trans.ts
@@ -1,4 +1,6 @@
-export default [
+import {TestCase} from "./index"
+
+const cases: TestCase[] = [
   {
     name: "Generate ID from children",
     input: `
@@ -54,6 +56,28 @@ export default [
         <Trans id="msg.hello" message={"Hello World"} />;
       `,
   },
+  {
+    name: 'Should preserve reserved props: `comment`, `context`, `render`, `id`',
+    input: `
+        import { Trans } from '@lingui/macro';
+        <Trans
+          comment="Comment for translator"
+          context="translation context"
+          id="custom.id"
+          render={() => {}}
+        >Hello World</Trans>;
+      `,
+    expected: `
+        import { Trans } from "@lingui/react";
+        <Trans
+          render={() => {}}
+          id="custom.id"
+          message={"Hello World"}
+          comment="Comment for translator"
+          context="translation context"
+        />;
+      `,
+  },
   {
     name: "Macro without children is noop",
     input: `
@@ -324,8 +348,9 @@ export default [
       `,
   },
   {
+    name: 'Use a js macro inside a JSX Attribute of a component handled by JSX macro',
     input: `
-        import { t, plural, Trans } from '@lingui/macro'
+        import { t, Trans } from '@lingui/macro';
         <Trans>Read <a href="/more" title={t\`Full content of \${articleName}\`}>more</a></Trans>
       `,
     expected: `
@@ -342,8 +367,9 @@ export default [
       `,
   },
   {
+    name: 'Use a js macro inside a JSX Attribute of a non macro JSX component',
     input: `
-        import { plural } from '@lingui/macro'
+        import { plural } from '@lingui/macro';
         <a href="/about" title={plural(count, {
           one: "# book",
           other: "# books"
@@ -382,3 +408,4 @@ export default [
       `,
   },
 ]
+export default cases;