메서드 데코레이터가 바꾼 타입을 추론하기 위한 TypeScript-GO 컴파일러 개선기

작성일:2026.07.31|수정일:2026.07.31|조회수:8

메서드 데코레이터가 바꾼 타입을 추론하기 위한 TypeScript-GO 컴파일러 개선기

저장소 메서드 위에 붙은 @ZodSchemaParse를 다시 보게 된 것은 API 응답의 has_nexthasNext로 바꾸고 싶어서였다. 스키마에 transform을 붙이면 런타임 값은 바뀐다. 그렇다면 호출부의 hover도 자연스럽게 hasNext를 보여줄 것 같았다. 하지만 커서를 올렸을 때 TypeScript는 여전히 원래 메서드가 선언한 응답을 보고 있었고, 그보다 먼저 데코레이터 자리에는 타입 오류가 생겼다.

구조 자체는 꽤 단정하다. AssetRepository를 포함한 네 도메인의 repository 메서드는 응답을 받은 뒤 같은 데코레이터를 통과한다. 총 27개이며 auth 10개, asset 3개, prediction 7개, subscription 7개다. 데코레이터는 각 메서드가 반환한 값을 schema.parseAsync에 넣고 Promise<z.output<Schema>>를 돌려준다. 내부 endpoint와 실제 base path를 걷어내고 핵심만 남기면 다음과 같다.

TS
import { z } from "zod";type AsyncMethod<This, Args extends unknown[], Return> = (  this: This,  ...args: Args) => Promise<Return>;export function ZodSchemaParse<Schema extends z.ZodTypeAny>(schema: Schema) {  return function <This, Args extends unknown[], Return extends z.input<Schema>>(    target: AsyncMethod<This, Args, Return>,  ) {    return async function (this: This, ...args: Args): Promise<z.output<Schema>> {      const response = await target.call(this, ...args);      return schema.parseAsync(response);    };  };}const assetPageSchema = z.object({  items: z.array(z.object({ id: z.string() })),  has_next: z.boolean(),});const articleSchema = z.object({  published_at: z.string(),  meta_site_name: z.string(),});class AssetRepository {  @ZodSchemaParse(assetPageSchema)  getAssetsBySearch(query: { keyword: string }) {    return this.fetcher.get("<sanitized-resource>", { query });  }}

런타임만 보면 이미 필요한 자리가 마련되어 있다. repository는 통신에 집중하고, 검증과 변환은 스키마가 담당하며, 데코레이터가 둘을 이어준다. 그런데 stock TypeScript는 변환된 반환 callable이 원래 메서드와 호환되지 않으면 그 차이를 멤버 타입으로 채택하지 않는다. 데코레이터에 TS1270을 보고한 뒤 원래 메서드 시그니처를 계속 공개하고 검사한다. schema.parseAsync가 다른 모양을 반환한다는 사실은 런타임에 남지만, 호출부 타입은 그곳까지 따라가지 않는다.

스키마의 입력과 출력은 같을 필요가 없다

Zod 스키마를 검증기로만 쓰면 z.inputz.output은 대개 같은 모양이다. 그래서 둘을 굳이 구분해야 하는 이유가 잘 보이지 않는다. 하지만 Zod의 transform은 입력을 검사한 뒤 다른 값을 내보낼 수 있고, 이 순간 스키마는 단순한 검문소보다 adapter에 가까워진다. 현재 프로젝트의 스키마는 has_next, published_at, meta_site_name 같은 snake_case 키를 그대로 검증하고 노출한다. camelCase는 이미 배포된 동작이 아니라, 이 구조에서 다음으로 넣고 싶었던 변환이다.

z.input과 z.output 사이에서 이름을 바꾸다

아주 작은 변환으로 줄이면 입력과 출력의 간격이 선명해진다. z.input<typeof pageSchema>에는 has_next가 있고, z.output<typeof pageSchema>에는 hasNext가 있다. published_atmeta_site_name도 같은 방식으로 옮길 수 있다.

TS
const pageSchema = z  .object({    has_next: z.boolean(),    published_at: z.string(),    meta_site_name: z.string(),  })  .transform(({ has_next, published_at, meta_site_name }) => ({    hasNext: has_next,    publishedAt: published_at,    metaSiteName: meta_site_name,  }));type ApiPage = z.input<typeof pageSchema>;// { has_next: boolean; published_at: string; meta_site_name: string }type Page = z.output<typeof pageSchema>;// { hasNext: boolean; publishedAt: string; metaSiteName: string }

이 분리는 취향보다 계약에 가깝다. API가 무엇을 보냈는지는 z.input에 남고, 검증과 변환을 통과한 뒤 애플리케이션이 무엇을 받는지는 z.output에 남는다. 외부 계약의 철자를 내부 전체로 전파하지 않아도 되고, 반대로 변환하기 전에 camelCase라고 우길 필요도 없다. 입력과 출력이 다른 스키마는 경계의 양쪽을 동시에 적는 셈이다.

런타임 output이 repository hover에 닿지 못한다

ZodSchemaParse도 이미 이 의도를 타입에 담고 있다. 원래 메서드가 반환한 값은 Return extends z.input<Schema>여야 하고, 래퍼는 Promise<z.output<Schema>>를 반환한다. 내가 호출자에게 기대한 hover는 복잡하지 않았다. 다만 아래 주석은 현재 TypeScript의 결과가 아니라, 이 글에서 만든 프로토타입이 보여주기를 바란 결과다.

TS
class AssetRepository {  @ZodSchemaParse(pageSchema)  async getAssets(): Promise<ApiPage> {    return fetchApiPage();  }}const page = await new AssetRepository().getAssets();page.hasNext; // 프로토타입: booleanpage.has_next; // 프로토타입: 오류

stock TypeScript에서 이 예제는 단지 getAssets() => Promise<ApiPage>로 보여주고 조용히 끝나지 않는다. 데코레이터가 반환한 () => Promise<Page>가 원래 () => Promise<ApiPage>와 호환되지 않으므로 데코레이터 위치에 TS1270을 낸다. 그 뒤 복구 과정에서는 원래 메서드 시그니처를 계속 사용하기 때문에 page.hasNext도 오류이고 page.has_next는 여전히 유효하다. 반환 타입을 아무리 정확하게 Promise<z.output<Schema>>로 써도 현재 checker가 그것을 꾸며진 멤버의 새 공개 타입으로 받아들이지는 않는다.

27개 메서드마다 반환 타입을 다시 적거나, 호출 뒤 별도 adapter를 두거나, repository 자체를 팩터리로 감싸는 선택도 가능하다. 다만 그런 사용자 영역 대안을 여기서 실제로 시도한 구현인 척하지 않겠다. 내가 확인하고 싶었던 질문은 더 좁았다. TS1270으로 거절하던 반환 callable을 메서드의 최종 타입으로 채택한다면, checker는 무엇을 함께 바꿔야 하는가.

데코레이터는 메타데이터보다 더 많은 일을 한다

데코레이터라는 말을 들으면 Java나 여러 프레임워크의 annotation처럼 선언에 메타데이터를 붙이는 모습을 먼저 떠올리기 쉽다. 실제 생태계에서는 annotation과 decorator라는 용어가 겹치거나 다르게 쓰이므로 둘을 엄격한 보편 정의로 가를 생각은 없다. 다만 JavaScript의 표준 데코레이터 문법에서 메서드 데코레이터는 설명표만 붙이는 장치가 아니다. 호출 가능한 함수를 받아 실행되고, 다른 함수를 반환해 원래 메서드를 런타임에서 대체할 수 있다.

TypeScript 5.0의 표준 데코레이터 설명을 따라가면 이 실행 모델이 중요해진다. 다음 데코레이터는 숫자를 받던 메서드를 문자열을 받는 메서드로 런타임에서 교체한다. 새 함수는 original.call(this, Number(value))로 원래 구현을 재사용하므로 JavaScript가 실제로 실행하는 입력 계약은 달라진다. 하지만 stock TypeScript의 타입 계약은 거기까지 따라가지 않는다.

TS
function parseInput<This>(  original: (this: This, value: number) => number,  context: ClassMethodDecoratorContext<This, (value: number) => number>,): (this: This, value: string) => number {  return function (this: This, value: string) {    return original.call(this, Number(value));  };}class Example {  @parseInput  method(value: number): number {    return value * 2;  }}new Example().method("21");// stock TypeScript: TS2345, string은 number 매개변수에 할당할 수 없다// 프로토타입: 허용, visible signature는 (value: string) => number

stock TypeScript는 먼저 @parseInput에 TS1270을 보고한다. 반환한 (value: string) => number가 원래 (value: number) => number와 호환되지 않기 때문이다. 이어서 method를 여전히 숫자를 받는 원래 시그니처로 노출하므로 method("21")도 타입 검사를 통과하지 못한다. 진단이 있어도 JavaScript emit을 허용해 실행하거나 같은 동작의 JavaScript를 직접 실행한다면 런타임 대체 함수는 문자열을 숫자로 바꾸고 42를 만들 수 있지만, 그것은 stock TypeScript가 이 호출을 타입상 허용했다는 뜻이 아니다.

이 구분을 고정하고 나니 프로토타입의 목표도 선명해졌다. 메서드 본문은 계속 number 입력을 기준으로 검사하되, 접근이 허용된 곳에서 꾸며진 멤버를 조회하거나 추출하고 상속을 비교하고 선언 파일을 만들 때는 (value: string) => number를 보여주고 싶었다. 몸체를 검사하는 타입과 런타임 교체 뒤의 공개 타입을 나누는 셈이다.

런타임 대체가 멤버 타입이 되지 못하는 이유

현재 TypeScript의 동작은 우연히 빠진 한 줄이라기보다, 표준 데코레이터를 도입할 때 정한 범위에 가깝다. microsoft/TypeScript#49229는 메서드 데코레이터가 보이는 시그니처를 바꿀 수 있게 하자는 제안이다. 표준 데코레이터 구현 PR인 microsoft/TypeScript#50820에는 데코레이터가 꾸미는 멤버나 클래스의 타입을 바꾸지 않으며, 이 능력을 허용하기로 한다면 이후 작업으로 다루겠다는 범위 설명이 있다. 즉 런타임 대체 능력이 있다는 사실과 checker가 그 타입을 공개한다는 규칙은 별개의 결정이다.

TypeScript-Go의 ES decorator transform 작업을 다룬 microsoft/typescript-go#2354에도 같은 제한을 피하고 싶다는 커뮤니티 의견이 있었다. 이것은 문제의식이 혼자만의 것은 아니라는 근거로는 쓸 수 있지만, Microsoft maintainer가 이 설계를 승인했거나 검토했고 merge할 계획이라는 뜻은 아니다. 이 글의 구현은 공개 fork의 실험 브랜치에서 동작하는 experimental workbench다. 현재 공식 TypeScript 동작을 설명하는 문서도, upstream 결정의 예고편도 아니다.

현재 checker가 원래 멤버 타입을 유지하는 덕분에 얻는 안정성도 있다. decorator expression이 복잡한 generic factory일 수 있고, 반환 타입이 union일 수도 있으며, overload나 abstract method와 섞일 수도 있다. 한 데코레이터가 바꾼 타입은 바로 위 데코레이터의 입력과 context 타입까지 바꾼다. “반환 타입을 멤버에 복사한다”로 시작하면 checker의 평가 순서, 접근 제어, generic instantiation, declaration emit까지 줄줄이 따라온다. 작은 문법 표면 아래에서 꽤 많은 부서가 회의에 들어오는 셈이다.

최종 타입은 어디서 메서드가 되는가

처음에는 데코레이터 검사에서 반환 타입만 저장하면 hover도 따라올 거라고 생각했다. 하지만 property access, 메서드 추출, 상속 비교와 language service는 모두 평소처럼 symbol의 타입을 묻는다. 최종 타입이 그 통로에 들어오지 않으면 데코레이터 검사만 새 타입을 알고 나머지 compiler는 원래 메서드를 본다.

symbol lookup이 최종 타입을 만나는 자리

관문은 getTypeOfSymbol이었다. 이 함수는 먼저 getTypeOfFuncClassEnumModule로 원래 타입을 얻고, method symbol만 getDecoratedMethodTypeOfMethod로 보낸다. 아래는 이번 분기만 남긴 축약이다.

GO
func (c *Checker) getTypeOfSymbol(symbol *ast.Symbol) *Type {    // deferred, instantiated, mapped, accessor 등의 기존 분기는 생략    if symbol.Flags&(ast.SymbolFlagsFunction|ast.SymbolFlagsMethod|        ast.SymbolFlagsClass|ast.SymbolFlagsEnum|ast.SymbolFlagsValueModule) != 0 {        originalType := c.getTypeOfFuncClassEnumModule(symbol)        if symbol.Flags&ast.SymbolFlagsMethod != 0 {            return c.getDecoratedMethodTypeOfMethod(symbol, originalType)        }        return originalType    }    // 기존 enum member, alias, error 분기는 생략    return c.errorType}func (c *Checker) getDecoratedMethodTypeOfMethod(symbol *ast.Symbol, originalType *Type) *Type {    if class := c.getEligibleStandardMethodDecoratorClassForMethod(symbol, originalType); class != nil {        c.ensureStandardMethodDecoratorClassComposition(class)    }    links := c.valueSymbolLinks.Get(symbol)    if links.decoratedMethodType == c.unresolvedType { return originalType }    if links.decoratedMethodType != nil { return links.decoratedMethodType }    links.decoratedMethodType = originalType    return originalType}

대상 메서드라면 여기서 클래스 합성을 시작한다. 재진입 중 decoratedMethodType == unresolvedType이면 미완성 값을 공개하지 않고 originalType으로 물러난다. 완료 뒤에는 decorated type을 반환하고, 비참여 메서드는 원래 타입을 캐시한다. source signature를 지우지 않으면서 외부 symbol 조회에만 최종 타입을 보여주는 자리다.

누가 합성 대상에서 빠지는가

후보 판정은 두 단계다. isStandardMethodDecoratorCompositionCandidate가 legacy mode, isolatedDeclarations, 여러 declaration, non-method, body 없음, class 밖, ambient/abstract, decorator 없음인 symbol을 제외한다. 이어 getEligibleStandardMethodDecorators가 원래 타입에 call signature가 정확히 하나인지 확인한다. overload처럼 출발점부터 여러 갈래인 메서드는 여기서 빠진다.

이 제한은 보수적이지만 결정적이다. 여러 declaration이나 signature 중 무엇을 current type으로 삼을지 정하려면 별도의 overload 병합 규칙이 필요하고, abstract와 ambient method에는 교체할 구현도 없다. 대신 구체적인 public, protected, TypeScript private, JavaScript #private method는 모두 참여한다.

별도 lookup API는 만들지 않았다. ordinary symbol lookup이 decorated type을 얻고, method body는 source type으로 계속 검사된다. 그래서 parseInput의 원본 메서드는 number를 받지만 호출자가 보는 메서드는 string을 받는다.

클래스 전체를 계산할 때만 중간 상태를 숨길 수 있다

다음에는 요청받은 메서드의 stack만 계산하려 했다. 그러나 ClassMethodDecoratorContextThis는 클래스 타입이어서 A의 context가 B를 보고, B가 다시 A를 볼 수 있다. 일부만 바뀐 클래스를 공개하면 hover 순서가 타입을 바꾼다. compiler가 운세 서비스가 되는 지점이었다.

세 가지 장치가 반쪽 공개를 막는다

클래스마다 StandardMethodDecoratorClassLinks를 두고 Uncomputed, Computing, Complete를 기록했다. method의 ValueSymbolLinks.decoratedMethodType은 공개 결과 자리이며, 계산 중에는 unresolvedType을 넣는다. 최종 값은 pending에 모았다가 함께 공개한다. state는 transaction 중복을, unresolved는 재진입을, pending은 반쪽 공개를 막는다.

입구는 달라도 공개 시점은 하나다.

TXT
[method symbol lookup / decorator resolution / method check / diagnostics / .d.ts emit]                                  |                                  v                    ensure class composition                                  |             Uncomputed -----> Computing                                  |                 mark every target method unresolved                                  |          compose instance/static stacks into pending                                  |                  publish every pending result                                  |                    clear pending -> Complete                                  |                                  v              ordinary lookup observes final callable

첫 진입만 state를 Computing으로 바꾸고 class.Members()를 순회한다. 아래 collectEligibleDecoratedMethods는 실제 helper가 아니라 그 loop와 methodComposition 수집을 줄인 글 속 이름이다.

GO
func (c *Checker) ensureStandardMethodDecoratorClassComposition(class *ast.Node) {    classLinks := c.standardMethodDecoratorClassLinks.Get(class)    if classLinks.state != StandardMethodDecoratorCompositionStateUncomputed { return }    classLinks.state = StandardMethodDecoratorCompositionStateComputing    methods := collectEligibleDecoratedMethods(class) // 글에서만 쓰는 축약    classLinks.pending = make(map[*ast.Symbol]StandardMethodDecoratorCompositionResult, len(methods))    for _, method := range methods {        links := c.valueSymbolLinks.Get(method.symbol)        links.decoratedMethodType = c.unresolvedType        classLinks.pending[method.symbol] = StandardMethodDecoratorCompositionResult{links: links}    }    instanceThis := c.getDeclaredTypeOfClassOrInterface(c.getSymbolOfDeclaration(class))    staticThis := c.getTypeOfSymbol(c.getSymbolOfDeclaration(class))    for _, method := range methods {        finalType := c.composeMethodDecorators(method, instanceThis, staticThis) // 글에서만 쓰는 축약        result := classLinks.pending[method.symbol]        result.decoratedMethodType = finalType        classLinks.pending[method.symbol] = result    }    for _, result := range classLinks.pending { result.links.decoratedMethodType = result.decoratedMethodType }    classLinks.pending = nil    classLinks.state = StandardMethodDecoratorCompositionStateComplete}

instance와 static은 같은 This를 쓰지 않는다

composeMethodDecorators도 실제 helper가 아니라 receiver 선택과 decorator loop의 축약이다. instance This는 class symbol의 getDeclaredTypeOfClassOrInterface, static This는 constructor를 나타내는 getTypeOfSymbol에서 온다. 후자를 놓치면 static decorator의 receiver 영역 자체가 틀린다.

질문 순서가 달라도 답은 하나여야 한다

재진입을 A, B, C, D로 놓으면 세 장치의 역할이 보인다.

TXT
[A/B/C/D class reentrancy]A  lookup methodA       state=Uncomputed -> ComputingB  mark methodA/B       decoratedMethodType=unresolvedC  context for methodA  asks class -> methodB -> methodA againD  reentrant methodA    sees unresolved, returns originalType temporarily   ... all stacks finish in pending ... publish A/B together -> Complete

trigger는 public lookup만이 아니다. checkDecorators의 method 검사, getResolvedSignature의 decorator resolution, diagnostics와 declaration emit도 먼저 들어올 수 있다. 그래서 query-order 테스트는 outer, inner, static stack, method type, diagnostics를 각각 먼저 요청하고 instance/static 최종 타입과 decorator value/context가 같은지 비교한다. transaction은 질문 순서가 의미를 바꾸지 않게 한다.

데코레이터 한 단계도 평범한 호출처럼 해결한다

클래스 계산 뒤에는 decorator를 함수 타입에 대입하면 될 것 같았다. 하지만 generic inference, overload 순서, arity와 assignability를 흉내 내면 작은 가짜 call checker가 생긴다. 평범한 호출과 결과가 어긋나지 않도록 각 단계도 기존 call machinery를 통과시켰다.

각 stage를 ordinary call로 되돌리기

합성은 bottom-to-top이다. 각 stage는 현재 ThiscurrentType으로 newClassMemberDecoratorContextTypeForNode를 호출하고, newESDecoratorCallSignature(currentType, contextType, currentType)으로 synthetic runtime call을 만든다. stage-ready flag 뒤에는 getResolvedSignature를 그대로 호출한다.

GO
currentType := method.originalTypefor i := len(method.decorators) - 1; i >= 0; i-- {    decorator := method.decorators[i]    links := c.signatureLinks.Get(decorator)    contextType := c.newClassMemberDecoratorContextTypeForNode(        method.declaration, thisType, currentType,    )    links.decoratorSignatureApplicable = false    links.decoratorSignature = c.newESDecoratorCallSignature(        currentType, contextType, currentType,    )    links.decoratorSignatureStageReady = true    signature := c.getResolvedSignature(decorator, nil, CheckModeNormal)    returnType := c.getReturnTypeOfSignature(signature)    if links.decoratorSignatureApplicable && c.getDefiniteSingleCallSignature(returnType) != nil {        currentType = returnType    }}func (c *Checker) getDefiniteSingleCallSignature(t *Type) *Signature {    if t.flags&(TypeFlagsAny|TypeFlagsVoidLike|TypeFlagsUnion) != 0 { return nil }    signatures := c.getSignaturesOfType(t, SignatureKindCall)    if len(signatures) != 1 { return nil }    return signatures[0]}

stage가 준비되면 ordinary resolution이 decorator expression을 현재 value/context에 적용하고, 선택된 signature의 return type을 다음 후보로 돌려준다. factory도 먼저 일반 call expression으로 해결되므로 factory 인자 오류와 반환된 decorator의 적용 오류가 분리된다.

signature 하나를 확인하되 callable 전체를 남긴다

getDefiniteSingleCallSignature는 타입을 축소하지 않는다. any, void-like, union, call 없음, 여러 call을 제외하는 eligibility check다. 통과 뒤 currentType에는 signature가 아니라 returnType 전체가 들어가므로 property, construct, index facet이 남는다.

TS
interface ConstructibleIndexedCallable {  (value: string): boolean;  new (value: number): Date;  readonly tag: "decorated";  readonly [key: string]: "string" | "number";  readonly [index: number]: "number";}const fn: ConstructibleIndexedCallable = instance.method;fn("value");new fn(1);fn.tag;fn[1];

fn("value")만 보다가 .tag, new fn(1), index access가 사라지고서야 메서드 타입이 call signature보다 넓다는 걸 알았다. 안쪽의 callable 전체가 다음 currentType이 되므로 바깥 decorator의 value와 context도 C1 전체를 받는다.

실패한 단계가 다음 타입을 훔치지 않게

가장 오래 붙잡힌 실패는 diagnostic 뒤에 있었다. resolver의 복구용 signature가 callable을 반환하자 초기 코드는 그것을 성공처럼 currentType에 넣었다. middle stage가 실패해도 바깥 decorator가 유효한 Inner 대신 Invalid를 받았다.

applicability는 resolveCall이 결정한다

수정은 getResolvedSignature의 reentrancy window 안에 놓였다. 이 함수는 해결 전에 resolvedSignatureresolvingSignature marker로 바꾼다. 성공 latch는 stage-ready이고 marker가 유지된 창에서 실제 overload가 선택될 때만 올라간다.

resolveCall은 일반 call, tagged template, JSX와 decorator가 공유한다. candidate 정렬, inference, arity를 거쳐 여러 후보에는 subtype relation과 assignable relation을 차례로 적용한다. 실패 시에는 가장 설명력 있는 후보로 diagnostic을 만든다. 프로토타입은 이 알고리즘을 복제하지 않고 실제 성공 branch를 탔는지만 기록한다.

GO
var result *Signatureif len(s.candidates) > 1 {    result = c.chooseOverload(&s, c.subtypeRelation)}if result == nil {    result = c.chooseOverload(&s, c.assignableRelation)}if result != nil {    if isDecorator {        links := c.signatureLinks.Get(node)        if links.decoratorSignatureStageReady && links.resolvedSignature == c.resolvingSignature {            links.decoratorSignatureApplicable = true        }    }    return result}result = c.getCandidateForOverloadFailure(s.node, s.candidates, s.args, candidatesOutArray != nil, checkMode)if isDecorator && c.signatureLinks.Get(node).decoratorSignatureStageReady {    c.signatureLinks.Get(node).decoratorSignatureApplicable = false}c.signatureLinks.Get(node).resolvedSignature = resultif reportErrors { c.reportCallResolutionErrors(node, &s, signatures, headMessage) }return result

실패한 signature를 먼저 캐시하는 이유

실패 branch는 복구 candidate를 diagnostic보다 먼저 캐시한다. error reporting 중 같은 signature로 재진입해 다른 결과를 만드는 일을 막기 위해서다. latch는 false인 채로 남고 diagnostic은 그대로 보고된다.

앞 diagram의 invalid-stage 흐름처럼 middle 실패는 diagnostic과 fallback cache만 남긴다. decoratorSignatureApplicable이 false라 adoption은 일어나지 않고, 바깥 stage는 마지막 유효 타입 C1을 받는다.

나중에 decorator 전용 assignability check를 붙이면 normal overload selection의 inference, ordering, arity, recovery와 어긋날 수 있다. 성공 순간의 latch는 새 판정기를 만들지 않고 ordinary call machinery의 결정을 adoption 조건으로 쓴다.

오류가 있는 파일에서도 checker는 바깥 context, hover와 emit을 계속 만든다. middle의 diagnostic은 남기고 복구 타입은 마지막으로 증명된 C1에 두어야 다음 답까지 오염시키지 않는다.

Box과 Box는 같은 캐시를 쓸 수 없다

generic class도 자동으로 따라올 줄 알았다. 하지만 decorated type은 별도 필드에 있고 계산 중에는 unresolvedType일 수 있다. fast path가 원형 값을 완성된 타입처럼 돌려주거나 첫 치환을 캐시하면 Box<string>의 hover가 Box<number>까지 오염시킨다.

원형 symbol에서 다시 시작하기

instantiateSymbol은 먼저 links.target을 따라 원형 symbol을 찾고 class composition을 보장한다. target이 아직 unresolved면 그 상태를 유지해 부분 결과를 캐시하지 않는다. 완성된 타입에 type variable이 있으면 mapper를 보존한 새 symbol을 만든다.

GO
func (c *Checker) instantiateSymbol(symbol *ast.Symbol, m *TypeMapper) *ast.Symbol {    links := c.valueSymbolLinks.Get(symbol)    effectiveResolvedType := links.resolvedType    decoratedTypeSymbol, decoratedTypeLinks := symbol, links    if symbol.CheckFlags&ast.CheckFlagsInstantiated != 0 {        decoratedTypeSymbol, decoratedTypeLinks = links.target, c.valueSymbolLinks.Get(links.target)    }    var decoratedMethodTypeToInstantiate *Type    if decoratedTypeSymbol.Flags&ast.SymbolFlagsMethod != 0 {        original := c.getTypeOfFuncClassEnumModule(decoratedTypeSymbol)        if c.getEligibleStandardMethodDecoratorClassForMethod(decoratedTypeSymbol, original) != nil {            effectiveResolvedType = c.getDecoratedMethodTypeOfMethod(decoratedTypeSymbol, original)            if decoratedTypeLinks.decoratedMethodType == c.unresolvedType {                effectiveResolvedType = c.unresolvedType            } else { decoratedMethodTypeToInstantiate = effectiveResolvedType }        }    }    result := c.newSymbol(symbol.Flags, symbol.Name)    resultLinks := c.valueSymbolLinks.Get(result)    resultLinks.target, resultLinks.mapper = symbol, m    if decoratedMethodTypeToInstantiate != nil && m != nil {        resultLinks.resolvedType = c.instantiateType(decoratedMethodTypeToInstantiate, m)    }    return result}

일반 symbol은 치환할 관련 type variable이 없으면 기존 symbol을 재사용할 수 있다. 하지만 type variable을 품은 decorated callable은 그 지름길을 건너뛰어야 하며, 이미 instantiate된 symbol이라면 target을 따라 원본 source를 찾고 기존 mapper와 새 mapper를 결합한다. 그래야 최종 callable이 원본에서 현재 instantiation에 맞게 만들어지고, call signature뿐 아니라 property, construct, index 안의 type variable도 함께 치환된다.

같은 선언에서 서로 다른 hover를 만들기

This extends Box<infer U> ? (value: U) => U : never라면 Box<string>은 string callable, Box<number>는 number callable이어야 한다. 같은 source symbol을 가리켜도 공유할 수 있는 것은 원형 callable이며, resolved result에는 각자의 mapper가 필요하다.

query-order 테스트는 매 case를 새 program에서 시작해 decorator, string instance, number instance, diagnostics를 각각 먼저 묻는다. 어느 순서에서도 decorator context와 두 method type, diagnostic count가 같아야 한다. 그래서 두 Box는 먼저 조회된 타입에 오염되지 않고 서로 다른 hover를 얻는다.

hover의 타입을 .d.ts까지 운반하기

hover가 맞아도 package 소비자는 .d.ts만 읽는다. declaration transformer가 source method를 그대로 내보내면 module seam에서 다시 원래 숫자 signature로 돌아간다. checker 안의 final type을 emit까지 운반해야 했다.

checker와 printer 사이에 새 seam을 놓다

transformer가 checker 내부를 직접 읽게 하지 않고 printer.EmitResolverCreateDecoratedMethodDeclaration을 추가했다. checker 구현은 originalType과 ordinary lookup의 publicType을 비교하고, 다르면서 definite single call일 때만 새 node를 만든다.

GO
// printer.EmitResolver에는 같은 이름의 seam이 추가된다.original := emitContext.ParseNode(methodDeclaration)symbol := r.checker.getSymbolOfDeclaration(original)originalType := r.checker.getTypeOfFuncClassEnumModule(symbol)publicType := r.checker.getTypeOfSymbol(symbol)if publicType == originalType { return nil }publicSignature := r.checker.getDefiniteSingleCallSignature(publicType)if publicSignature == nil { return nil }requestNodeBuilder := NewNodeBuilder(r.checker, emitContext)if len(r.checker.getPropertiesOfType(publicType)) != 0 ||    len(r.checker.getSignaturesOfType(publicType, SignatureKindConstruct)) != 0 ||    len(r.checker.getIndexInfosOfType(publicType)) != 0 {    return emitContext.Factory.NewPropertyDeclaration(nil, nil, nil,        requestNodeBuilder.TypeToTypeNode(publicType, enclosingDeclaration,            flags, internalFlags, tracker), nil)}return requestNodeBuilder.SignatureToSignatureDeclaration(publicSignature,    ast.KindMethodDeclaration, enclosingDeclaration, flags, internalFlags, tracker)// declaration transformer의 handoffdecorated := tx.resolver.CreateDecoratedMethodDeclaration(    tx.EmitContext(), input.AsNode(), tx.enclosingDeclaration,    declarationEmitNodeBuilderFlags, declarationEmitInternalNodeBuilderFlags, tx.tracker,)

method syntax가 감당하지 못하는 callable

추가 facet이 없으면 SignatureToSignatureDeclaration이 method syntax를 만든다. property, construct, index가 있으면 TypeToTypeNode로 callable-typed property를 만든다. resolver는 모양을 전달하고 transformer가 source 이름과 modifier를 붙인다.

transformMethodDeclaration은 method와 property 양쪽에서 source modifiers, name, postfix token을 유지한다. method는 새 parameters/type을 받고, property path는 preserveJsDoc도 호출한다. 그래야 static, protected, optional marker와 문서가 떨어지지 않는다.

private branch는 handoff보다 앞에 있다. TypeScript private는 기존 placeholder를, #private는 숨김을 유지한다. class transaction에는 static 조합까지 참여하지만 declaration은 source 접근 범위를 넓히지 않는다. runtime fixture도 TypeScript privatecontext.private === false#privatetrue를 구분한다.

최종 출력은 call-only면 method syntax, .tag 같은 facet이 있으면 property syntax가 된다.

TS
export type Replacement = ((value: string) => boolean) & {  readonly tag: "decorated";};export declare class Example {  static parse(value: string): number;  protected transform(value: string): boolean;  private hidden;  #private;  richMethod: Replacement;}

hover와 .d.ts가 같은 타입을 말할 때

결국 ordinary lookup이 입구가 되고 class transaction이 중간 상태를 숨긴다. ordinary call resolution이 stage 성공을 정하고, generic mapper가 callable 전체를 치환하며, emit resolver와 transformer가 이름, modifier, privacy, JSDoc을 보존한다. hover, diagnostic, .d.ts가 같은 final callable을 가리켜야 module 경계 뒤에서도 같은 메서드 타입이 남는다.

lookup, class transaction, resolver, generic, emit의 경계가 처음부터 완성된 모습으로 나타난 것은 아니다. 실패 범위가 넓어지고 검토가 거듭될수록 빠져 있던 경계가 드러났으며, 그 검토에는 AI agent도 참여했다.

구체적으로 agent들은 코드 탐색, 구현 보조, 테스트 실행, standards와 spec 검토를 맡았고, 나는 문제와 의미 규칙을 선택한 뒤 실제 diff, 선언 생성물, 런타임 출력과 테스트 결과를 확인했다. TypeScript-Go의 CONTRIBUTING이 요구하듯 도구 사용을 공개하고, 결과를 이해하고 수정하며 설명할 책임은 작성자가 져야 한다. 이 글의 결론과 이 브랜치를 공개한 판단도 내 몫이다.

직접 실행해 보기: 공개 fork, CLI, VS Code

내가 로컬에서 보고 싶었던 성공 장면은 명확했다. 같은 parseInput 예제를 stock TypeScript로 검사하면 데코레이터의 TS1270과 method("21")의 호출 오류가 나오지만, 실험 compiler에서는 둘 다 사라지고 VS Code hover가 (value: string) => number를 보여야 한다. Zod 예제라면 page.hasNext가 유효하고 page.has_next가 오류여야 한다. 이 차이를 확인하는 데 배포나 전역 compiler 교체는 필요하지 않다.

fork를 빌드해 local tsgo를 만들기

최소 준비는 실험 브랜치를 submodule과 함께 clone하고 local binary를 만드는 것이다. 공식 TypeScript-Go build 안내와 root package.jsonhereby build와 같은 작업을 npm run build로도 제공한다.

SH
git clone --recurse-submodules https://github.com/ayden94/typescript-go.gitcd typescript-gogit checkout prototype/decorator-method-final-typenpm cihereby build

성공하면 clone 안에 built/local/tsgo가 생긴다. hereby를 명령으로 찾지 못한다면 마지막 줄 대신 npm run build를 사용하면 된다. 이 local binary가 보이면 기존 tsc를 건드리지 않고 sample만 따로 검사할 준비가 끝난다.

가장 작은 sample로 CLI 차이를 확인하기

sample에는 앞의 parseInput 예제와 표준 데코레이터 lib를 읽는 tsconfig.json을 둔다. Zod 변환까지 확인하려면 sample에 Zod를 설치해야 하므로, compiler 차이만 볼 때는 parseInput이 더 작은 재현이다.

JSON
{  "compilerOptions": {    "target": "ES2022",    "module": "ESNext",    "strict": true,    "noEmit": true,    "lib": ["ES2022", "ESNext.Decorators"]  },  "include": ["src/**/*.ts"]}

이 설정에서 실험 binary를 실행했을 때 TS1270과 문자열 인자 오류가 없어야 한다. 다음의 정확한 absolute-path -p 조합은 공식 문서에 그대로 실린 명령이 아니라, 문서화된 built/local/tsgo 경로와 tsc와 대체로 같은 CLI라는 설명에서 추론한 실행 예다.

SH
/path/to/typescript-go/built/local/tsgo -p /path/to/sample/tsconfig.json

같은 binary를 VS Code에 연결하기

아무 진단 없이 종료되면 CLI 쪽 최종 타입 합성은 확인한 셈이다. 이제 hover까지 보려면 VS Code marketplace의 TypeScriptTeam.native-preview를 설치하고 sample workspace가 방금 만든 binary를 가리키게 한다. 공식 extension README에 나온 두 설정에 실행 경로 확인용 옵션을 하나 더 붙이면 된다.

JSONC
{  "js/ts.experimental.useTsgo": true,  "js/ts.tsdk.path": "/path/to/typescript-go/built/local",  "js/ts.showDebugInfo": true}

설정을 저장한 뒤 TypeScript 7의 Select Version에서 workspace version을 고르고 Restart TS Server를 실행한다. 성공 여부는 두 군데에서 확인할 수 있다. Output -> TypeScript 7Resolved to /path/to/typescript-go/built/local/tsgo가 찍혀야 하고, js/ts.showDebugInfo를 켰다면 command picker의 ExecutablePID가 실제 process를 보여줘야 한다. 마지막으로 method hover가 (value: string) => number인지 확인한다. 설정 파일만 바뀌고 내장 TypeScript가 계속 실행 중인 경우를 여기서 걸러낼 수 있다.

실험의 경계와 돌아오는 길까지 확인하기

이 연결은 workspace가 지정한 executable을 실행하므로 신뢰하는 clone에서만 켜야 한다. 현재 extension 설정과 source는 workspace trust와 별도 opt-in을 요구하고 untrusted workspace를 지원하지 않으며, 필요한 VS Code engine 버전도 바뀔 수 있다. TypeScript 7의 Language Server Protocol (LSP) 경로는 기존 tsserver와 달라서 tsserver plugin을 제공하는 다른 extension이 그대로 적용되지 않을 수도 있다. 설치나 framework tooling이 예상과 다르면 현재 extension README와 package manifest를 먼저 확인해야 한다.

확인이 끝나면 TypeScript 7 command의 Disable TypeScript 7을 선택하거나 js/ts.experimental.useTsgofalse로 바꾸고 서버를 재시작한다. Output이 더는 실험 binary를 가리키지 않고, 같은 파일의 hover가 원래 숫자 시그니처로 돌아오며 데코레이터 진단에 TS1270이 다시 나타나는지 확인하면 복구까지 끝난다. 실험을 켜는 것만큼 원래 도구로 돌아오는 경로도 분명해야, 다른 작업에서 preview 차이를 프로젝트 버그로 오해하지 않는다.

hover 하나가 맞았다고 끝나지 않는다

처음 원했던 장면은 작았다. AssetRepository의 메서드에 Zod transform을 붙이고, 호출자에서 has_next가 아니라 hasNext가 보이는 것. 그 한 번의 hover가 맞으면 꽤 기쁠 것이다. 27개 메서드의 반환 타입을 손으로 다시 쓰지 않아도 되고, 스키마가 가진 입력과 출력의 차이가 repository 경계를 지나 호출자까지 이어지기 때문이다.

하지만 compiler 쪽에서 돌아보면 그 hover 하나는 마지막 결과의 아주 얇은 단면이다. 잘못된 입력은 정확히 거절되어야 하고, 안쪽에서 바깥쪽으로 decorator stack이 같은 순서로 합성되어야 하며, 실패한 stage는 진단을 남기되 다음 타입을 훔치지 않아야 한다. 메서드를 어떤 순서로 조회해도 같아야 하고 generic class의 인스턴스마다 올바르게 치환되어야 한다. .d.ts는 callable의 프로퍼티와 construct/index signature를 잃지 않아야 하며, 실제 JavaScript의 this, context.static, context.private, context.name도 타입이 약속한 동작과 맞아야 한다. 마지막으로 CLI와 로컬 VS Code가 같은 binary를 보고 있어야 한다.

has_nexthasNext로 바꾸는 일은 결국 철자 교정이 아니었다. 외부 응답을 어느 지점에서 내부 모델로 바꾸고, 그 변환의 결과를 누가 공개 계약으로 소유할지 정하는 일이었다. 데코레이터가 런타임에서 이미 그 책임을 수행한다면 타입도 따라오게 만들 수 있을지 궁금했고, 프로토타입은 가능하다는 한 가지 증거를 만들었다. 다만 한 번의 올바른 hover만으로는 충분하지 않다. invalid input, lookup order, declaration, runtime, editor가 모두 같은 has_next -> hasNext를 말할 때에야 그 타입은 정말 메서드의 최종 타입이라고 부를 만하다.

댓글

댓글을 불러오는 중...