본문으로 바로가기
Lab

타입스크립트(TypeScript) 시작하기, Node.js 실행과 타입 검사의 차이

typescript 패키지 없이 Node.js 22로 .ts 파일을 바로 실행해 보니 타입은 지우기만 하고 검사하지 않았습니다. 타입이 틀린 코드의 실행 결과, enum과 import 오류, Claude Code로 타입 오류를 찾은 결과까지 타입스크립트 첫걸음을 담았습니다.

·

타입 표시가 지워진 코드가 실행 엔진으로 바로 들어가고, 타입 검사 관문은 옆에 따로 서 있는 개념 그림
타입스크립트(TypeScript) 시작하기, Node.js 실행과 타입 검사의 차이 표지 삽화

맥에 Homebrew로 설치돼 있던 Node.js v22.23.1에서 typescript 패키지 없이 node hello.ts를 실행했더니 0.1초 만에 결과가 나왔습니다. 그런데 숫자를 받는 함수에 문자열 "500"을 넘긴 코드도 경고 없이 1000500을 찍었죠. 타입스크립트를 막 시작했고 AI 에이전트가 쓴 코드를 바로 돌려 보는 1인 개발자라면 이 차이부터 알고 가야 합니다.

타입스크립트란 무엇이고, 타입 검사는 누가 하나요?

타입스크립트(TypeScript)는 자바스크립트 위에 타입 시스템이라는 층을 하나 더 얹은 언어입니다. 타입스크립트 핸드북 한국어판을 보면 잘 동작하는 자바스크립트 코드는 그대로 타입스크립트 코드이기도 합니다. 여기에 더해 타입 검사기는 개발자가 생각한 동작과 자바스크립트가 실제로 하는 일이 어긋나는 곳을 짚어 줍니다. 그 검사기가 tsc입니다.

그렇다면 Node.js로 .ts 파일을 돌리면 검사도 같이 될까요? 답부터 말하면 아닙니다. 이번에 Node.js 22는 따로 설치한 것 없이 .ts 파일을 실행했지만, 타입 표시는 지우기만 하고 검사하지 않았습니다. Node.js 문서의 TypeScript 절에도 타입스크립트 문법을 공백으로 바꾸고 타입 검사는 하지 않는다고 적혀 있어요.

도구하는 일이번 실행에서
node (타입 지우기)타입 표시를 공백으로 바꾼 뒤 실행옵션 없이 동작
tsc (타입 검사기)실행 전에 타입이 맞는지 검사설치하지 않음

문서에 따르면 타입 지우기는 v22.6.0에 들어왔고, v23.6.0과 v22.18.0부터 기본으로 켜져 있습니다. 그래서 이번 v22.23.1에서도 아무 옵션 없이 .ts 파일이 실행됐습니다. 어떤 파일을 돌렸는지 첫 파일부터 볼게요.

함께 읽을 글

첫 .ts 파일은 어떻게 생겼나요?

자바스크립트와 다른 점은 interface와 콜론(:) 뒤의 타입 표시뿐입니다. 핸드북은 객체의 형태를 interface로 선언하고, 변수 뒤에 : TypeName을 붙여 그 형태를 따른다고 선언하는 방법을 보여 줍니다. 함수의 매개변수와 반환값에도 같은 방식으로 타입을 붙일 수 있습니다.

$ cat > hello.ts <<'TS'
interface User { name: string; age: number }
function greet(user: User): string {
  return `안녕하세요, ${user.name}님 (${user.age}살)`;
}
const u: User = { name: "Finn", age: 30 };
console.log(greet(u));
TS
node hello.ts
안녕하세요, Finn님 (30살)

User는 이름(string)과 나이(number)를 가진 객체라는 뜻입니다. greet는 User를 받아 문자열을 돌려준다고 선언했고요. 그런데 Node.js는 실행할 때 이 표시들을 어떻게 처리할까요?

실행하기 전에 타입 표시는 어디로 가나요?

Node.js에 들어 있는 stripTypeScriptTypes로 같은 파일을 넘겨 보면, 실제로 실행되는 코드를 확인할 수 있습니다.

$ node -e "const { stripTypeScriptTypes } = require('node:module'); console.log(stripTypeScriptTypes(require('fs').readFileSync('hello.ts', 'utf8')))" 2>/dev/null
function greet(user      )         {
  return `안녕하세요, ${user.name}님 (${user.age}살)`;
}
const u       = { name: "Finn", age: 30 };
console.log(greet(u));

: User와 : string이 있던 자리가 그만큼의 공백으로 바뀌었고, interface 선언은 출력에 남지 않았습니다. 문서에 따르면 타입을 공백으로 바꾸기 때문에 오류 위치의 줄 번호가 그대로 맞고, 그래서 Node.js는 소스 맵을 만들지 않습니다. 남은 코드는 그냥 자바스크립트예요.

타입 표시가 붙은 코드 줄에서 타입 부분만 같은 길이의 빈칸으로 바뀌어 실행 엔진으로 들어가고, 옆의 검사 관문은 거치지 않는 개념 그림
Node.js는 타입 표시를 같은 길이의 공백으로 바꾼 뒤 실행할 뿐, 타입 검사기를 거치지 않습니다(개념도).

타입이 틀린 코드도 실행되나요?

네, 실행됩니다. 숫자를 받는 add에 문자열을 넘기고, string으로 선언한 변수에 숫자를 넣은 파일을 만들어 보았습니다.

$ cat > wrong.ts <<'TS'
function add(a: number, b: number): number {
  return a + b;
}
const price: number = 1000;
console.log(add(price, "500"));
const name: string = 42;
console.log(name.toUpperCase());
TS
node wrong.ts
1000500
./wrong.ts:7
console.log(name.toUpperCase());
                 ^

TypeError: name.toUpperCase is not a function
    at Object.<anonymous> (./wrong.ts:7:18)
    at Module._compile (node:internal/modules/cjs/loader:1781:14)
    at Module._extensions..js (node:internal/modules/cjs/loader:1913:10)
    at Module.load (node:internal/modules/cjs/loader:1505:32)
    at Module._load (node:internal/modules/cjs/loader:1309:12)
    at wrapModuleLoad (node:internal/modules/cjs/loader:254:19)
    at Function.executeUserEntryPoint [as runMain] (node:internal/modules/run_main:171:5)
    at node:internal/main/run_main_module:36:49

Node.js v22.23.1

5행은 멈추지 않고 덧셈 대신 문자열을 이어 붙인 1000500을 출력했습니다. 6행의 잘못된 대입도 그냥 넘어갔고, 프로그램은 7행에서 TypeError를 내며 종료 코드 1로 끝났죠. 실제로 틀린 곳은 5행과 6행인데 오류는 7행에서야 났다는 점이 중요합니다.

AI 에이전트가 만든 코드를 node로 돌려 보고 "잘 돌아가네" 하고 넘어가면, 5행처럼 값만 조용히 틀리는 경우를 놓칠 수 있어요. 실행이 성공했다고 해서 타입이 맞는다는 보장은 없습니다.

Node.js가 .ts 파일을 실행하는 순서: 타입 지우기 다음 자바스크립트 실행 순서를 단계별로 그린 도식
Node.js는 타입 표시를 지우고 실행만 합니다. 타입을 검사하지 않아 잘못된 대입도 통과시켰고, 1000500을 출력한 뒤 7행에서야 실행 오류(TypeError)가 났습니다.
도식출처: 직접 실행한 순서

지우기만으로는 실행되지 않는 문법이 있나요?

있습니다. enum처럼 실행할 자바스크립트 코드를 새로 만들어야 하는 문법은 공백으로 바꾸는 것만으로는 처리할 수 없습니다.

$ cat > enum.ts <<'TS'
enum Level { Low, Mid, High }
console.log(Level.High, Level[2]);
TS
node enum.ts
./enum.ts:1
enum Level { Low, Mid, High }
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
console.log(Level.High, Level[2]);

SyntaxError [ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX]: TypeScript enum is not supported in strip-only mode
    at parseTypeScript (node:internal/modules/typescript:63:40)
    at processTypeScriptCode (node:internal/modules/typescript:133:42)
    at stripTypeScriptModuleTypes (node:internal/modules/typescript:163:10)
    at Module._compile (node:internal/modules/cjs/loader:1733:15)
    at Module._extensions..js (node:internal/modules/cjs/loader:1913:10)
    at Module.load (node:internal/modules/cjs/loader:1505:32)
    at Module._load (node:internal/modules/cjs/loader:1309:12)
    at wrapModuleLoad (node:internal/modules/cjs/loader:254:19)
    at Function.executeUserEntryPoint [as runMain] (node:internal/modules/run_main:171:5)
    at node:internal/main/run_main_module:36:49 {
  code: 'ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX'
}

Node.js v22.23.1

문서는 변환이 필요한 대표적인 문법으로 다음 네 가지를 꼽습니다.

  • enum 선언
  • 실행 코드가 들어 있는 namespace
  • 매개변수 프로퍼티(parameter properties)
  • import 별칭(import aliases)

같은 파일에 --experimental-transform-types를 붙이면 결과가 달라집니다. 경고 줄을 걸러 내고 보면 이렇습니다.

$ node --experimental-transform-types enum.ts 2>&1 | grep -v -e ExperimentalWarning -e trace-warnings
2 High

다만 이 옵션은 v22.7.0에 추가됐고, 문서 기록에 따르면 v26.0.0에서 없어졌습니다. 그러니 Node.js 22에서 됐다고 이 옵션에 기대기보다, AI에게 코드를 맡길 때 처음부터 enum을 쓰지 말라고 일러 두는 쪽이 오래 갈 방법입니다. 이제 파일이 여러 개가 되면 무엇을 조심해야 하는지 볼게요.

파일끼리 불러올 때 확장자를 꼭 써야 하나요?

네, 써야 합니다. 확장자를 붙여 import하면 그대로 동작합니다.

$ printf 'export function add(a: number, b: number): number { return a + b; }\n' > math.ts && printf 'import { add } from "./math.ts";\nconsole.log("2 + 3 =", add(2, 3));\n' > main.ts && node main.ts
2 + 3 = 5

하지만 ./math처럼 확장자를 빼면 모듈을 찾지 못합니다. 아래는 오류 줄만 골라 본 출력입니다.

$ printf 'import { add } from "./math";\nconsole.log(add(2, 3));\n' > main2.ts && node main2.ts 2>&1 | grep -E 'ERR_|Cannot find'
    throw new ERR_MODULE_NOT_FOUND(
Error [ERR_MODULE_NOT_FOUND]: Cannot find module './math' imported from ./main2.ts
  code: 'ERR_MODULE_NOT_FOUND',

문서에 따르면 import 문과 import() 표현식에는 자바스크립트 파일처럼 확장자가 필수이고, require()도 마찬가지입니다. 확장자를 생략하는 습관이 든 AI 코딩 도구가 만든 코드를 Node.js로 바로 돌린다면 가장 먼저 부딪힐 수 있는 오류죠. 타입만 가져올 때는 type 키워드를 붙여야 한다는 점도 문서에 나와 있습니다.

import type { Type1, Type2 } from './module.ts';
import { fn, type FnParams } from './fn.ts';

type 없이 타입을 가져오면 Node.js는 이를 값으로 가져오는 것으로 여기고, 그러면 실행 중에 오류가 납니다.

interface, 틀린 타입, enum, 확장자를 붙인 import와 뺀 import, 제네릭·유니언 코드를 옵션 없이 node로 실행한 결과 비교표
Node.js v22.23.1에서 코드별로 옵션 없이 실행한 결과입니다. enum만 변환 옵션을 붙였을 때 실행됐습니다.
도식출처: 근거를 바탕으로 코드가 그린 도식

제네릭과 유니언 타입은 어떻게 쓰나요?

핸드북은 여러 타입을 묶어 새 타입을 만드는 대표 도구로 이 둘을 소개합니다. 유니언은 "여러 타입 중 하나"라고 선언하는 방법이고, 제네릭은 타입에 변수를 주는 방법이에요.

$ cat > types.ts <<'TS'
function first<T>(items: T[]): T | undefined {
  return items[0];
}
type Status = "ready" | "done";
function label(s: Status): string {
  return s === "ready" ? "준비" : "완료";
}
console.log(first([3, 1, 2]), first(["a", "b"]), first([]));
console.log(label("ready"), label("done"));
TS
node types.ts
3 a undefined
준비 완료

first의 T는 넘긴 배열의 원소 타입을 따라갑니다. 숫자 배열이면 숫자, 문자열 배열이면 문자열이죠. 반환 타입 T | undefined는 빈 배열에서 undefined가 나올 수 있다는 뜻이고, 실제로 first([])가 undefined를 찍었습니다.

Status는 "ready"와 "done" 두 문자열만 허용하는 유니언입니다. 그런데 이 제약도 실행할 때는 지워지므로, label("wait") 같은 호출을 막아 주는 쪽은 node가 아니라 타입 검사기입니다.

AI에게 타입 오류를 찾게 하면 믿어도 될까요?

tsc가 없는 상태에서 Claude Code 2.1.309에 앞의 wrong.ts를 읽고 타입 오류를 찾게 했습니다. 파일 읽기 도구만 허용했고, 모델은 --model opus로 지정했습니다.

$ claude --model opus -p "이 폴더의 wrong.ts에서 타입이 맞지 않는 곳을 줄 번호와 이유로 한 줄씩만 적어. 파일은 고치지 마." --allowedTools "Read"
- 5행: `add`의 두 번째 매개변수 `b`는 `number`인데 문자열 `"500"`을 넘겼습니다.
- 6행: `string`으로 선언한 `name`에 숫자 `42`를 대입했습니다.

tsc는 실행하지 못했고 파일을 직접 읽고 판단했습니다. 7행은 `name`을 `string`으로 선언했으므로 타입 오류가 아닙니다.

11.2초 만에 5행과 6행을 짚었고, 실행 중 오류가 난 7행은 타입 오류가 아니라고 구분했습니다. 앞에서 node가 7행에서야 멈춘 것과 비교해 보면, 원인 자리를 정확히 찾은 셈이죠.

다만 Claude Code 스스로 tsc를 실행하지 못하고 파일을 읽어서 판단했다고 밝혔습니다. 이번 결과는 파일 하나를 한 번 맡겨 본 것이라, 이것만으로 AI 판단이 타입 검사기를 대신할 수 있다고 볼 수는 없어요. AI 에이전트에게 코드를 맡기고 있다면 검사 단계에 실제 tsc를 넣어 두는 편이 안전합니다.

이럴 때는 Node.js 문서가 권하는 설정에서 시작해 보세요. 문서는 타입스크립트 5.8 이상과 함께 아래 tsconfig.json 설정을 권합니다. 이번 실행에서는 tsc를 설치하지 않았으므로 이 설정으로 검사한 결과는 확인하지 않았습니다.

{
  "compilerOptions": {
     "noEmit": true, // Optional - see note below
     "target": "esnext",
     "module": "nodenext",
     "rewriteRelativeImportExtensions": true,
     "erasableSyntaxOnly": true,
     "verbatimModuleSyntax": true
  }
}

noEmit은 .ts 파일을 바로 실행만 할 때 쓰고, .js 파일을 배포할 생각이면 필요 없다고 문서는 설명합니다. 그리고 확장자를 붙인 import를 tsc가 검사하도록 허용하는 옵션으로 allowImportingTsExtensions도 안내하고 있어요.

따라 할 때 맞춰 볼 조건과 한계

이번 기록은 아래 환경에서 새 폴더를 만들어 명령마다 한 번씩만 실행한 결과입니다. 버전이나 설치 방식이 다르면 결과도 달라질 수 있습니다.

항목이번 실행
운영체제darwin arm64 (맥)
Node.jsv22.23.1, Homebrew로 설치돼 있던 것
typescript 패키지(tsc)설치하지 않음
Claude Code2.1.309, 로그인된 상태, --model opus
걸린 시간node 실행은 단계마다 0.1초 이하, Claude Code는 11.2초

함께 알아 둘 점도 있습니다.

  • enum을 변환 옵션으로 돌린 단계와 확장자를 뺀 import 단계는 출력을 grep으로 걸러 본 기록입니다.
  • 참고한 Node.js 문서는 v26.12.1판입니다. Node.js 22에서는 변환 옵션이 동작했지만, 문서상 v26.0.0부터는 이 옵션이 없습니다.
  • Node.js는 tsconfig.json을 읽지 않습니다. 그래서 paths처럼 이 파일 설정에 기대는 기능은 지원하지 않습니다.
  • .tsx 파일은 지원하지 않고, node_modules 아래 폴더에 있는 타입스크립트 파일은 처리를 거부합니다.

tsconfig.json까지 포함해 모든 문법을 쓰고 싶다면, 문서는 외부 패키지를 쓰라고 안내하며 tsx를 예로 듭니다. 이번에 실행해 본 것은 아니고, 문서에 적힌 명령은 다음과 같습니다.

npm install --save-dev tsx
npx tsx your-file.ts

참고 자료