개발/Front-end

Cannot find module or its corresponding type declarations 오류 (feat. package.jso

devriver 2024. 4. 5. 16:56

최근 typescript로 개발된 라이브러리를 배포했습니다. 번들러는 tsup을 이용했습니다.
rollup과 고민하다가 tsup을 선택했는데 나중에 이 부분도 정리해서 올리도록 하겠습니다. (다짐)
 
오늘 다룰 부분은 배포한 라이브러리를 서비스에서 사용할 때 겪은 이슈입니다.
 
오류에 대해 자세히 말씀드리면,
Nextjs로 만들어진 서비스에서 B 라이브러리를 Install하고 페이지 코드에서 `import B from '@library/b/subpath-a' 를 했습니다.
그 외 한 게 없습니다. 근데,   Cannot find module @library/b/subpath-a' or its corresponding type declarations 
에러가 떴습니다. 타입 에러가 났지만 사용하는데 문제는 없었습니다. 즉 모듈을 못 찾는게 아니라 type declarations를 못 찾는거였죠.
 
일단 라이브러리의 package.json과 tsconfig.json을 확인해보겠습니다. 
참고로 타입스크립트 버전은 5.4.3입니다.
 
 


 
 
먼저 라이브러리의 package.json부터 간단히 살펴볼게요.
 
type에는 모듈의 포맷이 들어가는 데 보시다시피 module로 셋팅해두었습니다.
이는 .js 확장자를 가진 파일들을 ESM 모듈로 보고 로드를 한다는 의미입니다.
브라우저 환경에서만 지원할 예정이라 CJS는 고려하지 않았습니다.
 
files 필드는 npm publish 할 때 어떤 파일들을 포함시킬지 결정합니다.
빌드 결과물인 dist와 package.json을 포함시켰습니다.
 
이제 이번 에러와 관련이 깊은 exports 필드가 살펴봅시다.
exports를 이용해서 패키지의 진입점을 설정할 수 있습니다. 보시다시피 여러 sub-path를 진입점으로 만들 수도 있구요.
저는 from '@library/b'  from '@library/sub-path-a'  from '@library/sub-path-b'  이렇게 3개의 진입점을 만들고 싶어서 아래처럼 작성을 했습니다.

{
  "name": "@library/b",
  "version": "0.0.0",
  "type": "module",
  "files": [
    "dist",
    "package.json"
  ],
  "exports": {
    ".": "./dist/index.js",
    "./sub-path-a": "./dist/sub-path-a/index.js",
    "./sub-path-b": "./dist/sub-path-b/index.js",
  },
  "scripts": {
    "build": "tsup ./src",
    "dev": "yarn build --watch",
    "lint": "eslint . --ext ts --max-warnings 0"
  },
  "devDependencies": {
    "@typescript-eslint/eslint-plugin": "^7.2.0",
    "@typescript-eslint/parser": "^7.2.0",
    "eslint": "^8.57.0",
    "tsup": "^8.0.2",
    "typescript": "^5.4.3"
  }
}

 
 
 
그 다음으로 tsconfig를 살펴보겠습니다.
 
target은 ES6로 지정했습니다. IE와 같이 올드 브라우저를 지원하지 않을 예정이라서요.
 
lib에 DOM, DOM.Iterable, ESNext를 추가했습니다. [High Level libraries]
타입스크립트는 기본적으로 자바스크립트 API에 대해 타입을 지원합니다.
물론 target field에 따라 포함하지 않을 경우가 있어요. DOM, DOM.Iterable, ESNext를 추가한 이유가 여기에 있습니다.
바로 기본적으로 빌트인 되어있지 않기 때문이죠. DOM의 경우는 프로그램이 브라우저에서 동작할지 안할지 모르기 때문에 빠져있는 것 같아요. 브라우저 환경에서 프로그램을 돌린다면 DOM, DOM.Iterable를 추가해야합니다. ESNext은 경우는 최신 API 스펙이 생기면 그 변경이 빠르게 반영되기 때문에 추가하시면 편합니다.
 
outDir을 dist로 지정하여 컴파일 결과물이 위치할 디렉토리를 정했습니다.
declaration을 true로 지정하여 타입스크립트 컴파일 할 때 .d.ts 파일도 같이 만들 수 있도록 했습니다.
 
module 필드는 컴파일 결과의 포맷을 결정해줍니다. node16, nodenext, esnext, commonjs 등등이 있죠.
node16, nodenext의 경우는 대부분의 앱에 적절하긴 하지만, node 12 또는 그 이후 버전 위에서 돌아가는 라이브러리를 만들 때 가장 적절합니다. 이 두 포맷은 cjs, esm 두 개의 포맷으로 컴파일할 수 있기 때문에 esm도 지원할 수 있습니다. 물론 아무런 조건없이 cjs, esm 포맷으로 컴파일 하는 건 아니고 각 파일 확장자 또는 package json의 type 필드를 보고 처리합니다. [Module format detection]
 
module을 esnext로 설정하면 파일은 ESM 포맷으로 컴파일됩니다. 만약 Nodejs 환경에서 돌아가는 서비스지만 ESM 포맷으로 컴파일하고 싶다면, esnext보다는 앞에서 설명드린 node16nodenext로 설정하고 package.json에 "type":"module"을 추가하는 걸 권장합니다. 이렇게 하면 Nodejs에서 잘 돌아갈 ESM 포맷으로 컴파일됩니다. 한가지 주의할 게, module을 esnext로 설정했다면 moduleResolution을 반드시 bundler로 설정해야합니다.
 
moduleResolution은 말 그대로 module resolution 전략을 말하고 node16, nodenext, node, bundler 등이 있어요.
node16, nodenext는 esm import, cjs require을 모두 지원합니다.
bundler로 하게되면 node16, nodenext와 동일하게 package.json의 imports exports 필드를 사용할 수 있게됩니다. 하지만 nodejs resolution 모드와 달리 bundler는 파일의 확장자를 지정할 수가 없고 상대 경로 import도 불가능합니다.

{
  "compilerOptions": {
    "baseUrl": ".",
    "paths": {
      "@/*": ["src/*"]
    },
    "esModuleInterop": true,
    "forceConsistentCasingInFileNames": true,
    "isolatedModules": true,
    "module": "ESNext",
    "moduleResolution": "Bundler",
    "preserveWatchOutput": true,
    "skipLibCheck": true,
    "strict": true,
    "target": "ES6",
    "lib": ["DOM", "DOM.Iterable", "ESNext"],
    "outDir": "dist",
    "declaration": true
  },
  "include": ["src"],
  "exclude": ["node_modules"]
}

 
 
 


 
 
사실 이 오류를 전에도 겪은 적이 있습니다.
라이브러리를 배포하고 import 해서 쓰려니 sub path를 쓸 수가 없었죠.
그 때 당시에는 어떻게 해결했는지와 이번엔 어떻게 해결하는 지를 같이 공유드리겠습니다.
 
과거에는 package.json의 typeVersions 필드를 이용했습니다.
사실 아래처럼 typeVersions를 추가해주면 에러는 사라집니다.
 
근데 마음에 들진 않았죠.
typeVersions 없이도 sub-path를 잘 지원하고 있는 라이브러리가 많았고 용도에 맞는 사용이 아니라고 생각했습니다.

{
  ... 동일 ...
  
  "typesVersions": {
    "*": {
      "*": [
        "dist/index.d.ts"
      ],
      "sub-path-a": [
        "./dist/sub-path-a/index.d.ts"
      ]
      "sub-path-b": [
        "./dist/sub-path-b/index.d.ts"
      ]
    }
  }
}

 
typeVersions는 3.1부터 지원하기 시작했고 타입스크립트 버전별로 적절한 엔트리 포인트를 지정할 수 있게 만들어줍니다.
아래 예제는 3.1 버전부터는 ts3.1 폴더에 있는 파일들을 읽어라는 의미입니다.
특정 버전에 따른 패키지 위치를 지정하기를 바라는 거죠.

{
  "name": "package-name",
  "version": "1.0.0",
  "types": "./index.d.ts",
  "typesVersions": {
    ">=3.1": { "*": ["ts3.1/*"] }
  }
}

 
사실 package.json의 exports처럼 기존의 main 필드를 대체할 수 있으면서 여러 진입점을 만들 수 있는 필드가 있는데 typesVersions 를 쓰기는 좀 아쉽죠. 그래서 저는 일단 exports만 유지할 수 있는 방향으로 더 알아 봤습니다.
 
 


 
 
여러 고민을 하다가 라이브러리를 수정하는 게 맞나라는 의문이 들었습니다.
라이브러리를 사용하는 서비스의 tsconfig나 package json의 이슈가 아닐까? 하구요.
더 나아가 tsconfig의 moduleResolution이 모듈을 어떻게 가져오는지를 결정하는 녀석인데 그게 이상한 건 아닐까? 했구요.
 
그럼 서비스의 tsconfig를 살펴볼게요.

{
  "compilerOptions": {
    "target": "es5",
    "lib": ["dom", "dom.iterable", "esnext"],
    "allowJs": true,
    "skipLibCheck": true,
    "strict": true,
    "forceConsistentCasingInFileNames": true,
    "noEmit": true,
    "esModuleInterop": true,
    "module": "esnext",
    "moduleResolution": "node",
    "resolveJsonModule": true,
    "isolatedModules": true,
    "jsx": "preserve",
    "incremental": true,
  },
  
  ... 생략 ...
}

 
보면 module은 esnext, moduleResolution은 node로 설정되어있습니다.
앞에서 moduleResolution이 node일 때의 설명을 생략했는데 여기서 정리해보겠습니다.
node10 이전 버전을 이용하는 서비스를 위해 존재하고 오직 commonJS의 require만 지원합니다.
 
정리하자면, 서비스는 cjs를 지원하는 라이브러리만 가져올 수 있는 상황이였는데 esm 포맷만 지원하는 b 라이브러리를 import해서 오류가 발생했습니다.
 
moduleResolution을 bundler로 바꿔보면 잘 동작하는 걸 보실 수 있습니다. 야~ 호~

{
  "compilerOptions": {
     ... 생략 ...
    "module": "esnext",
    "moduleResolution": "bundler"
  },
  
  ... 생략 ...
}

 
 
 


 
 
오늘 글은 여기까지입니다.
원인 파악을 위해 tsconfig.json, package.json 문서를 살펴보았고 각 필드의 연관성도 알게 되었습니다.
문서를 보고 다시 문제에 접근하니 이슈의 원인을 빠르게 캐치할 수 있었던 것 같습니다.
문서 보기의 중요성을 다시 깨닫고 이만 인사드리겠습니다. 바이바이~~