Skip to content

Commit c6b0d07

Browse files
authored
feat(use-i18n): add type safety (#865)
* feat(use-i18n): add type safety * fix: type errors * fix: add InitialScopedTranslateFn * chore: use separate tsconfig for typecheck * chore: add documentation about types * feat: add prefix * feat: param required if value has params * fix: type errors * fix: scoped types parameters * fix: remove unused type * fix: return all scopes if previous exists * refactor: use international-types * refactor: remove prefix * chore: remove typecheck specific tsconfig * chore: add typetests to eslintignore * refactor: remove prefix * chore: export types * chore(deps): bump international-types to 0.3.3
1 parent 23350dc commit c6b0d07

File tree

11 files changed

+468
-350
lines changed

11 files changed

+468
-350
lines changed

.eslintignore

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,4 +2,4 @@
22
node_modules/
33
dist/
44
build/
5-
5+
__typetests__/

jest.config.tsd.mjs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
export default {
2+
displayName: {
3+
color: 'blue',
4+
name: 'types',
5+
},
6+
runner: 'jest-runner-tsd',
7+
testMatch: ['**/__typetests__/*.test.ts'],
8+
};

package.json

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
"@rollup/plugin-node-resolve": "13.3.0",
1919
"@testing-library/jest-dom": "5.16.5",
2020
"@testing-library/react": "13.3.0",
21+
"@tsd/typescript": "^4.7.4",
2122
"@types/jest": "28.1.7",
2223
"@types/node": "17.0.31",
2324
"@types/prop-types": "15.7.5",
@@ -31,6 +32,7 @@
3132
"jest-environment-jsdom": "28.1.3",
3233
"jest-junit": "14.0.0",
3334
"jest-localstorage-mock": "2.4.22",
35+
"jest-runner-tsd": "^3.1.0",
3436
"lerna": "5.4.3",
3537
"lint-staged": "13.0.3",
3638
"mockdate": "3.0.5",
@@ -41,6 +43,7 @@
4143
"rollup": "2.78.1",
4244
"rollup-plugin-dts": "4.2.2",
4345
"rollup-plugin-visualizer": "5.7.1",
46+
"tsd-lite": "^0.5.6",
4447
"typescript": "4.8.2",
4548
"wait-for-expect": "3.0.2"
4649
},
@@ -54,6 +57,7 @@
5457
"test": "TZ=UTC jest",
5558
"test:watch": "pnpm run test --watch",
5659
"test:coverage": "pnpm run test --coverage",
60+
"test:types": "jest -c jest.config.tsd.mjs",
5761
"prepare": "husky install"
5862
},
5963
"pnpm": {
@@ -96,7 +100,8 @@
96100
"packages/*/src/**/*.{ts,tsx,js,jsx}"
97101
],
98102
"modulePathIgnorePatterns": [
99-
"locales"
103+
"locales",
104+
"__typetests__"
100105
],
101106
"coverageReporters": [
102107
"text",

packages/use-i18n/package.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,12 +31,14 @@
3131
"@formatjs/fast-memoize": "1.2.6",
3232
"date-fns": "2.29.2",
3333
"filesize": "9.0.11",
34+
"international-types": "0.3.3",
3435
"intl-messageformat": "10.1.3",
3536
"prop-types": "15.8.1"
3637
},
3738
"peerDependencies": {
3839
"date-fns": "2.x",
3940
"react": "18.x",
40-
"react-dom": "18.x"
41+
"react-dom": "18.x",
42+
"international-types": "0.3.3"
4143
}
4244
}
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import { expectError, expectType } from 'tsd-lite'
2+
import { useI18n } from '../usei18n'
3+
4+
// eslint-disable-next-line react-hooks/rules-of-hooks
5+
const { namespaceTranslation } = useI18n<{
6+
hello: 'world'
7+
'doe.john': 'John Doe'
8+
'doe.jane': 'Jane Doe'
9+
'doe.child': 'Child is {name}'
10+
'describe.john': '{name} is {age} years old'
11+
}>()
12+
13+
// Single key
14+
expectError(namespaceTranslation('hello'))
15+
16+
// Multiple keys
17+
expectError(namespaceTranslation('doe.john'))
18+
const scopedT1 = namespaceTranslation('doe')
19+
expectType<string>(scopedT1('john'))
20+
expectError(scopedT1('doesnotexists'))
21+
22+
// With a param
23+
const scopedT2 = namespaceTranslation('doe')
24+
expectError(scopedT2('child'))
25+
expectType<string>(
26+
scopedT2('child', {
27+
name: 'Name',
28+
}),
29+
)
30+
expectError(
31+
scopedT2('doesnotexists', {
32+
name: 'Name',
33+
}),
34+
)
35+
expectError(
36+
scopedT2('child', {
37+
doesnotexists: 'Name',
38+
}),
39+
)
40+
expectError(scopedT2('child', {}))
41+
expectError(scopedT2('child'))
42+
43+
// With multiple params
44+
const scopedT3 = namespaceTranslation('describe')
45+
expectType<string>(
46+
scopedT3('john', {
47+
age: '30',
48+
name: 'John',
49+
}),
50+
)
51+
expectError(scopedT3('john', {}))
52+
expectError(scopedT3('john'))
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import { expectError, expectType } from 'tsd-lite'
2+
import { useI18n } from '../usei18n'
3+
4+
// eslint-disable-next-line react-hooks/rules-of-hooks
5+
const { t } = useI18n<{
6+
hello: 'world'
7+
'doe.john': 'John Doe'
8+
'doe.jane': 'Jane Doe'
9+
'doe.child': 'Child is {name}'
10+
'describe.john': '{name} is {age} years old'
11+
}>()
12+
13+
// Single key
14+
expectType<string>(t('hello'))
15+
expectError(t('keydoesnotexists'))
16+
17+
// Multiple keys
18+
expectType<string>(t('doe.john'))
19+
expectError(t('doe.doesnotexists'))
20+
21+
// With a param
22+
expectError(t('doe.child'))
23+
expectType<string>(
24+
t('doe.child', {
25+
name: 'Name',
26+
}),
27+
)
28+
expectError(
29+
t('doe.doesnotexists', {
30+
name: 'Name',
31+
}),
32+
)
33+
expectError(
34+
t('doe.child', {
35+
doesnotexists: 'Name',
36+
}),
37+
)
38+
expectError(t('doe.child', {}))
39+
expectError(t('doe.child'))
40+
41+
// With multiple params
42+
expectType<string>(
43+
t('describe.john', {
44+
age: '30',
45+
name: 'John',
46+
}),
47+
)
48+
expectError(t('describe.john', {}))
49+
expectError(t('describe.john'))

packages/use-i18n/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import I18nContextProvider from './usei18n'
22

33
export * from './usei18n'
4+
export * from './types'
45

56
export default I18nContextProvider

packages/use-i18n/src/types.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import type {
2+
BaseLocale,
3+
LocaleKeys,
4+
LocaleValue,
5+
Params,
6+
ParamsObject,
7+
ScopedValue,
8+
Scopes,
9+
} from 'international-types'
10+
11+
export type TranslateFn<Locale extends BaseLocale> = <
12+
Key extends LocaleKeys<Locale, undefined>,
13+
Value extends LocaleValue = ScopedValue<Locale, undefined, Key>,
14+
>(
15+
key: Key,
16+
...params: Params<Value>['length'] extends 0 ? [] : [ParamsObject<Value>]
17+
) => string
18+
19+
export type ScopedTranslateFn<Locale extends BaseLocale> = <
20+
Scope extends Scopes<Locale>,
21+
>(
22+
scope: Scope,
23+
) => <
24+
Key extends LocaleKeys<Locale, Scope>,
25+
Value extends LocaleValue = ScopedValue<Locale, Scope, Key>,
26+
>(
27+
key: Key,
28+
...params: Params<Value>['length'] extends 0 ? [] : [ParamsObject<Value>]
29+
) => string

packages/use-i18n/src/usei18n.tsx

Lines changed: 28 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
import type { NumberFormatOptions } from '@formatjs/ecma402-abstract'
22
import {
3-
Locale,
3+
Locale as DateFnsLocale,
44
formatDistanceToNow,
55
formatDistanceToNowStrict,
66
} from 'date-fns'
7+
import type { BaseLocale, LocaleValue } from 'international-types'
78
import PropTypes from 'prop-types'
89
import {
910
ReactElement,
@@ -19,17 +20,20 @@ import ReactDOM from 'react-dom'
1920
import dateFormat, { FormatDateOptions } from './formatDate'
2021
import unitFormat, { FormatUnitOptions } from './formatUnit'
2122
import formatters, { IntlListFormatOptions } from './formatters'
23+
import type { ScopedTranslateFn, TranslateFn } from './types'
2224

2325
const LOCALE_ITEM_STORAGE = 'locale'
2426

25-
type PrimitiveType = string | number | boolean | null | undefined | Date
27+
type TranslationsByLocales = Record<string, BaseLocale>
2628

27-
type Translations = Record<string, string> & { prefix?: string }
28-
type TranslationsByLocales = Record<string, Translations>
29-
type TranslateFn = (
29+
export type InitialTranslateFn = (
3030
key: string,
31-
context?: Record<string, PrimitiveType>,
31+
context?: Record<string, LocaleValue>,
3232
) => string
33+
export type InitialScopedTranslateFn = (
34+
namespace: string,
35+
t?: InitialTranslateFn,
36+
) => InitialTranslateFn
3337

3438
const prefixKeys = (prefix: string) => (obj: { [key: string]: string }) =>
3539
Object.keys(obj).reduce((acc: { [key: string]: string }, key) => {
@@ -65,9 +69,9 @@ const getCurrentLocale = ({
6569
)
6670
}
6771

68-
interface Context {
72+
interface Context<Locale extends BaseLocale | undefined = undefined> {
6973
currentLocale: string
70-
dateFnsLocale?: Locale
74+
dateFnsLocale?: DateFnsLocale
7175
datetime: (
7276
date: Date | number,
7377
options?: Intl.DateTimeFormatOptions,
@@ -85,7 +89,9 @@ interface Context {
8589
) => Promise<string>
8690
locales: string[]
8791
namespaces: string[]
88-
namespaceTranslation: (namespace: string, t?: TranslateFn) => TranslateFn
92+
namespaceTranslation: Locale extends BaseLocale
93+
? ScopedTranslateFn<Locale>
94+
: InitialScopedTranslateFn
8995
relativeTime: (
9096
date: Date | number,
9197
options?: {
@@ -103,19 +109,21 @@ interface Context {
103109
) => string
104110
setTranslations: React.Dispatch<React.SetStateAction<TranslationsByLocales>>
105111
switchLocale: (locale: string) => void
106-
t: TranslateFn
112+
t: Locale extends BaseLocale ? TranslateFn<Locale> : InitialTranslateFn
107113
translations: TranslationsByLocales
108114
}
109115

110116
const I18nContext = createContext<Context | undefined>(undefined)
111117

112-
export const useI18n = (): Context => {
118+
export function useI18n<
119+
Locale extends BaseLocale | undefined = undefined,
120+
>(): Context<Locale> {
113121
const context = useContext(I18nContext)
114122
if (context === undefined) {
115123
throw new Error('useI18n must be used within a I18nProvider')
116124
}
117125

118-
return context
126+
return context as unknown as Context<Locale>
119127
}
120128

121129
export const useTranslation = (
@@ -149,7 +157,7 @@ type LoadTranslationsFn = ({
149157
}: {
150158
namespace: string
151159
locale: string
152-
}) => Promise<{ default: Translations }>
160+
}) => Promise<{ default: BaseLocale }>
153161
type LoadLocaleFn = (locale: string) => Promise<Locale>
154162

155163
const I18nContextProvider = ({
@@ -209,7 +217,7 @@ const I18nContextProvider = ({
209217
namespace,
210218
})
211219

212-
const trad: Translations = {
220+
const trad: Record<string, string> = {
213221
...result.defaultLocale.default,
214222
...result[currentLocale].default,
215223
}
@@ -321,9 +329,9 @@ const I18nContextProvider = ({
321329
[dateFnsLocale],
322330
)
323331

324-
const translate = useCallback<TranslateFn>(
325-
(key: string, context?: Record<string, PrimitiveType>) => {
326-
const value = translations[currentLocale]?.[key]
332+
const translate = useCallback<InitialTranslateFn>(
333+
(key, context) => {
334+
const value = translations[currentLocale]?.[key] as string
327335
if (!value) {
328336
if (enableDebugKey) {
329337
return key
@@ -342,9 +350,9 @@ const I18nContextProvider = ({
342350
[currentLocale, translations, enableDebugKey],
343351
)
344352

345-
const namespaceTranslation = useCallback(
346-
(namespace: string, t: TranslateFn = translate) =>
347-
(identifier: string, context?: Record<string, PrimitiveType>) =>
353+
const namespaceTranslation = useCallback<InitialScopedTranslateFn>(
354+
(namespace, t = translate) =>
355+
(identifier, context) =>
348356
t(`${namespace}.${identifier}`, context) || t(identifier, context),
349357
[translate],
350358
)

0 commit comments

Comments
 (0)