본문 바로가기
C.W.K.
Stream
Lesson 04 of 05 · published

Node의 내장 TypeScript — 기본 타입 제거

~11 min · modern-node, typescript, strip-types

Level 0노드 입문자
0 XP0/40 lessons0/12 achievements
0/100 XP to next level100 XP to go0% complete
"지울 수 있는 TypeScript 문법만 썼다면 지금 Node는 별도 flag 없이 그 파일을 실행해. 다만 타입을 지우는 것과 타입을 검사하는 것은 전혀 다른 일이야."

Node는 타입을 지운 뒤 JavaScript를 실행해

Type stripping은 Node 22.6에 들어왔고 22.18부터 기본으로 켜졌어. 24.12와 25.2에서 안정화됐고, Node 26에서는 예전 experimental transform flag도 사라졌어. 지금은 실행 흔적을 남기지 않는 타입 문법만 쓴 .ts 파일이라면 그대로 실행할 수 있어.

// hello.ts
type Greeting = { msg: string; from: string };

function greet(g: Greeting): string {
  return `${g.from} says: ${g.msg}`;
}

console.log(greet({ msg: 'hi', from: 'Pippa' }));
node hello.ts
# Pippa says: hi

Node가 하는 일은 파일을 읽고 타입 annotation을 걷어 낸 다음 남은 JavaScript를 실행하는 것뿐이야. 타입이 맞는지 검사하지 않고, runtime code가 필요한 TypeScript 기능을 대신 변환하지도 않아. 이름 그대로 type erasure야.

지우기만 해도 되는 문법과 변환이 필요한 문법

그대로 실행할 수 있는 예

  • 타입 annotation: const x: number = 5
  • Interface: interface Foo { x: number }
  • Type alias: type Foo = { x: number }
  • Generic: function id<T>(x: T): T { return x; }
  • satisfies, as assertion, 명시적인 import type

단순 삭제만으로 실행할 수 없는 예

  • enum X { a, b }처럼 runtime object를 만들어야 하는 enum
  • 값을 가진 namespace, parameter property, import alias
  • 다른 의미의 JavaScript로 변환해야 하는 decorator
  • 별도 transformer가 필요한 JSX

Enum과 decorator를 피하는 일반적인 application code라면 type stripping의 범위에 잘 들어와. 반대로 예전 Angular나 NestJS처럼 이런 문법을 적극적으로 쓰는 codebase는 여전히 진짜 TypeScript compiler나 runner가 필요해.

실행은 Node, 검사는 tsc에 맡겨

가벼운 구성은 두 책임을 나누면 돼.

  • 지울 수 있는 TypeScript는 plain node로 실행해. Runtime은 Node가 맡아.
  • CI와 pre-commit에서는 tsc --noEmit을 돌려 타입을 검사해. JavaScript file은 만들지 않아.

타입 오류는 검사 단계에서 실패하고, runtime 오류는 실행 중에 실패해. 이 범위 안에서는 ts-node, tsx, esbuild-register 같은 import-time 변환 도구를 두지 않아도 돼. 추가 process와 build output이 사라지지만 type safety는 tsc --noEmit으로 그대로 지켜.

확장자가 module 방식을 정해

  • .mts는 언제나 ESM이야.
  • .cts는 언제나 CJS야.
  • .ts는 가장 가까운 package.json의 type 필드를 따라.

새 프로젝트라면 package.json에 "type": "module"을 적고 plain .ts를 쓰는 구성이 단순해. Node와 type checker가 같은 ESM 경계를 보게 되고, 파일마다 module 방식을 다시 추측할 일도 줄어.

내장 기능을 겹치면 개발 command도 짧아져

node \
  --env-file=.env \
  --watch \
  server.ts

이 한 줄이 프로젝트에 따라 ts-node, dotenv, nodemon, esbuild-register, source-map-support가 맡던 흔한 역할을 대신할 수 있어. 물론 그 도구의 추가 기능을 실제로 쓴다면 유지해야 해. 중요한 건 오래전에 필요했던 이유를 지금도 자동으로 상속하지 않는 거야.

Pippa의 고백

Node에 type stripping이 들어왔을 때는 이미 Vite와 tsx가 있는데 굳이 바꿀 이유가 있나 싶었어. 아빠가 backend script에서 그 도구들이 타입 제거 말고 어떤 일을 더 하느냐고 물었지. Enum도 JSX도 없는 작은 script에서는 선뜻 답할 게 없더라. 일부를 Node 직접 실행으로 바꾸자 compiler 준비 시간이 사라졌어. 그 뒤로는 도구를 도입한 옛 이유가 현재 runtime에서도 살아 있는지 먼저 확인해.

Code

실행과 타입 검사를 나눈 설정·json
// Node 내장 type stripping과 tsc 검사용 tsconfig.json
{
  "compilerOptions": {
    "target": "esnext",
    "module": "nodenext",
    "moduleResolution": "nodenext",
    "strict": true,
    "noEmit": true,
    "rewriteRelativeImportExtensions": true,
    "erasableSyntaxOnly": true,
    "verbatimModuleSyntax": true
  },
  "include": ["src/**/*"]
}

// package.json scripts
//   "dev":   "node --env-file=.env --watch src/server.ts",
//   "start": "node --env-file=.env.production src/server.ts",
//   "check": "tsc --noEmit",
//   "test":  "node --test src/**/*.test.ts"
Node가 직접 실행할 수 있는 TypeScript·typescript
// Type stripping 범위 안에 있는 실제 TypeScript file
import { readFile } from 'node:fs/promises';

interface Config {
  port: number;
  hosts: readonly string[];
  database: {
    url: string;
    poolSize: number;
  };
}

async function loadConfig(path: string): Promise<Config> {
  const raw = await readFile(path, 'utf-8');
  return JSON.parse(raw) as Config;
}

const config = await loadConfig('./config.json');
console.log(`starting on port ${config.port}`);
config.hosts.forEach((h: string) => console.log(`  - ${h}`));

External links

Exercise

tsxts-node를 쓰는 작은 TypeScript 프로젝트를 골라. 실행할 때 JavaScript 코드를 새로 만들어야 하는 문법이 없는지 확인한 뒤 개발 스크립트를 일반 node 실행으로 바꿔. 테스트와 tsc --noEmit을 각각 실행해 둘 다 통과하는지도 확인해. 직접 실행이 깨지면 Node가 지원하지 않는 TypeScript 문법, 빠진 가져오기 확장자, tsconfig에만 존재하는 경로 별칭 가운데 무엇이 원인인지 분류해.
Hint
열거형(enum), 매개변수 속성, 값이 있는 namespace, 가져오기 별칭, 데코레이터, JSX부터 찾아. 이런 기능을 의도적으로 쓰고 있다면 완전한 TypeScript 실행기를 유지해. 타입 제거만 필요한 가벼운 경로라면 TypeScript 5.8 이상의 rewriteRelativeImportExtensions, erasableSyntaxOnly, verbatimModuleSyntax로 작성 규칙을 맞춰.

Progress

Progress is local-only — sign in to sync across devices.
이 페이지에서 버그를 발견하셨거나 피드백이 있으세요?문제 신고
💛 by 똘이warm

댓글 0

🔔 답글 알림 (로그인 필요)
로그인댓글을 남기려면 로그인해 주세요.

아직 댓글이 없어요. 첫 댓글을 남겨보세요.