Skip to content

feat(macro): make select macro choices strictly typed #2218

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 2 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions packages/babel-plugin-lingui-macro/src/macroJsAst.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -372,5 +372,34 @@ describe("js macro", () => {
},
})
})

it("select without other", () => {
const exp = parseExpression(
`select(gender, {
male: "he",
female: "she",
})`
)
const tokens = tokenizeChoiceComponent(
(exp as NodePath<CallExpression>).node,
JsMacroName.select,
createMacroCtx()
)
expect(tokens).toMatchObject({
format: "select",
name: "gender",
options: expect.objectContaining({
female: "she",
male: "he",
offset: undefined,
other: "", // <- other should be filled with empty string
}),
type: "arg",
value: {
name: "gender",
type: "Identifier",
},
})
})
})
})
2 changes: 2 additions & 0 deletions packages/babel-plugin-lingui-macro/src/macroJsAst.ts
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,8 @@ export function tokenizeChoiceComponent(
format: format,
options: {
offset: undefined,
/** Default to fill other with empty value for compatibility with ICU spec */
other: "",
Comment on lines +183 to +184
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

oh, that means SWC plugins changes would also be required

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I see, I guess that's in a different repo 👀

Copy link
Author

@MikaelSiidorow MikaelSiidorow May 9, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Here's my attempt at implementing the same behavior on SWC side:
lingui/swc-plugin#157

Let me know if it's going totally in the wrong direction

},
}

Expand Down
28 changes: 28 additions & 0 deletions packages/babel-plugin-lingui-macro/src/macroJsx.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -322,5 +322,33 @@ describe("jsx macro", () => {
},
})
})

it("Select without other", () => {
const macro = createMacro()
const exp = parseExpression(
`<Select
value={gender}
male="he"
one="heone"
female="she"
/>`
)
const tokens = macro.tokenizeNode(exp)
expect(tokens[0]).toMatchObject({
format: "select",
name: "gender",
options: {
female: "she",
male: "he",
offset: undefined,
other: "", // <- other should be filled with empty string
},
type: "arg",
value: {
name: "gender",
type: "Identifier",
},
})
})
})
})
2 changes: 2 additions & 0 deletions packages/babel-plugin-lingui-macro/src/macroJsx.ts
Original file line number Diff line number Diff line change
Expand Up @@ -297,6 +297,8 @@ export class MacroJSX {
value: undefined,
options: {
offset: undefined,
/** Default to fill other with empty value for compatibility with ICU spec */
other: "",
},
}

Expand Down
34 changes: 33 additions & 1 deletion packages/core/macro/__typetests__/index.test-d.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -266,7 +266,9 @@ expectType<string>(
//// Select
///////////////////

const gender = "male"
type Gender = "male" | "female"
const gender = "male" as Gender // make the type less specific on purpose

expectType<string>(
select(gender, {
// todo: here is inconsistency between jsx macro and js.
Expand All @@ -280,6 +282,36 @@ expectType<string>(
})
)

expectType<string>(
// @ts-expect-error: missing required property and other is not supplied
select(gender, {
male: "he",
})
)

expectType<string>(
// missing required property is okay, if other is supplied as fallback
select(gender, {
male: "he",
other: "they",
})
)

expectType<string>(
select(gender, {
// @ts-expect-error extra properties are not allowed
incorrect: "",
})
)

expectType<string>(
select(gender, {
// @ts-expect-error extra properties are not allowed even with other fallback
incorrect: "",
other: "they",
})
)

expectType<string>(
// @ts-expect-error value could be strings only
select(5, {
Expand Down
19 changes: 14 additions & 5 deletions packages/core/macro/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -154,12 +154,21 @@ export function selectOrdinal(
options: ChoiceOptions
): string

type SelectOptions = {
type SelectOptionsExhaustive<T extends string = string> = {
[key in T]: string
}

type SelectOptionsNonExhaustive<T extends string = string> = {
/** Catch-all option */
other: string
[matches: string]: string
} & {
[key in T]?: string
}

type SelectOptions<T extends string = string> =
| SelectOptionsExhaustive<T>
| SelectOptionsNonExhaustive<T>

/**
* Selects a translation based on a value
*
Expand All @@ -180,9 +189,9 @@ type SelectOptions = {
* @param value The key of choices to use
* @param choices
*/
export function select(
value: string | LabeledExpression<string>,
choices: SelectOptions
export function select<T extends string = string>(
value: T | LabeledExpression<T>,
choices: SelectOptions<T>
): string

/**
Expand Down
19 changes: 16 additions & 3 deletions packages/react/macro/__typetests__/index.test-d.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@ import {
import React from "react"
import { ph } from "@lingui/core/macro"

const gender = "male"
type Gender = "male" | "female"
const gender = "male" as Gender
const user = {
name: "John",
}
Expand Down Expand Up @@ -126,8 +127,20 @@ m = (
// @ts-expect-error: `value` could be string only
m = <Select value={5} other={"string"} />

// @ts-expect-error: `other` required
m = <Select value={"male"} />
// @ts-expect-error: `other` required unless exhaustive
m = <Select value={gender} />

// @ts-expect-error: `other` required unless exhaustive
m = <Select value={gender} _male="..." />

// @ts-expect-error: `other` required unless exhaustive
m = <Select value={gender} _female="..." />

// non-exhaustive okay if other is defined
m = <Select value={gender} _female="..." other="..." />

// exhaustive okay without other
m = <Select value={gender} _male="..." _female="..." />

// @ts-expect-error: `value` required
m = <Select other={"male"} />
Expand Down
25 changes: 20 additions & 5 deletions packages/react/macro/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,12 +32,25 @@ type PluralChoiceProps = {
[digit: `_${number}`]: ReactNode
} & CommonProps

type SelectChoiceProps = {
value: string | LabeledExpression<string | number>
type SelectChoiceOptionsExhaustive<T extends string = string> = {
[key in T as `_${key}`]: ReactNode
}

type SelectChoiceOptionsNonExhaustive<T extends string = string> = {
/** Catch-all option */
other: ReactNode
[option: `_${string}`]: ReactNode
} & CommonProps
} & {
[key in T as `_${key}`]?: ReactNode
}

type SelectChoiceOptions<T extends string = string> =
| SelectChoiceOptionsExhaustive<T>
| SelectChoiceOptionsNonExhaustive<T>

type SelectChoiceProps<T extends string = string> = {
value: T | LabeledExpression<T>
} & SelectChoiceOptions<T> &
CommonProps

/**
* Trans is the basic macro for static messages,
Expand Down Expand Up @@ -105,7 +118,9 @@ export const SelectOrdinal: VFC<PluralChoiceProps>
* />
* ```
*/
export const Select: VFC<SelectChoiceProps>
export const Select: {
<T extends string = string>(props: SelectChoiceProps<T>): React.JSX.Element
}

declare function _t(descriptor: MacroMessageDescriptor): string
declare function _t(
Expand Down
49 changes: 39 additions & 10 deletions website/docs/ref/macro.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -323,7 +323,7 @@ const message = t({
The `select` macro is used to handle different forms of a message based on a value.

```ts
select(value: string | number, options: Object)
select(value: string, options: Object)
```

It works like a switch statement - it selects one of the forms provided in the `options` object based on the `value`:
Expand All @@ -349,6 +349,35 @@ const message = i18n._(
);
```

The `select` macro can be strictly exhaustive if the fallback `other` option is not provided and the input `value` is a union type.

```ts
import { select } from "@lingui/core/macro";

type ValidationError =
| "missingValue"
| "invalidEmail"

const message = select(validationError, {
missingValue: "This field is required",
invalidEmail: "Invalid email address",
});

// ↓ ↓ ↓ ↓ ↓ ↓

import { i18n } from "@lingui/core";

const message = i18n._(
/*i18n*/ {
id: "VRptzI",
message: "{validationError, select, missingValue {This field is required} invalidEmail {Invalid email address} other {}}",
values: { validationError },
}
);
```

The `other` option is left empty, for compatibility with [ICU MessageFormat](/guides/message-format).

:::tip
Use `select` inside [`t`](#t) or [`defineMessage`](#definemessage) macro if you want to add custom `id`, `context` or `comment` for translators.

Expand Down Expand Up @@ -692,15 +721,15 @@ import { Select } from "@lingui/react/macro";

Available Props:

| Prop name | Type | Description |
| --------- | ------ | ------------------------------------------------------ |
| `value` | number | _(required)_ Value determines which form is output |
| `other` | number | _(required)_ Default, catch-all form |
| `_<case>` | string | Form for specific case |
| `id` | string | Custom message ID |
| `comment` | string | Comment for translators |
| `context` | string | Allows to extract the same messages with different IDs |
| `render` | func | Custom render callback to render translation |
| Prop name | Type | Description |
| --------- | ------ | ------------------------------------------------------------- |
| `value` | number | _(required)_ Value determines which form is output |
| `other` | number | Default, catch-all form, required if cases are not exhaustive |
| `_<case>` | string | Form for specific case |
| `id` | string | Custom message ID |
| `comment` | string | Comment for translators |
| `context` | string | Allows to extract the same messages with different IDs |
| `render` | func | Custom render callback to render translation |

The select cases except `other` should be prefixed with underscore: `_male` or `_female`.

Expand Down