DEV BOX

'26/11/08 UPDATE

NODE.JS / TYPESCRIPT / FIELD NOTES

node로 .ts 파일을 바로 돌리면, 타입 검사는 누가 해 줄까

Node.js 24는 node app.ts로 TypeScript 파일을 바로 실행하지만, 타입을 공백으로 지울 뿐 맞는지는 검사하지 않습니다. Node.js v24.21.0에서 타입이 틀린 코드가 1000100을 내고 종료 코드 0으로 끝나는 것, enum과 type 없이 가져온 interface가 SyntaxError를 내는 것, tsconfig.json의 paths 별칭이 적용되지 않는 것을 실제로 실행해 확인하고, 타입 검사를 tsc에 따로 맡기는 설정을 살펴봅니다.

Node.js 24에서는 node app.ts처럼 TypeScript 파일을 바로 실행할 수 있습니다. 그런데 타입이 틀린 코드도 경고 없이 끝까지 돌고, 흔히 쓰는 enum과 type 없이 가져온 interface는 오히려 SyntaxError를 냅니다. Node는 타입을 검사하지 않으므로, 검사는 TypeScript 컴파일러 tsc를 따로 돌려서 해야 합니다. 이 글을 읽고 나면 Node가 .ts 파일에서 하는 일과 하지 않는 일을 구분할 수 있습니다.

예제는 2026-10-27에 macOS 27.0.1에서 Node.js v24.21.0으로 실행했고, process.features.typescript 값은 strip이었습니다. 예제 폴더의 package.json에는 "type": "module"을 두었습니다. 가격과 주문 코드는 가상의 예입니다. 이 Mac의 PATH에는 tsc가 없어(command -v tsc 실패) tsc는 실행하지 않았습니다. Node의 동작은 v24 문서(v24.22.1판), 이후 변경은 v26 문서(v26.12.1판)를 기준으로 했고, 둘 다 2026-10-27에 확인했습니다. 2026-10-28에 LTS가 된 Node.js 26의 문서도 v26.13.0판까지 타입 지우기, 지울 수 없는 문법, type 없는 import, tsconfig.json에 대해 v24 문서와 같은 동작을 설명합니다(2026-10-30 확인, v26은 실행하지 않음).

타입이 틀려도 끝까지 실행된다

두 함수 모두 price를 number로 받는다고 적었지만, 마지막 두 줄은 폼에서 읽은 값처럼 문자열 "1000"을 넘깁니다.

function addShipping(price: number): number {
  return price + 100;
}
function times(price: number, qty: number): number {
  return price * qty;
}

const fromForm: string = "1000";
console.log(addShipping(1000), times(1000, 3));
console.log(addShipping(fromForm), times(fromForm, 3));
console.log(typeof addShipping(fromForm));

node price.ts는 경고 없이 1100 3000, 1000100 3000, string을 출력하고 종료 코드 0으로 끝났습니다. +는 한쪽이 문자열이면 둘을 이어 붙이므로 "1000100"이 나왔고, *는 두 값을 숫자로 바꾸므로 times(fromForm, 3)은 우연히 맞는 3000을 냈습니다. 같은 타입 오류가 한 호출에서는 틀린 값으로, 다른 호출에서는 맞는 값으로 나타난 것입니다. 실행해서 결과를 눈으로 보는 것만으로는 이런 오류를 다 찾을 수 없습니다. 타입 표기는 실행 동작을 바꾸지 않습니다. number를 돌려준다고 적은 함수가 문자열을 돌려준 마지막 줄이 그 예입니다.

Node.js v24.21.0 실행 결과. process.features.typescript는 strip. price.ts는 addShipping(price: number)가 price + 100을, times(price: number, qty: number)가 price * qty를 돌려주고, const fromForm: string = "1000"을 두 함수에 넘긴다. node price.ts의 출력은 1100 3000, 1000100 3000, string이고 exit 0이다. node strip.mjs price.ts의 출력은 같은 코드에서 : number와 : string 자리만 같은 길이의 공백으로 바뀐 코드이며, 끝에 stripTypeScriptTypes가 실험 기능이라는 ExperimentalWarning이 찍히고 exit 0이다.
타입이 틀린 호출이 들어 있는 price.ts를 node로 실행한 실제 출력과, 같은 파일을 node:module의 stripTypeScriptTypes()로 지운 결과입니다.

Node는 타입을 공백으로 바꿀 뿐이다

Node가 .ts 파일에 하는 일은 타입 지우기(type stripping) 하나입니다. TypeScript 문법을 공백으로 바꾼 뒤 실행하고, 타입이 맞는지는 검사하지 않습니다. 버그가 아니라 의도된 설계입니다.

지운 모양은 node:module의 공개 함수 stripTypeScriptTypes()로 볼 수 있습니다. price.ts를 넣어 보니 : number와 : string 자리만 같은 길이의 공백이 되었고, 글자 수(328자)와 줄 수(11줄)도 그대로였습니다. 위치가 바뀌지 않으니 스택 추적의 줄 번호를 맞추려고 소스 맵을 만들 필요도 없습니다. 이 함수는 ExperimentalWarning을 냈고(안정성 등급 1.2), 출력이 Node 버전에 따라 달라질 수 있습니다. node price.ts 실행에는 경고가 없었습니다.

타입 지우기는 v23.6.0에서 기본으로 켜졌고, v24.3.0부터 실험 기능 경고가 사라졌으며 v24.12.0에서 안정 기능이 되었습니다. Node.js 22에서는 v22.18.0부터 기본으로 켜져 있습니다.

지울 수 없는 문법과 type 없는 import

공백으로 바꾸기만 해서는 실행할 수 없는 문법도 있습니다. enum은 실행 중에 쓰는 객체를 만드는 문법이라 지우면 그 객체도 사라집니다. enum Status가 든 파일은 아무것도 출력하기 전에 SyntaxError [ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX]로 멈췄습니다. 런타임 코드가 있는 namespace, 클래스의 parameter properties, import Bar = container.Bar 같은 import 별칭도 같은 부류이며, 이 셋은 실행해 보지 않았습니다.

v24에는 이런 문법을 변환해 실행하는 --experimental-transform-types 플래그가 있지만 v26.0.0에서 제거되었습니다. 그래서 실행하지도, 해결책으로 쓰지도 않았습니다. 대신 enum을 평범한 객체와 union 타입으로 바꾸자 paid가 출력되었습니다.

const Status = {
  Paid: "paid",
  Refunded: "refunded",
} as const;
type Status = (typeof Status)[keyof typeof Status];

import도 비슷합니다. types.ts가 interface Order와 값 VERSION을 내보낼 때, import { Order, VERSION } from "./types.ts"는 does not provide an export named 'Order'라는 SyntaxError를 냈습니다. Node는 type 표시가 없는 import를 값 import로 다루는데, 타입을 지운 types.ts에는 Order라는 값이 없습니다. 기본 설정의 tsc는 JavaScript를 만들 때 타입으로만 쓰인 import를 지워 주지만(import elision), Node는 그렇게 하지 않습니다. import { type Order, VERSION }으로 고치자 { id: 7 } 1이 나왔습니다.

Node.js v24.21.0 실행 결과. node status-enum.ts는 enum Status 블록의 첫 줄과 끝 줄을 표시하고 SyntaxError [ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX]: TypeScript enum is not supported in strip-only mode로 exit 1. node status.ts는 paid를 출력하고 exit 0. node use-bad.ts는 import { Order, VERSION } from "./types.ts"의 Order를 가리키며 SyntaxError: The requested module './types.ts' does not provide an export named 'Order'로 exit 1. node use.ts는 { id: 7 } 1을 출력하고 exit 0.
지울 수 없는 문법(enum)과 type 없이 가져온 타입이 실행 단계에서 막힌 실제 출력, 그리고 고친 뒤의 결과입니다.

Node가 해 주지 않는 것: 확장자 채우기와 tsconfig.json 읽기

Node는 import 경로를 고쳐 주지 않습니다. import 문과 import()에는 ./types.ts처럼 확장자를 꼭 써야 하고, ./types로 쓰자 ERR_MODULE_NOT_FOUND가 났습니다.

tsconfig.json도 읽지 않으므로 paths 별칭이나 새 문법을 옛 JavaScript로 낮추는 변환처럼 그 설정에 기대는 기능은 일부러 빠져 있습니다. alias 폴더에 "@app/*": ["../*"] 별칭을 둔 tsconfig.json을 만들고 import { VERSION } from "@app/types.ts"를 실행해 보니, Node는 @app/types.ts를 패키지 이름으로 찾다가 Cannot find package로 멈췄습니다. Node에서 가장 가까운 기능은 package.json의 imports 필드에 #으로 시작하는 이름을 두는 subpath imports입니다(실행하지 않음).

node --check로 문법만 먼저 확인하는 방법도 통하지 않았습니다. TypeScript 문법은 REPL과 --check에서 지원되지 않아, 첫 타입 표기의 콜론에서 SyntaxError: Unexpected token ':'가 났습니다.

Node.js v24.21.0 실행 결과. node use-noext.ts는 Error [ERR_MODULE_NOT_FOUND]: Cannot find module '/…/lab/types'로 exit 1. alias/tsconfig.json에는 "paths": { "@app/*": ["../*"] }가 있고 alias/main.ts는 import { VERSION } from "@app/types.ts"인데, node alias/main.ts는 Error [ERR_MODULE_NOT_FOUND]: Cannot find package '@app/types.ts'로 exit 1. node --check price.ts는 price: number의 콜론을 가리키며 SyntaxError: Unexpected token ':'로 exit 1. command -v tsc가 실패해 run.sh가 tsc: not found를 출력.
실제 출력입니다. tsconfig.json의 paths 별칭은 적용되지 않았고, 확장자를 뺀 import도 실패했으며, node --check는 타입 표기를 읽지 못했습니다.

타입 검사는 tsc를 따로 돌려서 한다

node는 타입을 지우고 실행할 뿐이고, 그 과정에서 타입을 확인하는 단계는 없습니다. 이 일은 TypeScript 컴파일러 tsc에 따로 맡겨야 합니다. Node v24 문서는 TypeScript 5.8 이상과 아래 tsconfig.json 설정을 권합니다.

{
  "compilerOptions": {
    "noEmit": true,
    "target": "esnext",
    "module": "nodenext",
    "rewriteRelativeImportExtensions": true,
    "erasableSyntaxOnly": true,
    "verbatimModuleSyntax": true
  }
}

noEmit을 켜면 tsc는 JavaScript, 소스 맵, 선언 파일을 만들지 않고 타입 검사만 합니다. .js 파일을 만들어 배포한다면 필요 없습니다. TypeScript 5.8에 생긴 erasableSyntaxOnly는 enum처럼 실행 동작이 있는 TypeScript 전용 문법 대부분을 tsc 단계에서 오류로 봅니다. 타입 지우기 도구는 이런 코드를 실행해 봐야 알려 주기 때문에 생긴 옵션입니다. verbatimModuleSyntax를 켜면 tsc도 type 표시가 없는 import를 지우지 않아 Node와 같은 규칙을 따르고, interface처럼 타입뿐인 이름을 type 없이 가져오면 오류 TS1484를 냅니다. rewriteRelativeImportExtensions는 JavaScript를 출력할 때 상대 경로의 .ts 확장자를 바꿔 줍니다.

검사는 실행 전에 따로 돌립니다. TypeScript를 개발 의존성으로 설치하고(npm install typescript --save-dev, 2026-10-27에는 7.0.2가 설치됨) tsc가 통과하면 node로 실행하는 식입니다. tsconfig.json이 있는 폴더에서 npx tsc price.ts처럼 파일 이름을 주면 TypeScript 5.x는 설정을 조용히 무시하고, 6.0과 7.0은 오류를 냅니다(6.0의 오류 번호는 TS5112). 설정대로 검사하려면 파일 이름 없이 npx tsc로 실행합니다. 설정을 일부러 건너뛸 때만 --ignoreConfig를 붙입니다. 6.0부터는 types 기본값도 빈 목록이라, process나 node:fs 같은 Node API를 쓰는 코드는 @types/node를 설치하고 "types": ["node"]를 더해야 할 수 있습니다. 이 문단은 TypeScript 문서와 npm 레지스트리를 따른 설명이며 실행하지 않았습니다.

npx tsc        # tsconfig.json을 읽어 타입만 검사(noEmit)
node app.ts    # 검사를 통과한 뒤 실행

개발 노트

node는 실행하고, 타입은 tsc가 따로 본다

Node.js v24는 타입을 지우고 실행할 뿐, 타입이 맞는지는 보지 않는다

  • node로 .ts 파일 실행 (실행 결과)

    • 타입이 틀려도 실행: 1000100, exit 0
    • enum → SyntaxError (strip-only 모드에서 지원하지 않음)
    • type 없이 가져온 타입 → SyntaxError (없는 export)
    • 확장자 없는 import, tsconfig의 paths → ERR_MODULE_NOT_FOUND
  • tsc (TypeScript, 실행하지 않음)

    npx tsc

    • tsconfig.json을 읽고 타입을 검사한다
    • noEmit이면 JS를 만들지 않고 검사만 한다
    • 파일 이름을 주면 5.x는 tsconfig.json 무시, 6.0부터 오류(6.0은 TS5112)
  • Node에 가까운 규칙으로 맞추는 옵션

    "erasableSyntaxOnly": true

    • erasableSyntaxOnly: 실행 동작이 있는 TS 문법 대부분을 오류로 본다
    • verbatimModuleSyntax: type 표시가 없는 import는 지우지 않는다
    • TypeScript 5.8 이상

첫 칸은 2026-10-27 Node.js v24.21.0에서 실행한 결과다. 나머지 두 칸은 TypeScript와 Node.js 문서를 따른 설명이며, tsc는 실행하지 않았다.

Node가 실행하면서 하는 일과, 타입 검사를 맡는 tsc의 일을 나눠 본 그림입니다. 두 옵션은 tsc가 Node에서 막힐 코드를 실행 전에 더 많이 잡도록 해 줍니다.

실행 명령 앞에 npx tsc를 두고, tsconfig.json에 erasableSyntaxOnly와 verbatimModuleSyntax를 켜 tsc의 규칙을 Node에 가깝게 맞춰 두십시오. TypeScript 문서대로라면 그때부터 이 글의 타입 오류와 enum은 실행이 아니라 tsc 단계에서 드러납니다.

끝 · END OF NOTE
RSS 구독목록으로
COMMENTS BOX

이 기록에 대화를 더해 주세요.

궁금한 점, 다른 접근, 직접 해 본 결과를 나눠 주세요.

최신순

로그인 상태 확인 중…

댓글을 불러오는 중…