skip to Main Content

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


  1. 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 because main field is mapped to src/index.js, and .ts extension takes precedence in Expo configuration.

    The solution is to either remove node_modules/@ouroboros/*/src/*.ts files in npm postinstall hook to avoid ambiguous imports.

    Or partially fix metro.config.js to make it closer to defaults and avoid the prioritization of .ts:

    const { getDefaultConfig } = require('expo/metro-config');
    const config = getDefaultConfig(__dirname);
    config.resolver.sourceExts = [...new Set(['js', 'jsx', ...config.resolver.sourceExts])];
    module.exports = config;
    
    Login or Signup to reply.
  2. 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 because main field maps it to src/index.js, and .ts extension takes precedence in Expo configuration.

    The solution is to either remove node_modules/@ouroboros/*/src/*.ts files in npm postinstall hook to avoid ambiguous imports.

    Or partially fix metro.config.js to make it closer to defaults and avoid the prioritization of .ts:

    const { getDefaultConfig } = require('expo/metro-config');
    const config = getDefaultConfig(__dirname);
    config.resolver.sourceExts = [...new Set(['js', 'jsx', ...config.resolver.sourceExts])];
    module.exports = config;
    
    Login or Signup to reply.
Please signup or login to give your own answer.
Back To Top
Search