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

Keep this rule for JavaScript or projects that only need inline placeholder checks. It shares missing/unused-value checks, ignoreList, and parser settings with enforce-message-types. Neither rule is deprecated. Choose one rule per file to avoid duplicate diagnostics.

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

Emoji detection supports the ESM data files shipped by @unicode/unicode-17.0.0 2.x.

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.

enforce-message-types

Checks message values and keeps TypeScript argument types in sync with ICU messages. In JavaScript and untyped calls, it checks inline values like enforce-placeholders. Typed calls also let TypeScript check variables, missing arguments, and incorrect value types.

Setup

Use a TypeScript-capable ESLint parser, enable the rule, and run eslint --fix:

{
  'formatjs/enforce-message-types': ['error', {generateTypes: true}]
}

The rule is not included in the recommended or strict presets. Keep it enabled: TypeScript checks the generated types, but only the linter checks that they still match your message text.

// Before
const items = defineMessage({defaultMessage: '{count, number} items'})

// After autofix
const items = defineMessage<{readonly count: number | bigint}>({
  defaultMessage: '{count, number} items',
})

intl.formatMessage(items, {count: 2}) // OK
intl.formatMessage(items, {count: 'two'}) // Type error
intl.formatMessage(items) // Type error: count is required

What autofix supports

  • defineMessage and defineMessages from @formatjs/intl, react-intl, or react-intl/server.
  • Inline formatMessage, $t, and $formatMessage calls.
  • new IntlMessageFormat from intl-messageformat.
  • Catalog entries that reference existing messages, including identifier and enum-member keys. References use MessageValuesOf<typeof message>.
  • Local const descriptors used only by message helpers and formatter calls.

Autofix updates the first generic, including handwritten types, and preserves an optional second generic. It adds type imports as needed. Helpers default to no arguments, so empty messages need no <{}> or {typed: true} option. Mixed catalogs and inline calls that need an explicit empty contract use NoMessageValues, imported from the matching FormatJS package. This type is Record<never, never>, so generated contracts work with @typescript-eslint/no-empty-object-type without disabling the rule.

import {
  defineMessages,
  type NoMessageValues,
  type MessageValue,
} from 'react-intl'

const messages = defineMessages<{
  readonly hello: NoMessageValues
  readonly welcome: {readonly name: MessageValue}
}>({
  hello: {defaultMessage: 'Hello'},
  welcome: {defaultMessage: 'Welcome, {name}'},
})

Generated argument types

ICU argumentType
Number or pluralnumber | bigint
Date or timenumber | Date
Selectstring
Plain placeholderMessageValue
Rich-text tagMessageTag (a callback when formatting)

Types include arguments from every ICU branch. Incompatible uses of the same argument produce a diagnostic instead of a fix. Generated properties are readonly; this does not freeze objects at runtime.

Options

  • generateTypes (default false): add types to untyped TypeScript messages. Existing typed messages are checked and refreshed even when this is off.
  • ignoreList: argument names supplied by your wrapper. Inline checks ignore them; generated types make them optional and allow listed extra keys. Values still need the right types when supplied. This option does not provide values at runtime.
  • moduleSource: package to import types from for inline formatter calls. Choose @formatjs/intl, react-intl, or react-intl/server. By default, the rule uses the first matching import, falling back to @formatjs/intl. Set this explicitly when your formatter comes from an application wrapper.

Explicit annotations and dynamic keys

Autofix replaces broad MessageDescriptor annotations with TypedMessageDescriptor types, including per-message types for catalogs. It supports local aliases, imported descriptor aliases, Record types, property maps, and Readonly wrappers. These annotations work with isolatedDeclarations.

Catalog keys become exact: broad index signatures and optional entries are replaced with the messages actually present. Narrow dynamic keys to keyof typeof messages before indexing. A selected message needs values valid for every message it could refer to.

Limits

Type generation needs static message text. It cannot derive types from ID-only messages, dynamic strings, spreads, or arbitrary computed properties. Constructor fixes support a static ignoreTag option, but not parsed AST input or dynamic parser options. Unsupported typed declarations produce diagnostics rather than guessed types; descriptor assertions are left unchanged.

Keep generated types when passing messages through wrappers. Widening to MessageDescriptor or any loses the checks. This rule generates no JavaScript types and does not validate translated catalogs.