템플릿 리터럴 타입(Template Literal Types) 다루기

템플릿 리터럴 타입(Template Literal Types) 다루기

템플릿 리터럴 타입은 문자열 리터럴 타입 위에 쌓여서 만들어져요. 그리고 유니언(union) 덕분에 여러 개의 문자열로 확장될 수 있는 능력을 갖고 있죠. JavaScript의 템플릿 리터럴 문자열과 문법이 똑같지만, 타입이 들어가는 자리에서 쓰인다는 점이 달라요. 구체적인 리터럴 타입과 함께 쓰이면, 템플릿 리터럴은 내용을 이어붙여서 새로운 문자열 리터럴 타입을 만들어냅니다.

출처: TypeScript 공식문서 - Template Literal Types

본문

가장 기본적인 형태를 먼저 볼게요. ${World}처럼 중간에 변수를 끼워 넣고, 그 문자가 가진 리터럴 타입이 실제 값으로 채워지는 걸 확인해 보시면 돼요.

type World = "world";
type Greeting = `hello ${World}`;
//        ^ = type Greeting = "hello world"

자리(interpolated position)에 유니언이 들어가면 어떻게 될까요? 이때 타입은 각 유니언 멤버가 나타낼 수 있는 모든 문자열 리터럴의 집합이 됩니다.

type EmailLocaleIDs = "welcome_email" | "email_heading";
type FooterLocaleIDs = "footer_title" | "footer_sendoff";

type AllLocaleIDs = `${EmailLocaleIDs | FooterLocaleIDs}_id`;
//          ^ = type AllLocaleIDs = "welcome_email_id" | "email_heading_id" | "footer_title_id" | "footer_sendoff_id"

템플릿 리터럴 안의 각 자리마다 유니언은 서로 교차 곱(교차곱) 방식으로 계산돼요. 자리가 두 개라면 두 유니언이 곱해지는 만큼 경우의 수가 늘어납니다.

type AllLocaleIDs = `${EmailLocaleIDs | FooterLocaleIDs}_id`;
type Lang = "en" | "ja" | "pt";

type LocaleMessageIDs = `${Lang}_${AllLocaleIDs}`;
//            ^ = type LocaleMessageIDs = "en_welcome_email_id" | "en_email_heading_id" | "en_footer_title_id" | "en_footer_sendoff_id" | "ja_welcome_email_id" | "ja_email_heading_id" | "ja_footer_title_id" | "ja_footer_sendoff_id" | "pt_welcome_email_id" | "pt_email_heading_id" | "pt_footer_title_id" | "pt_footer_sendoff_id"

큰 문자열 유니언이라면 코드를 실행하기 전에(generation) 미리 만들어 두는 방식을 권장하지만, 이렇게 수가 적은 경우에는 템플릿 리터럴 타입이 훨씬 유용해요.

타입 안의 정보로 새 문자열 정의하기(String Unions in Types)

템플릿 리터럴의 진짜 힘은 타입 안에 담긴 정보를 바탕으로 새로운 문자열을 정의할 때 나옵니다.

makeWatchedObject라는 함수가 전달받은 객체에 on()이라는 새 함수를 추가하는 경우를 생각해 볼게요. JavaScript에서 호출은 makeWatchedObject(baseObject)처럼 생겼을 거예요. 기본 객체는 아래처럼 생겼다고 상상해 보시죠.

const passedObject = {
  firstName: "Saoirse",
  lastName: "Ronan",
  age: 26,
};

기본 객체에 추가될 on 함수는 인자를 두 개 받아요. 하나는 eventName(타입은 string)이고, 다른 하나는 callback(타입은 function)이에요. eventName은 반드시 attributeInThePassedObject + "Changed" 꼴이어야 해요. 그러니까 기본 객체의 속성 firstName에서 파생된 이벤트 이름은 firstNameChanged가 되는 거죠.

callback 함수가 호출될 때는 이런 규칙이 적용돼요.

  • attributeInThePassedObject라는 이름과 연결된 타입의 값을 전달받아야 해요. firstNamestring으로 타입이 정해져 있으니, firstNameChanged 이벤트의 콜백은 호출 시점에 string을 받아야 하고요. 마찬가지로 age와 연결된 이벤트는 number 인자를 받아야 하죠.
  • 설명을 단순하게 하기 위해 void 반환 타입을 가져야 해요.

그럼 on()의 대략적인 시그니처는 on(eventName: string, callback: (newValue: any) => void) 정도가 되겠죠. 그런데 아까 위에서 우리는 코드에 담아두고 싶은 중요한 타입 제약을 몇 가지 발견했어요. 템플릿 리터럴 타입은 바로 이 제약들을 코드 안으로 끌어들여 줍니다.

const person = makeWatchedObject({
  firstName: "Saoirse",
  lastName: "Ronan",
  age: 26,
});

// makeWatchedObject has added `on` to the anonymous Object

person.on("firstNameChanged", (newValue) => {
  console.log(`firstName was changed to ${newValue}!`);
});

여기서 on"firstName"이 아니라 "firstNameChanged" 이벤트를 듣고 있다는 점에 주목해 보세요. on()의 단순한 시그니처만으로는, 감시 대상 객체의 속성 이름 유니언에 "Changed"를 붙인 값들의 집합으로 이벤트 이름을 제약하지 못해요. 그 계산을 JavaScript에서 한다면 Object.keys(passedObject).map(x => ${x}Changed)처럼 할 수 있겠죠. 타입 시스템 안의 템플릿 리터럴도 바로 그런 문자열 조작 방법을 제공합니다.

type PropEventSource<Type> = {
    on(eventName: `${string & keyof Type}Changed`, callback: (newValue: any) => void): void;
};

/// Create a "watched object" with an `on` method
/// so that you can watch for changes to properties.
declare function makeWatchedObject<Type>(obj: Type): Type & PropEventSource<Type>;

이렇게 하면 잘못된 속성을 넘겼을 때 오류를 내는 구조를 만들 수 있어요. 아래에서 오류가 나는 부분을 확인해 보시죠.

const person = makeWatchedObject({
  firstName: "Saoirse",
  lastName: "Ronan",
  age: 26
});

person.on("firstNameChanged", () => {});

// Prevent easy human error (using the key instead of the event name)
person.on("firstName", () => {});
// Argument of type '"firstName"' is not assignable to parameter of type '"firstNameChanged" | "lastNameChanged" | "ageChanged"'.

// It's typo-resistant
person.on("frstNameChanged", () => {});
// Argument of type '"frstNameChanged"' is not assignable to parameter of type '"firstNameChanged" | "lastNameChanged" | "ageChanged"'.

템플릿 리터럴로 추론하기(Inference with Template Literals)

방금 예제에서는 원래 전달된 객체의 모든 정보를 다 활용하지는 못했어요. firstName이 바뀌는 일(firstNameChanged 이벤트)이 생기면 콜백이 string 타입 인자를 받는다고 기대할 수 있겠죠. age가 바뀌는 일에는 number를 받아야 하고요. 그런데 우리는 콜백 인자를 any로 대충(naively) 타입하고 있었어요. 템플릿 리터럴 타입을 쓰면, 속성의 데이터 타입이 곧 그 속성 콜백의 첫 번째 인자 타입이 되도록 보장할 수 있습니다.

이걸 가능하게 하는 핵심 통찰은, 제네릭을 가진 함수에서 이렇게 활용할 수 있다는 거예요.

  • 첫 번째 인자에 쓰인 리터럴을 리터럴 타입으로 잡아내고(capture),
  • 그 리터럴 타입이 제네릭 안의 유효한 속성 유니언에 있는지 검증하며,
  • 검증된 속성의 타입을 Indexed Access로 제네릭 구조에서 찾아내고,
  • 그 타입 정보를 콜백 함수의 인자가 같은 타입이 되도록 적용합니다.
type PropEventSource<Type> = {
    on<Key extends string & keyof Type>
        (eventName: `${Key}Changed`, callback: (newValue: Type[Key]) => void): void;
};

declare function makeWatchedObject<Type>(obj: Type): Type & PropEventSource<Type>;

const person = makeWatchedObject({
  firstName: "Saoirse",
  lastName: "Ronan",
  age: 26
});

person.on("firstNameChanged", newName => {
    // (parameter) newName: string
    console.log(`new name is ${newName.toUpperCase()}`);
});

person.on("ageChanged", newAge => {
    // (parameter) newAge: number
    if (newAge < 0) {
        console.warn("warning! negative age");
    }
})

여기서 우리는 on을 제네릭 메서드로 만들었어요. 사용자가 "firstNameChanged" 문자열로 호출하면, TypeScript는 Key에 맞는 타입을 추론하려고 해요. 그러려면 "Changed" 앞의 내용과 Key를 대조해서 "firstName"이라는 문자열을 추론해 내요. 이를 알아내면 on 메서드는 원래 객체에서 firstName의 타입을 가져오는데, 이 경우에는 string이죠. 마찬가지로 "ageChanged"로 호출하면 age 속성의 타입인 number를 찾아내요. 이렇게 추론은 여러 방식으로 조합될 수 있어서, 문자열을 분해했다가 다른 방식으로 다시 조립하는 일에 자주 쓰입니다.

내장 문자열 조작 타입(Intrinsic String Manipulation Types)

문자열 조작을 돕기 위해 TypeScript는 문자열을 다룰 때 쓸 수 있는 타입 묶음을 포함하고 있어요. 이 타입들은 성능을 위해 컴파일러에 내장(intrinsic)되어 있어서, TypeScript와 함께 배포되는 .d.ts 파일에서는 찾을 수 없답니다.

Uppercase<StringType>

문자열의 각 문자를 대문자 버전으로 변환해요.

type Greeting = "Hello, world"
type ShoutyGreeting = Uppercase<Greeting>
//        ^ = type ShoutyGreeting = "HELLO, WORLD"

type ASCIICacheKey<Str extends string> = `ID-${Uppercase<Str>}`
type MainID = ASCIICacheKey<"my_app">
//      ^ = type MainID = "ID-MY_APP"

Lowercase<StringType>

문자열의 각 문자를 소문자 버전으로 변환해요.

type Greeting = "Hello, world"
type QuietGreeting = Lowercase<Greeting>
//        ^ = type QuietGreeting = "hello, world"

type ASCIICacheKey<Str extends string> = `id-${Lowercase<Str>}`
type MainID = ASCIICacheKey<"MY_APP">
//      ^ = type MainID = "id-my_app"

Capitalize<StringType>

문자열의 첫 문자를 대문자 버전으로 변환해요.

type LowercaseGreeting = "hello, world";
type Greeting = Capitalize<LowercaseGreeting>;
//        ^ = type Greeting = "Hello, world"

Uncapitalize<StringType>

문자열의 첫 문자를 소문자 버전으로 변환해요.

type UppercaseGreeting = "HELLO WORLD";
type UncomfortableGreeting = Uncapitalize<UppercaseGreeting>;
//              ^ = type UncomfortableGreeting = "hELLO WORLD"

내장 문자열 조작 타입에 대한 기술적인 세부사항을 잠깐 짚어 볼게요. TypeScript 4.1 기준으로, 이 내장 함수들의 코드는 문자열 조작에 JavaScript 문자열 런타임 함수를 직접 사용하며, 로케일(locale)을 인식하지 않아요.

function applyStringMapping(symbol: Symbol, str: string) {
    switch (intrinsicTypeKinds.get(symbol.escapedName as string)) {
        case IntrinsicTypeKind.Uppercase: return str.toUpperCase();
        case IntrinsicTypeKind.Lowercase: return str.toLowerCase();
        case IntrinsicTypeKind.Capitalize: return str.charAt(0).toUpperCase() + str.slice(1);
        case IntrinsicTypeKind.Uncapitalize: return str.charAt(0).toLowerCase() + str.slice(1);
    }
    return str;
}

더 알아보기