This eslint plugin allows you to enforce certain rules in your ICU message.

Usage

npm i -D eslint-plugin-formatjs

Then in your eslint config:

export default [
  // other configs...
  {
    plugins: {
      formatjs,
    },
    rules: {
      'formatjs/no-offset': 'error',
    },
  },
]

React

The plugin checks formatMessage, $formatMessage, $t, defineMessage, defineMessages, and <FormattedMessage> messages. Function calls match by name, including methods on any receiver, just like @formatjs/unplugin. For example, wrappedIntl.formatMessage(...) and messages.defineMessages(...) are checked without resolving imports or receiver types:

const messages = defineMessages({
  foo: {
    defaultMessage: 'foo',
    description: 'bar',
  },
})

defineMessage({
  defaultMessage: 'single message',
})
<FormattedMessage defaultMessage="foo" description="bar" />
function foo() {
  intl.formatMessage({
    defaultMessage: 'foo',
  })
}

TypeScript wrappers (as, satisfies, non-null assertions, and angle-bracket assertions in .ts files) do not hide inline descriptors. This also applies to defineMessages maps and their individual entries. Autofixes preserve the surrounding wrappers.

const wrappedIntl = useIntl()
wrappedIntl.formatMessage({defaultMessage: 'Hello'} as const)

messages.defineMessages({
  greeting: {defaultMessage: 'Hello'} satisfies MessageDescriptor,
} as const)

Vue

This will check against intl.formatMessage, $formatMessage function calls in both your JS/TS & your SFC .vue files. For example:

<template>
  <p>
    {{
      $formatMessage({
        defaultMessage: 'today is {now, date}',
      })
    }}
  </p>
</template>

Shared Settings

These settings are applied globally to all formatjs rules once specified. See Shared Settings for more details on how to set them.

formatjs.additionalFunctionNames

Similar to babel-plugin-formatjs & @formatjs/ts-transformer, this allows you to specify additional function names to check besides formatMessage, $formatMessage, and $t. These names also match methods: configuring customMessage checks both customMessage(...) and helpers.customMessage(...).

formatjs.additionalComponentNames

Similar to babel-plugin-formatjs & @formatjs/ts-transformer, this allows you to specify additional component names to check besides FormattedMessage.

Shareable Configs

The plugin provides the following two shareable configs:

  1. recommended
  2. strict

By using these, you can simplify your configuration while still using a set of rules that aligns with your quality standards.

Example

export default [
  formatjs.configs.recommended,
  // Other configs...
]

Available Rules

blocklist-elements

This blocklists usage of specific elements in ICU message.

Why

  • Certain translation vendors cannot handle things like selectordinal

Available elements

enum Element {
  // literal text, like `defaultMessage: 'some text'`
  literal = 'literal',
  // placeholder, like `defaultMessage: '{placeholder} var'`
  argument = 'argument',
  // number, like `defaultMessage: '{placeholder, number} var'`
  number = 'number',
  // date, like `defaultMessage: '{placeholder, date} var'`
  date = 'date',
  // time, like `defaultMessage: '{placeholder, time} var'`
  time = 'time',
  // select, like `defaultMessage: '{var, select, foo{one} bar{two}} var'`
  select = 'select',
  // selectordinal, like `defaultMessage: '{var, selectordinal, one{one} other{two}} var'`
  selectordinal = 'selectordinal',
  // plural, like `defaultMessage: '{var, plural, one{one} other{two}} var'`
  plural = 'plural',
}

Example

export default [
  {
    plugins: {
      formatjs,
    },
    rules: {
      'formatjs/blocklist-elements': [
        2,
        [
          'selectordinal',
          {
            type: 'select',
            allow: {
              variable: 'gender',
              options: ['male', 'female', 'other'],
            },
          },
        ],
      ],
    },
  },
]

String entries block every element of that type. Object entries allow an exception for the named variable. When options is present, selector options must match that list exactly. Both recommended and strict block selectordinal and allow select only for gender with male, female, and other options.

enforce-description

This enforces description in the message descriptor.

Why

  • Description provides helpful context for translators
const messages = defineMessages({
  // WORKS
  foo: {
    defaultMessage: 'foo',
    description: 'bar',
  },
  // FAILS
  bar: {
    defaultMessage: 'bar',
  },
})

Options

The rule accepts a single option, which can be either a string or an object.

String option:

export default [
  {
    plugins: {
      formatjs,
    },
    rules: {
      'formatjs/enforce-description': ['error', 'literal'],
    },
  },
]

Setting literal forces description to always be a string literal instead of function calls or variables. This is helpful for extraction tools that expects description to always be a literal.

Object option:

export default [
  {
    plugins: {
      formatjs,
    },
    rules: {
      'formatjs/enforce-description': [
        'error',
        {minLength: 10, mode: 'literal'},
      ],
    },
  },
]
PropertyTypeRequiredDescription
minLengthintegerNoMinimum character length for the description string.
modestringNoSet to 'literal' to require description to be a string literal.

enforce-default-message

This enforces defaultMessage in the message descriptor.

Why

  • Can be useful in case we want to extract messages for translations from source code. This way can make sure people won't forget about defaultMessage
const messages = defineMessages({
  // WORKS
  foo: {
    defaultMessage: 'This is default message',
    description: 'bar',
  },
  // FAILS
  bar: {
    description: 'bar',
  },
})

Options

export default [
  {
    plugins: {
      formatjs,
    },
    rules: {
      'formatjs/enforce-default-message': ['error', 'literal'],
    },
  },
]

Setting literal forces defaultMessage to always be a string literal instead of function calls or variables. This is helpful for extraction tools that expects defaultMessage to always be a literal

enforce-placeholders

Makes sure all values are passed in if message has placeholders (number/date/time/plural/select/selectordinal). This requires values to be passed in as literal object (not a variable).

// WORKS, no error
<FormattedMessage
  defaultMessage="this is a {placeholder}"
  values={{placeholder: 'dog'}}
/>

// WORKS, no error
intl.formatMessage({
  defaultMessage: 'this is a {placeholder}'
}, {placeholder: 'dog'})

// WORKS, error bc no values were provided
<FormattedMessage
  defaultMessage="this is a {placeholder}"
/>

// WORKS, error bc no values were provided
intl.formatMessage({
  defaultMessage: 'this is a {placeholder}'
})

// WORKS, error bc `placeholder` is not passed in
<FormattedMessage
  defaultMessage="this is a {placeholder}"
  values={{foo: 1}}
/>

// WORKS, error bc `placeholder` is not passed in
intl.formatMessage({
  defaultMessage: 'this is a {placeholder}'
}, {foo: 1})

// DOESN'T WORK
<FormattedMessage
  defaultMessage="this is a {placeholder}"
  values={someVar}
/>

// DOESN'T WORK
intl.formatMessage({
  defaultMessage: 'this is a {placeholder}'
}, values)

Options

export default [
  {
    plugins: {
      formatjs,
    },
    rules: {
      'formatjs/enforce-placeholders': [
        'error',
        {
          ignoreList: ['foo'],
        },
      ],
    },
  },
]
  • ignoreList: List of placeholder names to ignore. This works with defaultRichTextElements in react-intl so we don't provide false positive for ambient global tag formatting

enforce-plural-rules

Enforce certain plural rules to always be specified/forbidden in a message.

Why

  • It is recommended to always specify other as fallback in the message.
  • Some translation vendors only accept certain rules.

Available rules

enum LDML {
  zero = 'zero',
  one = 'one',
  two = 'two',
  few = 'few',
  many = 'many',
  other = 'other',
}

Example

export default [
  {
    plugins: {
      formatjs,
    },
    rules: {
      'formatjs/enforce-plural-rules': [
        2,
        {
          one: true,
          other: true,
          zero: false,
        },
      ],
    },
  },
]

no-camel-case

This make sure placeholders are not camel-case.

Why

  • This is to prevent case-sensitivity issue in certain translation vendors.
const messages = defineMessages({
  // WORKS
  foo: {
    defaultMessage: 'foo {snake_case} {nothing}',
  },
  // FAILS
  bar: {
    defaultMessage: 'foo {camelCase}',
  },
})

no-missing-icu-plural-one-placeholders

Messages that look like {thing, plural, one {1 thing} other {# things}} will need to be changed to {thing, plural, one {# thing} other {# things}}

Why

  • one is a category for any number that behaves like 1. So in some languages, for example Ukrainian, Russian and Serbian, one → numbers that end in 1 (like 1, 21, 151) but that don’t end in 11 (like 11, 111, 10311). More info

no-emoji

This prevents usage of emojis (or above a certain Unicode version) in message

export default [
  {
    plugins: {
      formatjs,
    },
    rules: {
      'formatjs/no-emoji': ['error'],
    },
  },

  // OR
  {
    plugins: {
      formatjs,
    },
    rules: {
      'formatjs/no-emoji': ['error', {versionAbove: '12.0'}],
    },
  },
]

Why

  • Certain translation vendors cannot handle emojis.
  • Cross-platform encoding for emojis are faulty.
const messages = defineMessages({
  // WORKS
  foo: {
    defaultMessage: 'Smileys & People',
  },
  // WORKS with option {versionAbove: '12.0'}
  foo_bar: {
    defaultMessage: '😃 Smileys & People',
  },
  // FAILS
  bar: {
    defaultMessage: '😃 Smileys & People',
  },
  // FAILS with option {versionAbove: '12.0'}
  bar_foo: {
    defaultMessage: '🥹 Smileys & People',
  },
})

no-literal-string-in-jsx

This prevents untranslated strings in JSX.

Why

  • It is easy to forget wrapping JSX text in translation functions or components.
  • It is easy to forget wrapping certain accessibility attributes (e.g. aria-label) in translation functions.
// WORKS
<Button>
  <FormattedMessage defaultMessage="Submit" />
</Button>
// WORKS
<Button>
  {customTranslateFn("Submit")}
</Button>
// WORKS
<input aria-label={intl.formatMessage({defaultMessage: "Label"})} />
// WORKS
<img
  src="/example.png"
  alt={intl.formatMessage({defaultMessage: "Image description"})}
/>
// FAILS
<Button>Submit</Button>
// FAILS
<Button>{'Submit'}</Button>
// FAILS
<Button>{`Te` + 's' + t}</Button>
// FAILS
<input aria-label="Untranslated label" />
// FAILS
<img src="/example.png" alt="Image description" />
// FAILS
<input aria-label={`Untranslated label`} />

This linter reports text literals or string expressions, including string concatenation expressions in the JSX children. It also checks certain JSX attributes that you can customize.

Example

export default [
  {
    plugins: {
      formatjs,
    },
    rules: {
      'formatjs/no-literal-string-in-jsx': [
        2,
        {
          // Include or exclude additional prop checks (merged with the default checks)
          props: {
            include: [
              // picomatch style glob pattern for tag name and prop name.
              // check `name` prop of `UI.Button` tag.
              ['UI.Button', 'name'],
              // check `message` of any component.
              ['*', 'message'],
            ],
            // Exclude will always override include.
            exclude: [
              // do not check `message` of the `Foo` tag.
              ['Foo', 'message'],
              // do not check aria-label and aria-description of `Bar` tag.
              ['Bar', 'aria-{label,description}'],
            ],
          },
        },
      ],
    },
  },
]

The default prop checks are:

{
  include: [
    // check aria attributes that the screen reader announces.
    ['*', 'aria-{label,description,details,errormessage}'],
    // check placeholder and title attribute of all native DOM elements.
    ['[a-z]*([a-z0-9])', '(placeholder|title)'],
    // check alt attribute of the img tag.
    ['img', 'alt'],
  ],
  exclude: []
}

no-literal-string-in-object

This prevents untranslated strings in chosen object properties.

Why

  • It is easy to forget wrapping literal strings in translation functions, when they are defined in an object field like {label: "Untranslated label"}.
const options = () => [
  // FAILS
  {value: 'chocolate', label: 'Chocolate'},
  // WORKS
  {
    value: 'strawberry',
    label: intl.formatMessage({defaultMessage: 'Strawberry'}),
  },
  // WORKS, custom translation function
  {
    value: 'mint',
    label: customTranslateFn('Mint'),
  },
  // FAILS, string concatenation
  {
    value: 'coconut',
    label: 'Coconut' + intl.formatMessage({defaultMessage: 'Ice Cream'}),
  },
  // FAILS, template literal
  {
    value: 'mango',
    label: `Mango ${intl.formatMessage({defaultMessage: 'Ice Cream'})}`,
  },
  // FAILS, conditional rendering
  {
    value: 'recommended',
    label: feelLikeSour
      ? intl.formatMessage({defaultMessage: 'Lime'})
      : 'Vanilla',
  },
]

const MyComponent = () => <Select options={options()} />

This linter reports text literals or string expressions, including string concatenation expressions in the object properties that you can customize.

Example

export default [
  {
    plugins: {
      formatjs,
    },
    rules: {
      'formatjs/no-literal-string-in-object': [
        'warn',
        {
          // The object properties to check for untranslated literal strings, default: ['label']
          include: ['label'],
        },
      ],
    },
  },
]

no-multiple-whitespaces

This prevents usage of multiple consecutive whitespaces in message.

Why

  • Consecutive whitespaces are handled differently in different locales.
  • Prevents \ linebreaks in JS string which results in awkward whitespaces.
const messages = defineMessages({
  // WORKS
  foo: {
    defaultMessage: 'Smileys & People',
  },
  // FAILS
  bar: {
    defaultMessage: 'Smileys &   People',
  },
  // FAILS
  baz: {
    defaultMessage:
      'this message is too long \
    so I wanna line break it.',
  },
})

no-multiple-plurals

This prevents specifying multiple plurals in your message.

Why

  • Nested plurals are hard to translate across languages so some translation vendors don't allow it.

const messages = defineMessages({
    // WORKS
    foo: {
        defaultMessage: '{p1, plural, one{one}}',
    },
    // FAILS
    bar: {
        defaultMessage: '{p1, plural, one{one}} {p2, plural, one{two}}',
    }
    // ALSO FAILS
    bar2: {
        defaultMessage: '{p1, plural, one{{p2, plural, one{two}}}}',
    }
})

no-offset

This prevents specifying offset in plural rules in your message.

Why

  • Offset has complicated logic implication so some translation vendors don't allow it.
const messages = defineMessages({
  // PASS
  foo: {
    defaultMessage: '{var, plural, one{one} other{other}}',
  },
  // FAILS
  bar: {
    defaultMessage: '{var, plural, offset:1 one{one} other{other}}',
  },
})

enforce-id

This enforces generated ID to be set in MessageDescriptor.

Why

Pipelines can enforce automatic/manual ID generation at the linter level (autofix to insert autogen ID) so this guarantees that.


const messages = defineMessages({
  // PASS
  foo: {
    id: '19shaf'
    defaultMessage: '{var, plural, one{one} other{other}}',
  },
  // FAILS
  bar: {
    id: 'something',
    defaultMessage: '{var, plural, offset:1 one{one} other{other}}',
  },
  // FAILS
  bar: {
    defaultMessage: '{var, plural, offset:1 one{one} other{other}}',
  },
});

Options

export default [
  {
    plugins: {
      formatjs,
    },
    rules: {
      'formatjs/enforce-id': [
        'error',
        {
          idInterpolationPattern: '[sha512:contenthash:base64:6]',
        },
      ],
    },
  },
]
  • idInterpolationPattern: Pattern to verify ID against
  • idWhitelist: An array of strings with regular expressions. This array allows allowlist custom ids for messages. For example '\\.' allows any id which has dot; '^payment_.*' - allows any custom id which has prefix payment_. Be aware that any backslash \ provided via string must be escaped with an additional backslash.

no-invalid-icu

This bans strings inside defaultMessage that are syntactically invalid.

Why

It's easy to miss strings that look correct to you as a developer but which are actually syntactically invalid ICU strings. For instance, the following would cause an eslint error:

formatMessage(
  {
    defaultMessage: '{count, plural one {#} other {# more}}', //Missing a comma!
  },
  {
    count: 1,
  }
)

no-id

This bans explicit ID in MessageDescriptor.

Why

We generally encourage automatic ID generation due to these reasons. This makes sure no explicit IDs are set.

no-complex-selectors

Make sure a sentence is not too complex. Complexity is determined by how many strings are produced when we try to flatten the sentence given its selectors. For example:

I have {count, plural, one{a dog} other{many dogs}}

has the complexity of 2 because flattening the plural selector results in 2 sentences: I have a dog & I have many dogs. Default complexity limit is 20 (using Smartling as a reference)

Options

export default [
  {
    plugins: {
      formatjs,
    },
    rules: {
      'formatjs/no-complex-selectors': [
        'error',
        {
          limit: 3,
        },
      ],
    },
  },
]

no-useless-message

This bans messages that do not require translation.

Why

Messages like {test} is not actionable by translators. The code should just directly reference test.

prefer-formatted-message

Use <FormattedMessage> instead of the imperative intl.formatMessage(...) if applicable.

// Bad
<p>
  {intl.formatMessage({defaultMessage: 'hello'})}
</p>

// Good
<p>
  <FormattedMessage defaultMessage="hello" />
</p>

Why

Consistent coding style in JSX and less syntax clutter.

prefer-full-sentence

Messages should be self-contained, full sentences. Leading or trailing whitespace in a message suggests it is a fragment being concatenated with other strings, which is an anti-pattern for localization. Placeholders joined directly to words, such as {count}day, also fail because units and spacing vary by locale.

Why

  • String concatenation breaks translation because different languages have different word orders, grammar rules, and sentence structures.
  • Translators need the full sentence to produce an accurate translation.
  • Leading/trailing whitespace is a code smell indicating the message is a fragment.
  • Units should use ICU number skeletons or an Intl formatter instead of being joined to placeholders.
const messages = defineMessages({
  // WORKS
  foo: {
    defaultMessage: 'Hello {name}, welcome back!',
  },
  // FAILS — leading whitespace
  bar: {
    defaultMessage: ' items in your cart',
  },
  // FAILS — trailing whitespace
  baz: {
    defaultMessage: 'You have ',
  },
  // FAILS — placeholder joined to a unit
  qux: {
    defaultMessage: '{duration}day',
  },
})

prefer-pound-in-plural

Use # in the plural argument to reference the count instead of repeating the argument.

Bad:

I have {count} {
  count, plural,
    one {apple}
    other {apples}
  }
}

Bad:

I have {count} {
  count, plural,
    one {apple}
    other {apples}
  }
}

Good:

I have {
  count, plural,
    one {# apple}
    other {# apples}
  }
}

Bad:

I have {
  count, plural,
    one {{count} apple}
    other {{count} apples}
  }
}

Good:

I have {
  count, plural,
    one {# apple}
    other {# apples}
  }
}

Bad:

I won the {ranking}{
  count, selectordinal,
    one {st}
    two {nd}
    few {rd}
    other {th}
} place.

Good:

I won the {ranking}{
  count, selectordinal,
    one {#st}
    two {#nd}
    few {#rd}
    other {#th}
} place.

Why

  1. More concise message.
  2. Ensures that the count are correctly formatted as numbers.