TypeScript에서 전역 개체 타입은 어떻게 정의하나요?

최근에 동료가 Node.js 애플리케이션에서 mongodb 클라이언트를 이용해서 데이터 삽입 로직을 테스트하는 코드를 작성했다.

import { MongoClient, Db, ObjectId } from 'mongodb';

describe('insert', () => {
  let client: MongoClient;
  let db: Db;

  beforeAll(async () => {
    client = new MongoClient(globalThis.__MONGO_URI__);

    await client.connect();

    db = client.db(globalThis.__MONGO_DB_NAME__);
  });

  afterAll(async () => {
    await client.close();
  });

  it('should insert a doc into collection', async () => {
    const users = db.collection('users');
    const mockUser = { _id: new ObjectId('some-user-id'), name: 'John' };
    await users.insertOne(mockUser);

    const insertedUser = await users.findOne({ _id: new ObjectId('some-user-id') });
    expect(insertedUser).toEqual(mockUser);
  });
});

그런데 이 코드에서 globalThis의 프로퍼티 타입을 TypeScript 컴파일러가 인식하지 못해서 에러가 발생했다. 특정 라이브러리가 전역 개체에 커스텀 프로퍼티를 추가하는데, 타입 정보를 제공하지 않아 컴파일러가 타입 에러를 뿜는 상황이었다. 호기심이 땡겨서 내가 도와주겠다고 선뜻 나섰다.

IDE가 globalThis의 __MONGO_URI__와 __MONGO_DB_NAME__을 인식하지 못하는 모습

구글링을 해보니 declare global로 타입을 선언해주면 된다는데 이게 잘 먹히지 않네? 이런.

적당히 알고 해결하면 다음에 또 삽질할 것 같아서 원인을 좀 더 깊이 추적해 들어가면서 학습한 내용을 기록한다.

globalThis는 무엇인가?

globalThis는 JS 실행 환경에 상관없이 전역 개체를 참조하는 통일 된 수단을 제공하는 표준 개체이며 ECMAScript 11에 도입되었다. 이 개체는 브라우저 환경에서는 window 개체를 참고하고, Node.js 환경에서는 global 개체를 참조한다.

한가지 주의할 점은 globalThis가 전역 개체를 참조할 뿐, 전역 개체 그 자체는 아니라는 점이다. 전역 실행 콘텍스트는 NewGlobalEnvironment를 갖는데, 이 안에 this(thisValue)가 담겨 있다. 이 this의 내부 슬롯 중에 [[GloblThisValue]]가 바로 globalThis다.

global 개체의 타입 재정의

그렇다면 TypeScript를 사용할 때 전역 개체의 프로퍼티 타입은 어떻게 정의를 해야 하는 걸까?

여기저기 찾아보니 declare global로 타입을 커스텀하라길래 types/global.d.ts에 아래와 같은 타입을 만들었다.

declare global {
  var __MONGO_URI__: string;
  var __MONGO_DB_NAME__: string;
}  

하지만 여전히 타입을 인식하지 못한다. 왜 안 되는 걸까?

여전히 __MONGO_URI__의 타입을 인식하지 못하는 모습

원인을 정확히 이해하려면 내가 모르는 개념 몇 개를 좀 더 자세히 찾아봐야 할 것 같다.

  1. declare는 정확히 뭔가?
  2. declare global로 타입을 선언해야 하는가?
  3. 하라는대로 했는데 왜 동작하지 않는가?

declare는 정확히 뭔가?

TypeScript는 Ambient Declaration라는 걸 정의하고 있다. 이는 TypeScript로 작성하지 않은 코드의 타입 정보를 컴파일러에게 알려주는 선언이다. 대게 외부 사용자에게 내가 만든 라이브러리의 타입 정보를 알려줄 목적으로 d.ts 파일을 정의할 때 Ambient Declaration을 이용한다. Ambient Declaration을 작성할 때 declare 키워드를 사용한다.

declare로 선언할 수 있는 타입 유형은 크게 세 가지다.

declare namespace

컴파일러는 namespace로 선언한 TS 코드를 JS 일반 객체로 컴파일 하는데, declare 키워드를 붙여주면 JS 코드로 컴파일을 하지 않는다. 이렇게 객체의 타입 정보만 알려줄 목적으로 declare namespace를 사용한다. 이를 Ambient Namespace 또는 Internal Module이라고 부른다.

declare namespace D3 {
    export interface Selectors {
        select: {
            (selector: string): Selection;
            (element: EventTarget): Selection;
        };
    }
    export interface Event {
        x: number;
        y: number;
    }
    export interface Base extends Selectors {
        event: Event;
    }
}
declare var d3: D3.Base;

declare module

declare module로 선언한 타입만 가진 모듈을 Ambient Module이라고 한다. Ambient Module이 컴파일 대상에 포함이 되어있기만 하면 TypeScript 컴파일러는 자동으로 타입을 인지할 수 있다. 이는 Ambient Namespace와 유사한데 import를 할 때 동작한다는 점이 다르다.

예를 들어 node.js는 자신이 제공하는 개별 모듈의 타입을 node.d.ts 파일에 아래와 같이 정의하고 있다.

declare module "url" {
    export interface Url {
        protocol?: string;
        hostname?: string;
        pathname?: string;
    }
    export function parse(urlStr: string, parseQueryString?, slashesDenoteHost?): Url;
}
declare module "path" {
    export function normalize(p: string): string;
    export function join(...paths: any[]): string;
    export var sep: string;
}

클라이언트는 외부 라이브러리를, 마치 TypeScript 모듈인 것처럼 import 해서 사용할 수 있다.

/// <reference path="node.d.ts"/>
import * as URL from "url";
let myUrl = URL.parse("<http://www.typescriptlang.org>");

declare global

전역 개체는 특별한 존재이며 import로 참조할 수 없는 모듈이기 때문에 TypeScript는 전역 개체의 타입을 커스텀하는 별도의 문법을 제공한다.

declare global 블럭 안에 선언한 타입은 전역 개체의 프로퍼티 타입으로 정의된다.

// 컴파일러는 export/import 구문이 없는 파일은 스크립트로 인지한다.
declare global {
  var country: any;
  function multiply(a: number, b: number): number;
}

그런데 TypeScript 컴파일러는 고유의 컴파일 규칙을 따라서 export/import 구문이 없는 파일은 일반 스크립트(그렇지 않은 경우는 모듈로 취급)로 취급한다. 위의 declare global 선언은 스크립트가 존재하는 스코프에 속하기 때문에 다른 모듈은 이 타입을 인지할 수 없다. 이 지점이 실체가 좀 아리까리한데 시간 날 때 더 파볼 생각이다.

이 문제는 아래와 같이 빈 export 구문을 하나 넣어주는 걸로 해결할 수 있다. export 구문이 있기 때문에 TypeScript 컴파일러는 이 파일을 모듈로 처리한다.

declare global {
  var country: any;
  function multiply(a: number, b: number): number;
}

// 가짜 export를 넣어서 외부 모듈로 인식시킬 수 있다.
export {};

그래서 해결은?

types/global.d.ts에 아래의 declare global 선언을 넣어주면,

export {};

declare global {
  var __MONGO_URI__: string;
  var __MONGO_DB_NAME__: string;
}

컴파일러가 타입 정보를 제대로 인지한다. 이걸로 문제 해결. 이 외에도 경로 인식 문제가 있었지만 그건… 이 글의 주제가 아니므로 패스.

모든 타입 에러가 해결되어 IDE가 __MONGO__URI__를 인식함을 보여주는 이미지

알고나면 별 거 아니지만 이런 유형의 문제는 원리를 제대로 모른 채 대충 해결하고 넘어가면 다음에 비슷한 문제를 만났을 때 또 헤메기 쉽다. 귀찮더라도 좀 더 깊이 들여다 볼 가치가 있다고 생각해서 굳이 이슈 해결 과정을 정리했다.

누군가에게 도움이 되었기를. 🙏