Skip to content

Commit 8a0bbf5

Browse files
authored
feat: introduce locale proxy (#2004)
1 parent 2675ec2 commit 8a0bbf5

12 files changed

Lines changed: 378 additions & 25 deletions

File tree

docs/guide/upgrading.md

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,48 @@ for (let user of users) {
9797

9898
For more information refer to our [Localization Guide](localization).
9999

100+
### For missing locale data, Faker will now throw instead of returning `undefined` or `a`-`c`
101+
102+
::: note Note
103+
The following section mostly applies to custom-built Faker instances.
104+
:::
105+
106+
Previously, for example if `en` didn't have data for `animal.cat`, then `faker.animal.cat()` would have returned one of `a`, `b` or `c` (`arrayElement`'s default value).
107+
These values aren't expected/useful as a fallback and potentially also violate the method's defined return type definitions (in case it doesn't return a `string`).
108+
109+
We have now addressed this by changing the implementation so that an error is thrown, prompting you to provide/contribute the missing data.
110+
This will also give you detailed information which data are missing.
111+
If you want to check for data you can either use `entry in faker.definitions.category` or use `faker.rawDefinitions.category?.entry` instead.
112+
113+
```ts
114+
import { Faker, fakerES, es } from '@faker-js/faker';
115+
116+
const fakerES_noFallbacks = new Faker({
117+
locale: [es],
118+
});
119+
fakerES.music.songName(); // 'I Want to Hold Your Hand' (fallback from en)
120+
// Previously:
121+
//fakerES_noFallbacks.music.songName(); // 'b'
122+
// Now:
123+
fakerES_noFallbacks.music.songName(); // throws a FakerError
124+
```
125+
126+
This also has an impact on data that aren't applicable to a locale, for example Chinese doesn't use prefixes in names.
127+
128+
```ts
129+
import { faker, fakerZH_CN, zh_CN } from '@faker-js/faker';
130+
131+
const fakerZH_CN_noFallbacks = new Faker({
132+
locale: [zh_CN],
133+
});
134+
135+
faker.name.prefix(); // 'Mr'
136+
// Previously:
137+
//fakerZH_CN_noFallbacks.person.prefix(); // undefined
138+
// Now:
139+
fakerZH_CN.person.prefix(); // throws a FakerError
140+
```
141+
100142
### `faker.mersenne` and `faker.helpers.repeatString` removed
101143

102144
`faker.mersenne` and `faker.helpers.repeatString` were only ever intended for internal use, and are no longer available.

src/definitions/definitions.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,4 +52,4 @@ export type LocaleDefinition = {
5252
system?: SystemDefinitions;
5353
vehicle?: VehicleDefinitions;
5454
word?: WordDefinitions;
55-
} & Record<string, Record<string, unknown>>;
55+
} & Record<string, Record<string, unknown> | undefined>;

src/faker.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ import { FakerError } from './errors/faker-error';
33
import { deprecated } from './internal/deprecated';
44
import type { Mersenne } from './internal/mersenne/mersenne';
55
import mersenne from './internal/mersenne/mersenne';
6+
import type { LocaleProxy } from './locale-proxy';
7+
import { createLocaleProxy } from './locale-proxy';
68
import { AirlineModule } from './modules/airline';
79
import { AnimalModule } from './modules/animal';
810
import { ColorModule } from './modules/color';
@@ -59,7 +61,8 @@ import { mergeLocales } from './utils/merge-locales';
5961
* customFaker.music.genre(); // throws Error as this data is not available in `es`
6062
*/
6163
export class Faker {
62-
readonly definitions: LocaleDefinition;
64+
readonly rawDefinitions: LocaleDefinition;
65+
readonly definitions: LocaleProxy;
6366
private _defaultRefDate: () => Date = () => new Date();
6467

6568
/**
@@ -329,7 +332,8 @@ export class Faker {
329332
locale = mergeLocales(locale);
330333
}
331334

332-
this.definitions = locale as LocaleDefinition;
335+
this.rawDefinitions = locale as LocaleDefinition;
336+
this.definitions = createLocaleProxy(this.rawDefinitions);
333337
}
334338

335339
/**

src/locale-proxy.ts

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
import type { LocaleDefinition } from './definitions';
2+
import { FakerError } from './errors/faker-error';
3+
4+
/**
5+
* A proxy for LocaleDefinitions that marks all properties as required and throws an error when an entry is accessed that is not defined.
6+
*/
7+
export type LocaleProxy = Readonly<{
8+
[key in keyof LocaleDefinition]-?: Readonly<
9+
Required<NonNullable<LocaleDefinition[key]>>
10+
>;
11+
}>;
12+
13+
const throwReadOnlyError: () => never = () => {
14+
throw new FakerError('You cannot edit the locale data on the faker instance');
15+
};
16+
17+
/**
18+
* Creates a proxy for LocaleDefinition that throws an error if an undefined property is accessed.
19+
*
20+
* @param locale The locale definition to create the proxy for.
21+
*/
22+
export function createLocaleProxy(locale: LocaleDefinition): LocaleProxy {
23+
const proxies = {} as LocaleDefinition;
24+
return new Proxy(locale, {
25+
has(): true {
26+
// Categories are always present (proxied), that's why we return true.
27+
return true;
28+
},
29+
30+
get(
31+
target: LocaleDefinition,
32+
categoryName: keyof LocaleDefinition
33+
): LocaleDefinition[keyof LocaleDefinition] {
34+
if (categoryName in proxies) {
35+
return proxies[categoryName];
36+
}
37+
38+
return (proxies[categoryName] = createCategoryProxy(
39+
categoryName,
40+
target[categoryName]
41+
));
42+
},
43+
44+
set: throwReadOnlyError,
45+
deleteProperty: throwReadOnlyError,
46+
}) as LocaleProxy;
47+
}
48+
49+
/**
50+
* Creates a proxy for a category that throws an error when accessing an undefined property.
51+
*
52+
* @param categoryName The name of the category.
53+
* @param categoryData The module to create the proxy for.
54+
*/
55+
function createCategoryProxy<
56+
CategoryData extends Record<string | symbol, unknown>
57+
>(
58+
categoryName: string,
59+
categoryData: CategoryData = {} as CategoryData
60+
): Required<CategoryData> {
61+
return new Proxy(categoryData, {
62+
has(target: CategoryData, entryName: keyof CategoryData): boolean {
63+
const value = target[entryName];
64+
return value != null;
65+
},
66+
67+
get(
68+
target: CategoryData,
69+
entryName: keyof CategoryData
70+
): CategoryData[keyof CategoryData] {
71+
const value = target[entryName];
72+
if (value === null) {
73+
throw new FakerError(
74+
`The locale data for '${categoryName}.${entryName.toString()}' aren't applicable to this locale.
75+
If you think this is a bug, please report it at: https://github.com/faker-js/faker`
76+
);
77+
} else if (value === undefined) {
78+
throw new FakerError(
79+
`The locale data for '${categoryName}.${entryName.toString()}' are missing in this locale.
80+
Please contribute the missing data to the project or use a locale/Faker instance that has these data.
81+
For more information see https://next.fakerjs.dev/guide/localization.html`
82+
);
83+
} else {
84+
return value;
85+
}
86+
},
87+
88+
set: throwReadOnlyError,
89+
deleteProperty: throwReadOnlyError,
90+
}) as Required<CategoryData>;
91+
}

src/modules/helpers/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1160,7 +1160,7 @@ export class HelpersModule {
11601160
const parts = method.split('.');
11611161

11621162
let currentModuleOrMethod: unknown = this.faker;
1163-
let currentDefinitions: unknown = this.faker.definitions;
1163+
let currentDefinitions: unknown = this.faker.rawDefinitions;
11641164

11651165
// Search for the requested method or definition
11661166
for (const part of parts) {

src/modules/location/index.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -73,8 +73,7 @@ export class LocationModule {
7373
const { state } = options;
7474

7575
if (state) {
76-
const zipRange =
77-
this.faker.definitions.location.postcode_by_state?.[state];
76+
const zipRange = this.faker.definitions.location.postcode_by_state[state];
7877

7978
if (zipRange) {
8079
return String(this.faker.number.int(zipRange));

src/modules/person/index.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -102,7 +102,7 @@ export class PersonModule {
102102
*/
103103
firstName(sex?: SexType): string {
104104
const { first_name, female_first_name, male_first_name } =
105-
this.faker.definitions.person;
105+
this.faker.rawDefinitions.person ?? {};
106106

107107
return selectDefinition(this.faker, this.faker.helpers.arrayElement, sex, {
108108
generic: first_name,
@@ -132,7 +132,7 @@ export class PersonModule {
132132
last_name_pattern,
133133
male_last_name_pattern,
134134
female_last_name_pattern,
135-
} = this.faker.definitions.person;
135+
} = this.faker.rawDefinitions.person ?? {};
136136

137137
if (
138138
last_name_pattern != null ||
@@ -174,7 +174,7 @@ export class PersonModule {
174174
*/
175175
middleName(sex?: SexType): string {
176176
const { middle_name, female_middle_name, male_middle_name } =
177-
this.faker.definitions.person;
177+
this.faker.rawDefinitions.person ?? {};
178178

179179
return selectDefinition(this.faker, this.faker.helpers.arrayElement, sex, {
180180
generic: middle_name,
@@ -315,7 +315,7 @@ export class PersonModule {
315315
*/
316316
prefix(sex?: SexType): string {
317317
const { prefix, female_prefix, male_prefix } =
318-
this.faker.definitions.person;
318+
this.faker.rawDefinitions.person ?? {};
319319

320320
return selectDefinition(this.faker, this.faker.helpers.arrayElement, sex, {
321321
generic: prefix,

test/all_functional.spec.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import type { allLocales, Faker, RandomModule } from '../src';
33
import { allFakers, fakerEN } from '../src';
44

55
const IGNORED_MODULES = [
6+
'rawDefinitions',
67
'definitions',
78
'helpers',
89
'_mersenne',

test/faker.spec.ts

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -30,16 +30,33 @@ describe('faker', () => {
3030
}
3131
});
3232

33+
describe('rawDefinitions', () => {
34+
it('locale rawDefinition accessibility', () => {
35+
// Metadata
36+
expect(faker.rawDefinitions.metadata.title).toBeDefined();
37+
// Standard modules
38+
expect(faker.rawDefinitions.location?.city_name).toBeDefined();
39+
// Non-existing module
40+
expect(faker.rawDefinitions.missing).toBeUndefined();
41+
// Non-existing definition in a non-existing module
42+
expect(faker.rawDefinitions.missing?.missing).toBeUndefined();
43+
// Non-existing definition in an existing module
44+
expect(faker.rawDefinitions.location?.missing).toBeUndefined();
45+
});
46+
});
47+
3348
describe('definitions', () => {
34-
it('locale definition accessability', () => {
49+
it('locale definition accessibility', () => {
3550
// Metadata
3651
expect(faker.definitions.metadata.title).toBeDefined();
3752
// Standard modules
3853
expect(faker.definitions.location.city_name).toBeDefined();
3954
// Non-existing module
40-
expect(faker.definitions.missing).toBeUndefined();
55+
expect(faker.definitions.missing).toBeDefined();
56+
// Non-existing definition in a non-existing module
57+
expect(() => faker.definitions.missing.missing).toThrow();
4158
// Non-existing definition in an existing module
42-
expect(faker.definitions.location.missing).toBeUndefined();
59+
expect(() => faker.definitions.location.missing).toThrow();
4360
});
4461
});
4562

0 commit comments

Comments
 (0)