-
Notifications
You must be signed in to change notification settings - Fork 405
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
Closed
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 865dfad
feat(core/i18n/t): take into account escape characters
dilame 2da35cf
feat(core/i18n/t): only allow omit values param object if message str…
dilame 61e92d4
chore(core/i18n/t): use type-fest lib instead of manually declared ut…
dilame 1777509
fix(core/i18n/t): trim interpolation parameter names
dilame ab1419f
refactor: message descriptor types for core and macro
dilame 7f9e45d
test: add tests for new strict string interpolation typings
dilame fdd3fd8
refactor: rename type I18nT to I18nTValues
dilame 01fa074
chore: yarn.lock type-fest
dilame a53d429
refactor: rename descriptor types
dilame 1bf144f
chore: remove unnecessary eslint-ignore
dilame 43edfb2
refactor: rename type I18nT to I18nTValues
dilame 1726d7c
refactor: rename type _ExtractVars to ExtractVars
dilame a866f4f
refactor(core/i18n.t): use wide record type for values in case of wid…
dilame 60bbaf8
test(core/i18n.t): make the most complex test case even more complex
dilame dfd9d8e
feat(core/i18n.t/values): add formatters strict typing support
dilame 01d6608
test(core/i18n.t): formatter typings
dilame 64c697b
fix(core/i18n.t/values): replace all escaped symbols instead of just …
dilame 53d904b
test(core/i18n.t/values): ensure all escaped symbols in string are dr…
dilame 9d29f2d
chore(core/i18n.t/values): remove unnecessary condition in type Extra…
dilame c8cf4d1
chore(core/i18n.t/values): write explanation comments at each line of…
dilame e42c01e
chore(core/i18n.t/values): write explanation comments at each line of…
dilame d8463d1
chore(core/i18n/t): combine excessive overloads into one signature
dilame 3fc4562
chore(core/i18n/t): rename parameter 'id' to '_' in overloads
dilame File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | ||
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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) | ||
: {} // 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 |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
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, soExtractVars
might be behaving differently from what you expectsee https://www.totaltypescript.com/the-empty-object-type-in-typescript
There was a problem hiding this comment.
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 usenever
here, and it should be fixed, of course. Thank you again!