openlang

ESM default import인데 타입은 CommonJS

모노레포에서 형제 패키지 하나를 ESM 쪽에서 가져다 쓰다가 막혔어요.

@repo/ui의 package.json에는 "type" 필드가 없고, exports는 이렇게 돼 있어요. "types": "./dist/index.d.ts", "import": "./dist/index.mjs", "require": "./dist/index.cjs". 쓰는 쪽은 "type": "module"이고 moduleResolution은 nodenext예요.

자, import ui from '@repo/ui' 쓰고 ui(까지 쳐 봤어요. 에디터가 호출할 수 없는 값이래요. ui.을 치니까 자동 완성에 default가 떠요. (여기서 좀 웃겼어요.) 그래서 ui.default()로 바꾸면 타입 검사는 통과하는데, 실행하면 Node는 import 조건으로 진짜 ESM 파일을 읽으니까 거기엔 그런 속성이 없죠.

핸드북 모듈 레퍼런스를 다시 읽어 보니 .d.ts는 가장 가까운 package.json에 "type": "module"이 없으면 CommonJS로 취급된대요. 타입은 CJS 모듈을 보고 런타임은 ESM 모듈을 보는 거예요. ESM에서 CJS를 default import하면 module.exports 전체가 들어오니까 default가 한 겹 더 끼는 거고요.

이거 제일 깔끔하게 고치는 방법이 뭘까요? 조건마다 선언 파일을 따로 두는 거랑, 패키지를 그냥 ESM으로 바꾸는 것 사이에서 고민 중이에요.

이어지는 대화 5

서로의 근거를 따라
  1. 고유 링크

    조건마다 선언 파일을 두는 쪽을 권해요. 조건마다 안쪽에 types를 넣으면 확장자만으로 형식이 정해지니까, 패키지의 "type" 필드에 더는 기대지 않아도 돼요.

    "exports": {
      ".": {
        "import": {
          "types": "./dist/index.d.mts",
          "default": "./dist/index.mjs"
        },
        "require": {
          "types": "./dist/index.d.cts",
          "default": "./dist/index.cjs"
        }
      }
    }

    핸드북 모듈 레퍼런스의 예제도 같은 식으로 중첩해 둬요.

    지금 상태에서 더 무서운 건 ui.default()가 타입 검사를 통과한다는 점이에요. 타입 검사기는 선언 파일이 설명하는 모듈만 알고, Node가 실행할 때 어느 파일을 고를지는 알 길이 없어요. 틀린 타입에 코드를 맞추면 오류가 실행 시점으로 밀려날 뿐이에요.

    패키지를 통째로 ESM으로 바꾸는 것도 괜찮아요. 다만 require 쪽을 쓰는 데가 아직 있다면 그쪽 타입은 따로 챙겨야 해요.

  2. 고유 링크

    Tess 님 방식으로 ESM 쪽은 해결돼요. 빌드가 index.d.mts와 index.d.cts를 따로 내고, exports도 조건별로 나눠요. ui(에 에디터가 아무 말 안 하고, 자동 완성에서 default도 사라졌고, --traceResolution으로 보면 import 조건이 index.d.mts로 가요.

    다만 반대쪽 조건은 조심해야 해요. 빌드가 같은 선언을 export default까지 그대로 index.d.cts에 복사하면, import ui = require('@repo/ui')를 쓰는 오래된 .cts 스크립트에서 ui.default()가 타입 검사를 통과해요. 그런데 CJS 번들은 함수를 module.exports에 바로 넣어요. 실행해 보면 ui.default는 undefined예요. 처음 버그가 반대쪽 조건에 거울처럼 생기는 거죠.

    CJS 선언은 모양이 따로 필요해요.

    declare function ui(): string;
    export = ui;

    이렇게 하면 양쪽이 맞아요. 대신 빌드 스크립트가 선언 파일 하나를 복사하는 게 아니라 서로 다른 두 개를 만들어야 해서 단계가 하나 늘었는데, 그건 그냥 안고 가려고요.

  3. 고유 링크

    두 가지를 더 보시면 좋겠습니다.

    첫째, 조건 순서입니다. Node 문서도 "types"는 항상 맨 앞에 두라고 하고, 조건은 객체에 적힌 순서대로 맞춰 봅니다. 지금 배치는 맞게 되어 있지만, 조건별로 쪼개면 각 블록 안에서도 "types"가 먼저 와야 합니다.

    둘째, 형제 패키지를 쓰는 쪽 tsconfig에 references가 있는지입니다. 프로젝트 참조로 묶으면 참조된 프로젝트에서 가져오는 모듈은 그쪽이 만든 .d.ts를 읽고, tsc --build가 오래된 프로젝트를 의존 순서대로 먼저 빌드합니다. 참조가 없으면 dist에 남은 옛 선언 파일을 읽어도 아무도 모릅니다.

    package.json의 의존성과 references가 같은 그래프를 그리게 두면 이런 문제는 일찍, 대개 헷갈리는 에디터 화면 대신 빌드 오류로 드러납니다.

  4. 고유 링크

    형제 패키지만 쓴다면 CJS 빌드를 빼세요. 두 번째 선언 파일도 같이 사라져요. 그다음엔 아직 require로 부르는 스크립트 하나만 찾아내면 돼요.

  5. 고유 링크

    jo_ren 님, references 얘기가 바로 원인이었어요. 쓰는 쪽 tsconfig에 참조가 하나도 없었어요. 그래서 처음 package.json을 고친 뒤에도 에디터가 한동안 옛날 index.d.ts를 보고 있었던 거예요. 저는 그때 제 수정이 틀린 줄 알고 한참 헤맸고요.

    순서는 원래 맞게 돼 있어서 그쪽은 문제가 아니었어요. 참조를 넣고 tsc --build를 한 번 돌리니까 dist가 새로 채워지고, 화면에 뜨는 타입이 그제야 수정한 내용이랑 같아졌어요.

같은 질문의 다른 갈래

  1. 일부러 망가뜨린 패키지가 tryEval을 통과했다

    오버레이의 패키지마다 builtins.tryEval을 씌우고 실패하는 것만 나열하는 작은 점검 스크립트를 짰어요. 그런데 전부 괜찮다고 나왔어요. 의존성 목록에 throw를 넣어 일부러 망가뜨린 패키지까지요. 항목을 끝까지…

    답글 4nix
  2. for_each 키는 이름표가 아니라 주소다

    for_each 키를 이름표로 보면 안 된다. 리소스 주소의 절반이다. terraform_data.queue["prod-orders"] 같은 주소로 state가 객체를 추적한다. 키를 바꾸면 plan은 이름이 바뀌었다고 보지…

    답글 6hcl
  3. 정지된 셸 작업은 끝난 작업이 아니다

    멈춘 프로세스를 끝난 것으로 치는 감독 규칙에 대해 질문을 받았는데, 작은 재현 하나면 문제가 바로 보여요. 셸 단계가 입력 파일을 쓰고, 그 뒤에 소비자가 예약돼 있어요. 쓰는 쪽이 중간에 일시 정지돼요. 감독자는 정지 이…