서론

여러분은 타입스크립트의 컴파일러가 타입스크립트로 짜여져있다는 것을 아시나요?

이는 느린 컴파일 속도의 주범이기도 하지만, 반대로 이를 통해 높은 확장성을 만들어냈습니다.

그러나, 컴파일러 API는 공식 문서에도 제대로 나와있지 않고, tsc CLI에도 관련 옵션이 없는데다가, ttypescript 혹은 ts-patch 등의 라이브러리 문서를 보고서야 겨우 이해 가능한 수준이었죠.

이 글에서는 그 컴파일러 API를 사용하면 어떤 장점이 있는지, 그리고 간단한 예제를 통해 어떻게 사용 가능한지 등을 알아보려 합니다.

이걸 왜 써야하나요?

사실 "컴파일러 API를 써야지만 가능하다!" 이런 분야는 거의 없습니다. 그렇지만 이름부터가 컴파일러 API인 만큼, 타입스크립트 컴파일러가 컴파일하는 과정에 참견하여 많은 편의성을 제공하는 코드를 만들어내거나, 정말로 무에서 유를 만들어낼 수도 있죠.

저도 컴파일러 API를 접하고, 몇몇 프로젝트에 적용해봤습니다. 이를 통해 만든 라이브러리도 있는데요,

https://github.com/SieR-VR/ts-features

https://github.com/SieR-VR/typesl

첫번째는 러스트식 match를 타입스크립트에 가져오는 라이브러리이고, 두번째는 Typescript to GLSL (!) 컴파일러입니다.
개인적으로 두 라이브러리를 스스로 사용하면서 얻은 만족감이 정말 컸습니다. 여러분도 컴파일러 API를 통해 높은 생산성을 얻을 수 있으면 하는 바람이네요.

컴파일러 API라니, 너무 어렵지 않나요?

음.. 반은 맞고 반은 틀린 말입니다. 왜냐하면 컴파일러는 어렵고 API는 쉬우니까요.

타입스크립트에서는 컴파일러 API를 위해 정말 많은 편의성을 제공합니다. 개인적으로는 이 API가 별로 유명하지 않은게 매우 아쉬울 따름일 정도에요. 그래서 컴파일러란 단어 자체에 익숙하지 않은 분들이라도 접근 가능하도록 많은 기능을 래핑해 두었으니 저희는 그걸 단순히 갖다 쓰기만 하면 되는겁니다.

Getting Started!

간단한 예제로, 스트링 리터럴을 타입 파라미터로 받고, 그 리터럴을 반환하는 함수

fromTypeLiteral<T extends string>(): string

를 구현해볼까 합니다.

이 함수는 실제로는 Pure TS에선 구현이 불가능합니다. 컴파일 타임에 타입 정보는 완전히 사라지기 때문이죠. 그러나 컴파일러 API를 사용한다면, 타입 정보가 사라지기 전에 데이터를 얻어올 수 있습니다.

Installation

여러분이 이 API를 쓰기 위해 필요한건 Typescript 그 자체입니다.

npm i typescript -D

평소에 많이 설치하던 패키지 그 자체인데요. 뭔가 단순히 tsc 실행용으로만 쓰던 패키지를 사용한다니 새롭습니다.

Setup

가장 먼저 타입스크립트를 임포트해줍니다.

index.ts

import ts from "typescript"

그리고 메인으로 사용할 함수

index.ts

const transformer: ts.TransformerFactory<ts.SourceFile> = (context) => {
  return (sourceFile) => {
    const visitor = (node: ts.Node): ts.Node => {
      return ts.visitEachChild(sourceFile, visitor, context);
  	}
    
    return ts.visitEachChild(sourceFile, visitor, context);
  }
}

를 정의해줍시다. 간단한 설명을 덧붙이자면,

  • transformer: API를 사용할 메인 함수
  • visitor: 각각의 AST 노드*를 방문할 함수
  • (sourceFile) => { ... } 재귀 방문을 트리거할 함수

겠네요. 여기서 AST 노드가 무엇이냐면,

타입스크립트는 여러분의 소스코드를 받으면 가장 먼저 AST (Abstract Syntax Tree)라는 친구로 변환합니다.
타입스크립트 컴파일러는 타입스크립트를 자바스크립트로 변환하는 것이 주 목적이기 때문에, 변환의 편의성 (등등) 을 위해서 AST로 변환하는 것입니다. AST는 이름에서도 알 수 있듯이 트리 구조인데요, 각각의 문법 요소가 서로가 서로를 부모-자식 관계로 참조하기 때문입니다. 설명이 좀 길어졌지만, AST를 구성하는 노드 각각을 'AST 노드'라 부릅니다.

세팅의 마지막은 위 함수를 실행하는 코드입니다.

index.ts

const program = ts.createProgram(["./test.ts"], {});
const sourceFile = program.getSourceFile("./test.ts")!;
const printer = ts.createPrinter();

const result = ts.transform(sourceFile, [transformer]);
console.log(printer.printFile(result.transformed[0]));

상술했듯이 타입스크립트에서는 컴파일러 API를 위한 많은 편의 기능을 제공하기 때문에, 간단한 함수 몇개를 통해 트랜스폼을 사용할 수 있습니다.

아, 트랜스폼이란 용어가 등장했는데요, 기존의 AST 노드를 다른 노드로 바꾸는 과정을 '트랜스폼'이라고 합니다.

Implementation

저희가 세팅한 코드에는 이미 모든 노드를 순회하는 기능이 있습니다. visitor 함수에서 그 기능을 구현하는데요, 그렇기 때문에 visitor에 AST를 바꾸는 기능을 넣을까 합니다. (별도의 함수로 분리하는 것이 좋지만, 간단한 구조를 위해 통합하려 합니다.)

index.ts

const visitor = (node: ts.Node): ts.Node => {
  if (ts.isCallExpression(node) && node.expression.getText(sourceFile).includes("fromTypeLiteral")) {
    throw Error("todo!");
  }
  
  (...)
}

먼저 "정의한 함수를 감지했다면" 이라는 조건을 걸어줬습니다.

index.ts

if (ts.isCallExpression(node) && node.expression.getText(sourceFile).includes("fromTypeLiteral")) {
    const result = node.typeArguments![0];
 	
	if (!ts.isTypeLiteralNode(result))
		throw new Error("Expected type literal");

  	throw Error("todo!");
}

그 다음엔, 함수 호출의 타입 파라미터를 가져와서 (타입 제약조건이 있으니 non-null operator 사용) 타입 파라미터 노드가 타입 리터럴 노드가 아니라면 오류를 띄우게 해줬습니다.

index.ts

if (!ts.isTypeLiteralNode(result))
	throw new Error("Expected type literal");

return ts.factory.createStringLiteral(result.getText(sourceFile).slice(1, -1));

마지막으로 가져온 타입 파라미터 노드의 문자열을 AST 노드로 만들어서 반환해줍니다. slice를 하는 이유는 앞뒤의 따옴표 때문입니다.

Result

고대하던 결과 확인 시간이네요!

test.ts

// @ts-expect-error
function fromTypeLiteral<T extends string>(): string {}

const thisWorks = fromTypeLiteral<"thisWorks">();

이 파일을 컴파일해주겠습니다.

ts-node ./index.ts
// @ts-expect-error
function fromTypeLiteral<T extends string>(): string { }
const thisWorks = "thisWorks";

멋지게 동작하네요!

profile
흥미 위주의 개발

0개의 댓글

관련 채용 정보