최근에 몇가지 시험해볼 게 있어서 프로토타이핑을 진행할 일이 있었습니다. 평소 처럼 리액트 프로젝트를 새로 구성하고 이것저것 코딩을 하다가 처음보는 에러를 발견했습니다.

erasableSyntaxOnly

erasableSyntaxOnly… 이건 뭘까요? 잠시 에러를 쳐다보다가 찾아보기 시작했습니다.

그러니까 이 erasableSyntaxOnly는 Typescript 5.8에 추가된 컴파일러 옵션이었습니다.

이 옵션이 추가된 배경은 뭔지, 그래서 뭘 어떻게 바꿔야 하는지 하나씩 알아보겠습니다.

옵션이 추가된 배경⌗

런타임TypeScript 지원 상태특징
Bun.js✅ 네이티브 지원초기부터 TypeScript 실행 지원
Deno✅ 네이티브 지원TypeScript 우선 설계
Node.js (이전)❌ 별도 도구 필요tsc, ts-node 등 필요
Node.js 22.6.0+✅ 실험적 지원--experimental-strip-types
Node.js 23.6.0+✅ 기본 지원별도 플래그 불필요

원래 node.js는 Typescript 코드를 실행하는 기능이 없었습니다. 그래서 tsc를 통해 코드를 트랜스파일링 해서 Javascript로 변환해서 실행하거나 ts-node같은 도구를 사용했었죠. 하지만 Bun.js나 Deno 같이 Typescript 실행을 지원하는 런타임이 나오면서 node.js가 좀 뒤쳐지는 모양새가 되었습니다. 그러던 중에 node.js 22.6.0에서 Typescript 실행을 지원하는 –experimental-strip-types 플래그가 추가되었고 , node.js 23.6.0에서 별도의 설정없이 Typescript 코드를 실행할 수 있게 되었습니다.

물론 아직까지는 몇가지 제약사항이 있습니다.

제약사항⌗

node.js는 Typescript 코드를 실행하기 위해 타입을 제거하는 과정(type stripping)을 거칩니다. 이 과정은 Typescript 컴파일러의 트랜스파일 없이 진행되는데요, 이렇게 하면 몇가지 이점이 있습니다. 우선 타입 제거와 트랜스파일링을 비교해볼까요?

특징타입 스트리핑전통적 트랜스파일링
처리 방식타입만 제거전체 코드 변환
소스 맵불필요 (라인 유지)필요
성능빠름상대적으로 느림
디버깅직관적 (WYSIWYG)소스 맵 의존
지원 문법제한적 (erasable만)모든 TS 문법
  • 소스 맵이 필요없음: 코드의 구조가 전혀 바뀌지 않기 때문에, 소스 맵을 생성할 필요가 없으며 직관적인 디버깅이 가능합니다.
  • 성능 최적화: 컴파일 과정이 없기 때문에, 더 빠른 실행 속도를 기대할 수 있습니다.
  • 실행 과정이 직관적: 타입을 제외하고 작성된 코드 그대로(WYSIWYG) 실행됩니다.

다만, 컴파일 과정을 거치지 않기 때문에 몇가지 제약사항이 생깁니다. Typescript 컴파일러가 특정 기능을 지원하기 위해 Javascript로 변환하는 과정에서 뭔가 코드를 생성해야 한다면 어떨까요? 앞서 말씀 드렸듯이 node.js는 Typescript 코드를 타입만을 제거하고 있는 그대로 실행합니다. 그렇기 때문에 이런 기능들은 더 이상 사용할 수 없으며 다른 방법을 찾아야 합니다.

이 글을 시작하면서 보여드렸던 enum이 이런 제약사항에 가장 큰 영향을 받는 기능입니다. enum을 중심으로 좀 더 자세히 알아보죠!

enum으로 제약사항 알아보기⌗

우선 다음과 같이 tsconfig.json을 설정해서 타입을 제거하지 않고 실행해보겠습니다.

{
  "compilerOptions": {
    "erasableSyntaxOnly": false,
    "outDir": "./dist"
  }
}

그리고 다음과 같이 enum을 선언해보겠습니다.

// enum/enum.ts
enum Direction {
  UP,
  DOWN,
  LEFT,
  RIGHT,
}

그리고 다음과 같이 실행해보겠습니다.

npx tsc

그러면 다음과 같이 타입이 제거된 코드가 생성됩니다.

// dist/enum.js
var Direction;
(function (Direction) {
  Direction[(Direction["UP"] = 0)] = "UP";
  Direction[(Direction["DOWN"] = 1)] = "DOWN";
  Direction[(Direction["LEFT"] = 2)] = "LEFT";
  Direction[(Direction["RIGHT"] = 3)] = "RIGHT";
})(Direction || (Direction = {}));

결과에서 볼 수 있듯이 enum은 원래 Javascript에서 제공되지 않으므로 Typescript 컴파일러가 트랜스파일 하는 과정에서 Javascript 코드를 생성합니다. 하지만 node.js에서 이 코드를 바로 실행하기 위해 erasableSyntaxOnly 옵션을 true로 설정하면, Typescript 컴파일러는 enum에 대해 불평을 쏟아냅니다.

erasableSyntaxOnly

해결 방법⌗

erasableSyntaxOnly 옵션은 이름 그대로 타입을 제거하고 실행할 때 제거될 수 있는 문법들만 허용하겠다 그래서 node.js가 직접 Typescript를 실행할 수 있는 코드만 허용하겠다는 의미입니다.

enum은 별도의 코드를 생성해야 하므로 사용할 수 없으므로, 다음과 같이 별도의 과정없이 사용할 수 있는 형태로 변경해야 합니다.

// enum/enum.ts
// 원래 코드
// enum Direction {
//   UP,
//   DOWN,
//   LEFT,
//   RIGHT,
// }

// erasableSyntaxOnly 옵션에서 사용가능한 enum
const Direction = {
  Up: "up",
  Down: "down",
  Left: "left",
  Right: "right",
} as const;

type DirectionType = (typeof Direction)[keyof typeof Direction];

const direction: DirectionType = Direction.Up;

console.log(direction);

enum 대신에 객체 리터럴(literal)을 사용해서 enum을 대신할 수 있도록 합니다. 컴파일된 결과를 확인해볼까요?

// dist/enum.js
// 원래 코드
// enum Direction {
//   UP,
//   DOWN,
//   LEFT,
//   RIGHT,
// }
// erasableSyntaxOnly 옵션에서 사용가능한 enum
var Direction = {
  Up: "up",
  Down: "down",
  Left: "left",
  Right: "right",
};
var direction = Direction.Up;
console.log(direction);

타입 정보만 삭제된 채로 코드가 생성되었고, 다음과 같이 실행해보면 결과도 정상적으로 출력됩니다.

> node dist/enum.js
up

type DirectionType = (typeof Direction)[keyof typeof Direction]; 이 부분이 상당히 혼란스러운데요. 이 부분은 다음과 같이 해석할 수 있습니다.

  • typeof Direction: Direction은 객체 리터럴이고 다음과 같은 타입입니다.
    {
      readonly Up: "up";
      readonly Down: "down";
      readonly Left: "left";
      readonly Right: "right";
    }
    
  • keyof typeof Direction: 위 객체의 키들을 의미합니다. 즉, "Up" | "Down" | "Left" | "Right" 입니다.
  • (typeof Direction)[keyof typeof Direction]: 인덱스 엑세스 타입을 의미하는데요, Direction 객체에서 사용가능한 모든 값을 유니온 타입으로 반환합니다. 즉, "up" | "down" | "left" | "right" 입니다.

조금 복잡해 보이긴 하지만, 이렇게 함으로써 다음과 같은 효과를 얻을 수 있습니다.

  • 타입 안전성: enum과 마찬가지로 컴파일 타임에 타입 체크가 지원됩니다.
  • IDE 지원: 타입 정보가 있으니까 IDE가 자동 완성을 지원합니다.
  • 소스 맵이 필요없음: 삭제되는 코드는 빈칸으로 남겨서 라인 수를 유지하기 때문에 소스 맵을 생성 및 사용할 필요가 없습니다.
  • 삭제가능!: 타입 정보만 삭제하고 바로 실행 가능합니다.
  • 디버그 직관성: WYSIWYG(What You See Is What You Get). 코드를 보면 바로 이해할 수 있습니다.

좀 더 간단한 버전⌗

만약, Direction.Up 대신에 문자열 리터럴을 그대로 사용해도 된다면, 다음과 같이 유니온 타입으로 더 간단하게 사용할 수 있습니다.

// 좀 더 간단한 버전
type Direction = "up" | "down" | "left" | "right";

const direction: Direction = "up";

console.log(direction);

타입 정보를 활용할 수 있기 때문에 다음과 같이 IDE의 도움을 받을 수도 있습니다.

erasableSyntaxOnly

Enum 대안 비교표⌗

방법문법 복잡도타입 안전성IDE 지원런타임 비용추천도
기존 enum⭐⭐⭐⭐⭐⭐⭐⭐높음 (객체 생성)❌ (erasableSyntaxOnly에서 불가)
const 객체 + as const⭐⭐⭐⭐⭐⭐⭐⭐⭐낮음⭐⭐⭐
string literal union⭐⭐⭐⭐⭐⭐⭐없음⭐⭐⭐

그외 제약사항들⌗

클래스를 정의할 때 속성을 정의하는 대신에 생성자의 파라미터로 대신하는 패턴은 사용할 수 없습니다.

// ❌ erasableSyntaxOnly 옵션에서는 이렇게 하면 안됩니다!
class Person {
  constructor(private name: string, public age: number) {}
}

// ✅ 이렇게 해야 합니다.
class Person {
  private name: string;
  public age: number;

  constructor(name: string, age: number) {
    this.name = name;
    this.age = age;
  }
}

그리고 commonjs 스타일의 임포트 구문은 erasableSyntaxOnly 옵션에서 사용할 수 없습니다.

// ❌ erasableSyntaxOnly 옵션에서는 이렇게 하면 안됩니다!
import foo = require("foo");

// ✅ 이렇게 해야 합니다.
import foo from "foo";

erasableSyntaxOnly 제약사항 요약⌗

문법사용 가능 여부이유대안
enum❌런타임 코드 생성 필요const 객체 + as const
클래스 파라미터 속성❌자동 속성 할당 코드 생성명시적 속성 선언
CommonJS import❌import 변환 필요ES6 import

하지만, Typescript 기능을 활용할 수 있는 방법은 있습니다.⌗

node.js 22.7.0에서 추가된 --experimental-transform-types 플래그를 사용하면 Typescript 에서만 제공되는 enum 같은 문법을 그대로 사용할 수도 있습니다!

테스트를 위해 일단 erasableSyntaxOnly 옵션을 비활성화 하고 enum 코드를 실행해볼까요?

// enum/enum.ts
enum Direction {
  UP,
  DOWN,
  LEFT,
  RIGHT,
}

const direction = Direction.UP;

console.log(direction);

그리고 다음과 같이 실행합니다.

> node --experimental-transform-types enum/enum.ts
0
(node:63565) ExperimentalWarning: Type Stripping is an experimental feature and might change at any time
(Use `node --trace-warnings ...` to show where the warning was created)

불평을 하긴 하지만, 정상적으로 실행되는 걸 확인할 수 있습니다. 이 옵션을 사용하면 내부적으로 Amaro 를 활용해서 소스코드를 해석하고 변환한다고 합니다. 따라서 enum과 같은 Typescript 전용 문법도 사용할 수 있고, 디버깅을 위한 소스맵도 생성됩니다. 하지만 실행을 위해 코드를 변환해야 하니까 아무래도 성능은 떨어지겠죠?

Node.js TypeScript 실행 옵션 비교⌗

플래그추가 버전처리 방식enum 지원성능소스 맵
--experimental-strip-types22.6.0타입만 제거❌빠름불필요
--experimental-transform-types22.7.0Amaro 변환✅보통생성됨

권장사항⌗

앞서 설명했듯이 erasableSyntaxOnly 옵션은 타입을 제거하고 실행할 수 있는 문법만 허용합니다. Typescript 컴파일러가 코드를 변환하는 작업이 없어야 한다는 의미이며, 임포트 생략(import elision) 또한 여기에 포함됩니다. Typescript가 임의로 임포트를 생략하지 않도록 verbatimModuleSyntax 옵션을 같이 사용하는 게 좋습니다. 임포트 생략과 verbatimModuleSyntax 옵션에 대해서 다룬 글이 있으니 관심 있으신 분은 참고바랍니다.

한 가지 더: Type as Comments⌗

Javascript 표준에서도 타입을 지원하기 위한 논의가 진행되고 있습니다. TC39: Type as Comments 를 보면, Typescript와 유사한 형태로 타입을 선언할 수 있도록 제안이 진행되고 있습니다. 여기서도 타입은 역시 런타임에서는 무시되는 형태인데요, Typescript가 가고자 하는 방향도 이와 맞닿아 있는 걸로 보입니다.

요약⌗

erasableSyntaxOnly 핵심 정리⌗

항목내용
목적Node.js의 네이티브 TypeScript 실행 지원
원리타입만 제거하고 코드 변환 없이 실행
주요 장점⚡ 빠른 실행, 🚫 소스 맵 불필요, 🎯 직관적 디버깅
주요 제약enum, 클래스 파라미터 속성, CommonJS import 사용 불가
권장 설정erasableSyntaxOnly: true + verbatimModuleSyntax: true

마이그레이션 체크리스트⌗

  • enum → const 객체 + as const 또는 string literal union으로 변경
  • 클래스 파라미터 속성 → 명시적 속성 선언으로 변경
  • import foo = require() → ES6 import 구문으로 변경
  • verbatimModuleSyntax: true 옵션 추가
  • erasableSyntaxOnly: true 옵션 설정

참고자료⌗