Skip to content

feat(core/i18n/t): Precise typing for message parameters #1840

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

Closed
wants to merge 24 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
a89baef
feat(core/i18n/t): Precise typing for message parameters
dilame Jan 22, 2024
865dfad
feat(core/i18n/t): take into account escape characters
dilame Jan 22, 2024
2da35cf
feat(core/i18n/t): only allow omit values param object if message str…
dilame Jan 22, 2024
61e92d4
chore(core/i18n/t): use type-fest lib instead of manually declared ut…
dilame Jan 22, 2024
1777509
fix(core/i18n/t): trim interpolation parameter names
dilame Jan 22, 2024
ab1419f
refactor: message descriptor types for core and macro
dilame Jan 23, 2024
7f9e45d
test: add tests for new strict string interpolation typings
dilame Jan 23, 2024
fdd3fd8
refactor: rename type I18nT to I18nTValues
dilame Jan 23, 2024
01fa074
chore: yarn.lock type-fest
dilame Jan 23, 2024
a53d429
refactor: rename descriptor types
dilame Jan 23, 2024
1bf144f
chore: remove unnecessary eslint-ignore
dilame Jan 23, 2024
43edfb2
refactor: rename type I18nT to I18nTValues
dilame Jan 23, 2024
1726d7c
refactor: rename type _ExtractVars to ExtractVars
dilame Jan 23, 2024
a866f4f
refactor(core/i18n.t): use wide record type for values in case of wid…
dilame Jan 23, 2024
60bbaf8
test(core/i18n.t): make the most complex test case even more complex
dilame Jan 23, 2024
dfd9d8e
feat(core/i18n.t/values): add formatters strict typing support
dilame Jan 23, 2024
01d6608
test(core/i18n.t): formatter typings
dilame Jan 23, 2024
64c697b
fix(core/i18n.t/values): replace all escaped symbols instead of just …
dilame Jan 23, 2024
53d904b
test(core/i18n.t/values): ensure all escaped symbols in string are dr…
dilame Jan 23, 2024
9d29f2d
chore(core/i18n.t/values): remove unnecessary condition in type Extra…
dilame Jan 23, 2024
c8cf4d1
chore(core/i18n.t/values): write explanation comments at each line of…
dilame Jan 23, 2024
e42c01e
chore(core/i18n.t/values): write explanation comments at each line of…
dilame Jan 23, 2024
d8463d1
chore(core/i18n/t): combine excessive overloads into one signature
dilame Jan 26, 2024
3fc4562
chore(core/i18n/t): rename parameter 'id' to '_' in overloads
dilame Jan 26, 2024
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
6 changes: 4 additions & 2 deletions examples/nextjs-swc/src/components/Switcher.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
import { useRouter } from 'next/router'
import { useState } from 'react'
import { t, msg } from '@lingui/macro'
import { MessageDescriptor } from '@lingui/core/src'
import { I18nTDescriptorByMessage, I18nTDescriptorById } from '@lingui/core/src'
import { useLingui } from '@lingui/react'

type LOCALES = 'en' | 'sr' | 'es' | 'pseudo'

const languages: { [key: string]: MessageDescriptor } = {
const languages: {
[key: string]: I18nTDescriptorByMessage<string> | I18nTDescriptorById<string>
} = {
en: msg`English`,
sr: msg`Serbian`,
es: msg`Spanish`
Expand Down
135 changes: 135 additions & 0 deletions packages/core/__typetests__/index.test-d.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,77 @@
import { expectType } from "tsd"
import { i18n } from "@lingui/core"

// @ts-expect-error at least one parameter should be presented
expectType<string>(i18n._())
expectType<string>(i18n._("message.id"))
expectType<string>(i18n._("Hello, '{name}'"))
expectType<string>(i18n._("Hello, '{name}' '{lastName}'"))
expectType<string>(i18n._("Hello, '{name}'", {}))
expectType<string>(
i18n._("Hello, {lastName} '{name}', you have {count} messages", {
count: "1",
lastName: "LastName",
})
)
expectType<string>(i18n._("Hello, } { "))
expectType<string>(i18n._("Hello, {{{vvv} ", {}))
expectType<string>(i18n._("Hello, {}", { "": "Name" }))
expectType<string>(i18n._("Hello, {name}", { name: "Name" }))
// @ts-expect-error cannot call without a parameter object
expectType<string>(i18n._("Hello, {name}"))
i18n._("You have {n, number} unread messages", { n: 10 })
// @ts-expect-error n should be of type "number"
i18n._("You have {n, number} unread messages", { n: "hello" })
// @ts-expect-error cannot call with incorrect parameters object
expectType<string>(i18n._("Hello {name}", { username: "Name" }))
expectType<string>(
// @ts-expect-error cannot call with incorrect parameters object
i18n._("Hello {name}", { name: "Name", lastName: "Slivaev" })
)
expectType<string>(
i18n._("Hello {name}, you have {count} messages", {
name: "Dmitry",
count: "5",
})
)
expectType<string>(
// @ts-expect-error cannot call with incorrect parameters object for formatter
i18n._("{numBooks, plural, one {# book} other {# books}}", {})
)

expectType<string>(
i18n._(
`
{username} have {
numBooks, plural,
one {
{
numArticles, plural,
one {
1 book and 1 article
} other {
1 book and {numArticles, number} articles, good job, {name} !
}
}
} other {
{numArticles, plural,
one {
{numBooks, number} books and 1 article
} other {
numBooks, number} books and {numArticles, number} articles
}
}
}
}. Wish you a good {what}`,
{
numBooks: 1,
numArticles: 1,
username: "username",
name: "name",
what: "luck",
}
)
)
expectType<string>(
i18n._({
id: "message.id",
Expand All @@ -16,6 +86,15 @@ expectType<string>(
{ message: "Hello {name}", comment: "", formats: {} }
)
)

expectType<string>(
// @ts-expect-error cannot call with incorrect parameters object
i18n._(
"message.id",
{ username: "Tim" },
{ message: "Hello {name}", comment: "", formats: {} }
)
)
expectType<string>(
i18n._(
// @ts-expect-error you could not use message descriptor together with rest of params
Expand All @@ -36,6 +115,62 @@ expectType<string>(
})
)

// @ts-expect-error id or message should be presented
expectType<string>(i18n.t({}))
expectType<string>(i18n.t({ id: "Hello" }))
expectType<string>(i18n.t({ id: "Hello", message: "Hello" }))

expectType<string>(
i18n.t({
id: "Hello {name}",
values: {
name: "Name",
},
})
)

expectType<string>(
i18n.t({
id: "message.id",
message: "Hello {name}",
values: {
name: "Name",
},
})
)

expectType<string>(
// @ts-expect-error cannot call with incorrect parameters object
i18n.t({
id: "Hello {name}",
values: {
username: "Name",
},
})
)

expectType<string>(
// @ts-expect-error cannot call with incorrect parameters object
i18n.t({
id: "message.id",
message: "Hello {name}",
values: {
username: "Name",
},
})
)

expectType<string>(
// @ts-expect-error if "message" is presented – values should be typed according to messages instead of id
i18n.t({
id: "Hello, {username}",
message: "Hello {name}",
values: {
username: "Name",
},
})
)

expectType<string>(
i18n.t(
"message.id",
Expand Down
1 change: 1 addition & 0 deletions packages/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@
"dependencies": {
"@babel/runtime": "^7.20.13",
"@lingui/message-utils": "4.7.0",
"type-fest": "^4.9.0",
"unraw": "^3.0.0"
},
"devDependencies": {
Expand Down
7 changes: 2 additions & 5 deletions packages/core/src/formats.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { isString } from "./essentials"
import { Locales } from "./i18n"
import { PluralFormatterOptions } from "./formatter"

/** Memoized cache */
const cache = new Map<string, unknown>()
Expand Down Expand Up @@ -40,15 +41,11 @@ export function number(

return formatter.format(value)
}
export type PluralOptions = { [key: string]: Intl.LDMLPluralRule } & {
offset: number
other: string
}
export function plural(
locales: Locales,
ordinal: boolean,
value: number,
{ offset = 0, ...rules }: PluralOptions
{ offset = 0, ...rules }: PluralFormatterOptions
): string {
const _locales = normalizeLocales(locales)

Expand Down
41 changes: 41 additions & 0 deletions packages/core/src/formatter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
export type Formats = Record<
string,
Intl.DateTimeFormatOptions | Intl.NumberFormatOptions
>
export type PluralFormatterOptions = { [key: string]: Intl.LDMLPluralRule } & {
offset: number
other: string
}
export type PluralFormatter = (
value: number,
cases: PluralFormatterOptions
) => string
export type SelectOrdinalFormatter = (
value: number,
cases: PluralFormatterOptions
) => string

export type SelectFormatter = (
value: string,
rules: Record<string, any>
) => string

export type NumberFormatter = (
value: number,
format: string | Intl.NumberFormatOptions
) => string

export type DateFormatter = (
value: string,
format: string | Intl.DateTimeFormatOptions
) => string

export type UndefinedFormatter = (value: unknown) => unknown
export type FormatterMap = {
plural: PluralFormatter
selectordinal: SelectOrdinalFormatter
select: SelectFormatter
number: NumberFormatter
date: DateFormatter
undefined: UndefinedFormatter
}
148 changes: 148 additions & 0 deletions packages/core/src/i18n.t.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
import { Replace, Simplify, Trim, UnionToIntersection } from "type-fest"
import { Formats, FormatterMap } from "./formatter"

type DropEscapedBraces<Input extends string> = Replace<
Replace<Input, `'{`, "", { all: true }>,
`}'`,
"",
{ all: true }
>

type ExtractNextBrace<
T extends string,
Acc extends string = ""
> = T extends `${infer Head}${infer Tail}`
? Head extends "{" | "}"
? [Acc, Head, Tail]
: ExtractNextBrace<Tail, `${Acc}${Head}`>
: never

/**
* Takes a string literal and recursively extracts the content inside curly braces.
* It expects that opening curly brace is omitted.
* @example `name}` -> 'name'
* @example `plural, one {just one} other {many}} and much more` -> 'plural, one {just one} other {many}'
*/
type ExtractBraceBody<
Input extends string, // The string to be processed.
OpenedBraceStack extends "{"[] = [], // A stack to keep track of opened curly braces.
Body extends string = "" // Accumulator to build up the content inside the braces.
> = string extends Input // Check if Input is a wide 'string' type rather than a string literal.
? string // If it is, return the wide 'string' type.
: Input extends "" // Check if the input string is empty.
? [Body, ""] // If it is, return the accumulated Body and an empty string as a tuple.
: ExtractNextBrace<Input> extends [
// Use ExtractNextBrace to get the next brace and the parts of the string before and after it.
infer Before extends string,
infer Brace,
infer After extends string
]
? Brace extends "{" // Check if the extracted brace is an opening brace '{'.
? ExtractBraceBody<
// If it is, recursively call ExtractBraceBody...
After, // ...with the rest of the string...
["{", ...OpenedBraceStack], // ...pushing '{' onto the stack...
`${Body}${Before}${Brace}` // ...and appending the Before part and the brace to the Body.
>
: // Otherwise, if Brace is not an opening '{' but a closing one '}'
OpenedBraceStack extends ["{", ...infer Rest extends "{"[]] // Check if there are any unmatched opening braces in the stack.
? ExtractBraceBody<
// If there are, recursively call ExtractBraceBody...
After, // ...with the rest of the string...
Rest, // ...popping the last '{' from the stack...
`${Body}${Before}}` // ...and appending the Before part and the closing brace '}' to the Body.
>
: // If the brace is a closing brace '}' and there are no unmatched opening braces...
[`${Body}${Before}`, After] // ...return the content inside the braces (Body + Before) and the rest of the string (After) as a tuple.
: never // If the string does not match the expected pattern, return 'never'.

type ExtractFormatterMessages<Input extends string> = string extends Input
? string[]
: Input extends ""
? []
: Input extends `${string}{${infer Tail}`
? ExtractBraceBody<Tail> extends [
infer BraceBody extends string,
infer Next extends string
]
? [BraceBody, ...ExtractFormatterMessages<Next>]
: []
: []

type ExtractFormatterId<Input extends string> =
Input extends `${infer FormatterId},${string}`
? Trim<FormatterId>
: Trim<Input>

type IsExistedFormatter<FormatterId extends string> =
FormatterId extends keyof FormatterMap ? FormatterId : never

type FormatterInputType<FormatterId extends string> =
FormatterMap[IsExistedFormatter<FormatterId>] extends infer FormatterFn
? FormatterFn extends (input: infer InputType, ...args: any[]) => any
? InputType
: never
: never

/**
* Takes a string literal and extracts interpolation variables from it.
* @example `My name is {name}, i'm from {city}` -> {name: string} | {city: string}
* @return union of objects with one property each
*/
type ExtractVars<Input extends string> = string extends Input // Check if the type of Input is the general 'string' type and not a string literal.
? Record<string, unknown> // If it is, return a wide type Record<string, unknown> to represent any object.
: // Start processing the literal string.
Input extends `${string}{${infer Tail}` // Check if the Input string contains '{', indicating the start of an expression.
? // Extract the content inside the curly braces and the rest of the string after the closing curly brace.
ExtractBraceBody<Tail> extends [
infer BraceBody extends string,
infer Next extends string
]
? BraceBody extends `${infer FormatterInput},${infer FormatterTail}` // Check if the content inside the braces is a formatter.
? // Process the formatter content.
| {
// Create a type with a property where the key is trimmed FormatterInput (which is var name in this case), and the value is determined by the formatter's type.
[P in Trim<FormatterInput>]: FormatterInputType<
ExtractFormatterId<FormatterTail>
>
}
| ExtractVars<ExtractFormatterMessages<FormatterTail>[number]> // Recursively process messages inside the formatter.
| ExtractVars<Next> // Recursively process the rest of the string after the current variable.
: // If not a formatter, create a type with a property where the key is the whole trimmed BraceBody and the value is a string
{ [P in Trim<BraceBody>]: string } | ExtractVars<Next> // and recursively process the rest of the string.
: {} // If the string does not contain a valid structure, return an empty object type (possibly we can return an error here, because this branch indicates parsing error)
Copy link
Collaborator

Choose a reason for hiding this comment

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

{} does not represent an "empty object" in TS, so ExtractVars might be behaving differently from what you expect

see https://www.totaltypescript.com/the-empty-object-type-in-typescript

Copy link
Author

Choose a reason for hiding this comment

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

Yes, thank you for pointing that out! I know about this specific issue, and it's actually the only place I also wasn't sure about :) In this context, it makes no difference because ExtractVars only applies if the input string contains interpolation. But in the general case, I should use never here, and it should be fixed, of course. Thank you again!

: {} // If the Input string does not contain '{', return an empty object type.

export type I18nTValues<Input extends string> = Simplify<
UnionToIntersection<ExtractVars<DropEscapedBraces<Input>>>
>

type I18nTDescriptorBasic = {
comment?: string
}

export type I18nTDescriptorById<Message extends string> = I18nTDescriptorBasic &
({} extends I18nTValues<Message>
? { id: Message }
: { id: Message; values: I18nTValues<Message> })

export type I18nTDescriptorByMessage<Message extends string> = ({
id: string
} & I18nTDescriptorBasic) &
({} extends I18nTValues<Message>
? { message: Message }
: { message: Message; values: I18nTValues<Message> })

export type I18nTOptions = {
formats?: Formats
comment?: string
}

export type I18nTOptionsWithMessage<Message extends string> = {
message: Message
} & I18nTOptions

export type I18nTMessageWithNoParams<Message extends string> =
DropEscapedBraces<Message> extends `${string}{${string}}${string}`
? never
: Message
Loading