I have an npm module called Body. (https://github.com/ouroboroscoding/body-js) It exports a single instance of an object.
const body = new Body();
export default body;
I have a second npm module, called Brain. (https://github.com/ouroboroscoding/brain-js) Brain requires the use of body, the instance, not the class, so it imports it like so.
import body from '@ouroboros/body';
and exports itself
const brain = new Brain();
export default brain;
Both packages contain .ts, .d.ts, and .js files for all modules.
Before anything can be used from body, it needs to be passed a specific string. This is done in the top level project using these packages.
import body from '@ouroboros/body';
body.domain('somedomain.com');
In projects where I use JavaScript exclusively, I have never run into an issue, however recently I started a React Native project using Expo, which is using tsx files for components, and any time brain tried to use body, it would throw errors that it was missing the domain, despite it absolutely being set.
At this point I added a unique constructor to body just to add a console.log line, and this is what came out.
typescript constructor index.ts:111
javascript constructor index.js:54
Everything about how TypeScript / JavaScript works says it’s impossible for a module to be evaluated twice, so by definition it’s impossible for the Contructor to be called twice unless both the ts and js files are being loaded as different imports.
Why would the same call of import body from '@ouroboros/body';
in two different files end up loading different versions of the file? What makes one choose ts over js and vice versa?
Also, thanks to StackOverflow suggestions, I came across the "preferTsExts" option, added it as true to tsconfig.json, but nothing changed, still 2 instances.
Reproducible steps:
$: npx create-expo-app@latest ExpoTest
$: cd ExpoTest
$: npm install @ouroboros/body @ouroboros/brain
then in ExpoTest/app/_layout.tsx add
import body from '@ouroboros/body';
import brain from '@ouroboros/brain';
body.domain('somedomain.com');
And the body instance is created twice, once from ts, once from js.
If you absolutely must see the output to believe it, add the following to @ouroboros/body/src/index.ts
constructor() {
console.log('typescript constructor');
}
and add the following to @ouroboros/body/src/index.js
constructor() {
console.log('javascript constructor');
}
First time you run the app, no reload, no hot refresh, first time, you will see
typescript constructor
javascript constructor
2
Answers
This seems to be a bug in Expo configuration that needs to be reported. It doesn’t respect
main
, etc entry point fields and prioritizes .ts extension over .js everywhere including NPM modules. This results in unexpected behaviour that differs from TypeScript’s own resolution of Node modules.Another part of the problem is that
@ouroboros
packages have source and built files mixed in /src, which is rare and unconventional; they should’ve been in separate folders."brain" may not be related, the problem is reproducible with "body" alone. The latter contains
./
import inside the package that’s resolved to index.js. But@ouroboros/body
is resolved to@ouroboros/body/src/index.ts
becausemain
field is mapped tosrc/index.js
, and .ts extension takes precedence in Expo configuration.The solution is to either remove
node_modules/@ouroboros/*/src/*.ts
files in npmpostinstall
hook to avoid ambiguous imports.Or partially fix metro.config.js to make it closer to defaults and avoid the prioritization of .ts:
This seems to be a bug in Expo configuration that needs to be reported. It doesn’t respect
main
, etc entry point fields and prioritizes .ts extension over .js everywhere including NPM modules. This results in unexpected behaviour that differs from TypeScript’s own resolution of Node modules.Another part of the problem is that
@ouroboros
packages have source and built files mixed in /src, which is rare and unconventional; they should’ve been in separate folders."brain" may not be related, the problem is reproducible with "body" alone. The latter contains
./
import inside the package that’s resolved to index.js. But@ouroboros/body
is resolved to@ouroboros/body/src/index.ts
becausemain
field maps it tosrc/index.js
, and .ts extension takes precedence in Expo configuration.The solution is to either remove
node_modules/@ouroboros/*/src/*.ts
files in npmpostinstall
hook to avoid ambiguous imports.Or partially fix metro.config.js to make it closer to defaults and avoid the prioritization of .ts: