안녕하세요. SmartEditor FE 파트 주니어 개발자 권영언입니다.
저는 SmartEditor에서 기존 자바스크립트 코드를 타입스크립트로 마이그레이션하는 작업을 진행 중입니다. 점진적인 마이그레이션으로 기존 코드의 안정성을 유지하며, 정확한 타입 추론으로 타입 안정성과 편의성 등을 향상시키기 위해 노력하고 있습니다. 이 과정에서 타입스크립트의 다양한 기능들을 활용하여 타입을 프로그래밍했던 경험들을 공유하려고 합니다.
타입스크립트에서는 모든 타입을 명시적으로 지정하지 않더라도 타입스크립트 컴파일러가 코드를 분석하여 타입을 유추할 수 있습니다. 하지만, 명시적으로 타입을 지정하는 것이 코드의 가독성과 안정성을 높일 수 있으므로 필요에 따라 명시적인 타입 지정을 권장합니다.
때로는 더 정확한 타입 추론을 가능하게 하기 위해서 복잡한 타입을 정의해야 하는데요, 이때 타입스크립트에서 제공하는 여러 고급 기능들을 활용하여 타입을 프로그래밍할 수 있습니다.
이 글에서는 원하는 수준의 타입 추론이 가능한 타입을 정의하기 위한 방법을 알아볼 것입니다. 이를 위해 필요한 지식을 우리에게 익숙한 자바스크립트에서 값을 다루는 방식과 비교하며 이해하고, 실제로 팀에서 어떻게 활용하고 있는지 예시를 통해 보여드리겠습니다.
이 글은 TypeScript 4.8 버전을 기준으로 작성되었으며, 타입스크립트의 기초 지식에 대한 이해가 있다는 가정하에 설명합니다.
⎯ 목차 ⎯
1. 값 공간과 타입 공간
본론으로 들어가기 전에, 먼저 값 공간과 타입 공간에 대해 가볍게 알아보겠습니다.
타입스크립트에서는 두 가지 공간이 존재하는데요, 앞서 언급했듯이 타입스크립트는 자바스크립트에 타입 시스템이 추가된 것이므로 기존 자바스크립트의 영역에서 값을 다루는 값 공간과 타입스크립트에서 추가된 타입을 다루는 타입 공간으로 나누어집니다.
값 공간
기존 자바스크립트의 객체나 함수, 배열, 문자열 등의 값을 다루는 공간을 의미합니다. 타입스크립트 코드가 자바스크립트로 변환되어도 유지되어 런타임에 실제로 존재합니다.
타입 공간
타입 공간은 컴파일 타임에 자바스크립트 코드를 검사하기 위해 타입을 선언하는 공간을 의미합니다. 타입스크립트 코드가 자바스크립트로 변환되면 사라지는 영역이므로 런타임에 존재하지 않습니다.
다음 코드를 보겠습니다.
type Person = {
name: string;
age: number;
alive: boolean;
};
const person: Person = {
name: 'kwon',
age: 28,
alive: true
};
function isAlive(person: Person): boolean {
return person.alive;
}
위 타입스크립트 코드를 자바스크립트로 트랜스파일하면 아래와 같이 변환됩니다.
const person = {
name: "kwon",
age: 28,
alive: true,
};
function isAlive(person) {
return person.alive;
}
type
키워드로 선언되었던 Person
이라는 타입이 사라지고 person
객체와 isAlive
함수의 파라미터 및 반환 타입에 대한 타입 정의도 사라진 것을 볼 수 있습니다. 여기서 사라진 부분이 타입 공간이고 남아있는 부분이 값 공간이 되는 것입니다.
일반적으로 타입 공간에서는 값 공간에 선언된 값을 사용할 수 없고, 값 공간에서는 타입 공간에 선언된 타입을 사용할 수 없습니다. (예외적으로 class
나 enum
의 경우 값과 타입으로 모두 사용이 가능하기 때문에 타입 공간과 값 공간 모두에서 사용할 수 있습니다)
이와 유사하게 연산자 중에서도 값 공간과 타입 공간에서 서로 다른 용도로 사용되는 것들이 있는데요, 그중에 가장 중요한 typeof
연산자에 대해 잠시 알아보겠습니다.
먼저 값 공간에서의 typeof
연산자는 우리가 잘 알고 있듯이 대상 값의 런타임 타입을 문자열로 반환합니다.
const person = {
name: 'kwon',
age: 28,
alive: true
};
const personType = typeof person; // "object"
하지만 타입 공간에서는 값 공간에 선언된 값을 읽어서 타입으로 변환해 주는 역할을 수행합니다.
type PersonType = typeof person;
// type PersonType = { name: string; age: number; alive: boolean; }
이처럼 타입 공간에서의 typeof
연산자는 특별하게도 타입 공간에 값을 사용할 수 있게 해주는데요, 이를 통해 값을 타입으로 변환할 수 있게 됩니다. 이것은 값을 기반으로 새로운 타입을 만들어 내야 하는 경우에 매우 유용하게 사용됩니다. 하지만 반대로 타입을 값으로 변환하는 것은 불가능합니다. 값의 경우 단 하나의 타입만을 가지지만, 타입은 여러 개의 값을 가질 수 있으므로 하나의 값으로 결정할 수 없기 때문입니다.
2. 값을 다루는 방식과 타입을 다루는 방식
이제 원하는 타입을 정의하기 위해 필요한 타입스크립트의 기능들을 알아보겠습니다. 이해를 돕기 위해, 자바스크립트에서 값을 다루기 위한 개념들이 타입스크립트에서 어떠한 개념으로 대응되어 타입을 다룰 수 있는지 살펴보려고 합니다.
타입스크립트에서 타입을 다루는 것은 자바스크립트에서 값을 다루는 방식에 비해서 한정적인 구문만 사용할 수 있습니다. 그렇기 때문에 복잡한 연산이나 로직은 구현하기 어렵지만, 제공되는 기능을 잘 활용한다면 더 정확한 타입 추론을 위해서는 충분히 유용하게 활용할 수 있습니다.
1) 변수 선언
자바스크립트(값 공간)에서 변수 선언은 var
, let
, const
키워드를 사용해 이루어집니다.
// primitive type
const count = 0;
// reference type
const person = {
name: 'kwon',
age: 28,
alive: true
};
반면, 타입스크립트(타입 공간)에서 타입 선언은 type alias
나 interface
를 사용합니다. interface
는 객체 타입에 대해서만 사용할 수 있으므로 여기서는 type alias
를 사용했습니다.
// primitive type
type Count = number;
// reference type
type Person = {
name: string;
age: number;
alive: boolean;
};
2) 함수
간단한 예제로 두 문자열과 구분자를 받아 구분자와 함께 연결된 새로운 문자열 값을 반환해 주는 join
이라는 함수가 있다고 해보겠습니다. 이 경우 값 공간에서 함수 선언은 다음과 같이 할 수 있습니다.
function join(str1, str2, separator = ',') {
return `${str1}${separator}${str2}`;
}
const ab = join("a", "b"); // "a,b"
타입 공간에서는 유틸리티 타입에서 제네릭을 사용하여 함수와 같은 개념으로 사용할 수 있습니다. 다음은 위에서 자바스크립트로 작성된 join
함수의 동작을 타입 공간에서 구현한 예시입니다.
두 문자열 리터럴 타입을 구분자와 함께 연결하여 새로운 문자열 리터럴 타입을 만들어주는 유틸리티 타입입니다. 이 예시에서는 "A"
라는 문자열 리터럴 타입과 "B"
라는 문자열 리터럴 타입을 받아 "A,B"
라는 새로운 문자열 리터럴 타입을 반환합니다.
type Join<Str1 extends string, Str2 extends string, Separator extends string = ','> = `${Str1}${Separator}${Str2}`;
type AB = Join<'A', 'B'>; // type AB = "A,B"
자바스크립트의 함수가 값을 파라미터로 전달하여 사용하는 것과 같이 제네릭에서는 타입을 파라미터로 전달해 사용합니다. 또한, 함수의 Default Parameter와 마찬가지로 제네릭에서도 Default Type Parameter를 사용할 수 있습니다.
3) 분기
값 공간에서 조건에 따른 분기 처리는 if
문 혹은 삼항 연산자 등을 주로 사용합니다. 다음 예시처럼 조건식이 참 혹은 거짓으로 평가됨에 따라 어떤 구문을 실행할지 결정합니다.
다음의 간단한 예제를 보겠습니다. name
이 런타임 타입이 string
인지 판단하여 참이라면 "yes"
, 거짓이라면 "no"
라는 문자열을 result
라는 변수에 할당합니다.
const name = "kwon";
// if...else
let result;
if (typeof name === "string") {
result = "yes";
} else {
result = "no";
}
// 삼항 연산자
const result = typeof name === "string" ? "yes" : "no";
타입 공간에서는 Conditional Type을 통해 삼항 연산자와 같은 문법으로 분기를 처리할 수 있습니다.
그러나 값 공간에서 여러 비교 연산자(===
, !==
, >
, <
, …)를 통해 다양한 조건식을 사용할 수 있는 것과 달리, 타입 공간에서는 extends
절을 통한 조건식만 사용이 가능합니다. extends
절의 왼쪽 타입이 오른쪽 타입에 할당이 가능한지를 판단합니다.
type Name = "kwon";
type Result = Name extends string ? "yes" : "no"; // type Result = "yes"
위 예시에서 Name
은 "kwon"
이라는 문자열 리터럴 타입이므로 string
타입에 할당 는가능합니다. 따라서 true 분기에 위치한 "yes"
라는 문자열 리터럴 타입을 반환해 Result
타입에 할당되는 것을 볼 수 있습니다.
4) 반복
값 공간에서 반복되는 동작을 처리하기 위해서는 일반적으로 for
문이나 while
문과 같은 반복문 혹은 재귀 함수 등을 사용할 수 있습니다.
배열에 특정 원소가 존재하는지 여부를 반환하는 includes
함수가 있다고 해 보겠습니다. 이를 for
문을 사용하여 구현하면 다음과 같습니다.
function includes(arr, target) {
for (let i = 0; i < arr.length; i++) {
if (arr[i] === target) {
return true;
}
}
return false;
}
같은 동작을 이번에는 재귀로 구현해 보겠습니다.
function includes(arr, target) {
const [head, ...tail] = arr;
return head === target ? true : tail.length === 0 ? false : includes(tail, target);
}
타입 공간에는 반복문과 같은 문법이 따로 존재하지 않는데요, 대신에 재귀를 통해 반복적인 동작을 처리할 수 있습니다. TypeScript 4.1에서 Conditional Type을 재귀적으로 사용할 수 있게 된 후로 더 다양한 처리가 가능해졌습니다. 위의 includes 함수를 타입 공간에서 구현하면 다음과 같습니다.
type Equals<A, B> = A extends B ? B extends A ? true : false : false;
type Includes<Arr extends readonly any[], Target> =
Arr extends [infer Head, ...infer Tail]
? Equals<Head, Target> extends true
? true
: Includes<Tail, Target>
: false;
type Result = Includes<[1, 2, 3], 2>; // type Result = true;
Includes
라는 유틸리티 타입에서 다시 Includes
를 호출하여 반복적인 동작을 처리한 것을 볼 수 있습니다. 또한 값 공간에서 재귀를 활용하여 includes
함수를 구현한 것과 유사한 형태가 보이는 것도 확인할 수 있습니다.
5) 객체 다루기
① 프로퍼티 접근
값 공간에서 객체의 프로퍼티에 접근하기 위해서는 두 가지 방법을 사용할 수 있습니다.
- Dot Notation : 객체의 이름 뒤에
.
(마침표)를 통해 접근 - Bracket Notation : 객체 이름 뒤에
[]
(대괄호)를 통해 접근
// dot notation
const age = person.age;
// bracket notation
const age = person["age"];
타입 공간에서는 객체 타입의 프로퍼티 타입에 접근하려면 객체 타입 뒤에 []
(대괄호)를 통해서만 접근할 수 있습니다. .
(마침표)로 접근하면 에러가 발생합니다.
type Age = Person.age // error!
type Age = Person["age"]; // type Age = number
② key 추출
값 공간에서 특정 객체의 프로퍼티 키값을 추출하기 위해서는 Object.keys
라는 메서드를 활용할 수 있습니다. 이 메서드는 다음과 같이 객체의 모든 프로퍼티 키들을 원소로 가지는 배열을 반환합니다.
const keys = Object.keys(person); // ["name", "age", "alive"]
타입 공간에서도 객체 타입의 프로퍼티 키를 추출할 수 있는 keyof
키워드를 제공합니다. 값 공간에서는 배열의 원소로 반환하는 것과 달리 타입 공간에서 keyof
를 사용하면 객체의 모든 프로퍼티 키들을 문자열 리터럴 타입의 유니온 타입으로 반환합니다.
type Keys = keyof Person; // "name" | "age" | "alive"
③ 프로퍼티 순회
값 공간에서 객체의 프로퍼티를 순회할 때는 for...in
문을 사용하는 방법도 있겠지만 일반적으로는 배열의 형태로 변환해서 사용합니다. 배열로 변환하면 Array.prototype.map
이나 Array.prototype.filter
와 같은 메서드도 활용할 수 있습니다.
map
다음은 값 공간에서 객체의 모든 프로퍼티를 문자열로 변환하는 예제입니다.
const person = {
name: "kwon",
age: 28,
alive: true,
};
function stringifyProp(obj) {
return Object.fromEntries(Object.entries(person).map(([key, value]) => [key, String(value)]));
}
stringifyProp(person); // {name: "kwon", age: "28", alive: "true"}
타입 공간에서 객체 타입의 프로퍼티 타입을 string
으로 변환하고 싶다면 Mapped Type을 사용할 수 있습니다.
type Person = {
name: string;
age: number;
alive: boolean;
};
type StringifyProp<T> = {
[K in keyof T]: string;
};
type Result = StringifyProp<Person>;
// type Result = {name: string; age: string; alive: string;}
filter
이번에는 값 공간에서 객체의 number
타입 프로퍼티만 필터링하는 예제입니다.
function filterNumberProp(obj) {
return Object.fromEntries(Object.entries(obj).filter(([, value]) => typeof value !== "number"));
}
filterNumberProp(person); // {name: "kwon", alive: true}
타입 공간에서 객체의 특정 프로퍼티를 필터링하려면 Mapped Type에서 as
키워드를 함께 사용할 수 있습니다.
type FilterNumberProp<T> = {
[K in keyof T as T[K] extends number ? never : K]: T[K];
};
type Result = FilterNumberProp<Person>; // {name: string; alive: boolean;}
위와 같이 Mapped Type에서 as
키워드를 통해 객체 타입의 키에 never
타입을 할당하면 해당 키를 제외할 수 있습니다.
6) 패턴 매칭
값 공간에서 패턴 매칭을 위해 정규표현식을 사용할 수 있습니다.
const text = "id-1234";
const [, id] = text.match(/id-(.*)/);
console.log(id); // "1234"
타입 공간에서는 템플릿 리터럴 타입과 infer
키워드를 통해 문자열 패턴 매칭을 수행할 수 있습니다.
type Text = "id-1234";
type Id = Text extends `id-${infer Id}` ? Id : never;
// type Id = "1234"
여기선 문자열만 예로 들었지만, infer
키워드는 다음과 같이 더 다양한 동작을 처리할 수 있습니다.
type Parameters<T extends (...args: any) => any> = T extends (...args: infer P) => any ? P : never;
type ReturnType<T extends (...args: any) => any> = T extends (...args: any) => infer R ? R : any;
type Last<T extends readonly any[]> = T extends [...any, infer L] ? L : never;
위와 같이 내장 유틸리티 타입인 Parameters
와 ReturnType
에서 파라미터 타입 혹은 반환 타입의 위치에 올 타입을 추론하거나, Last
와 같이 튜플의 특정 위치의 원소 타입을 추론하는 것도 가능합니다.
3. 활용 사례
지금까지 자바스크립트에서 값을 다룰 때 사용되는 개념들을 타입스크립트에서 타입을 다룰 때 어떤 형태로 대응되는지 알아보았습니다. 이러한 개념들을 SmartEditor에서는 어떻게 활용하고 있는지 다음 두 가지 예제와 함께 살펴보겠습니다.
간단한 예제를 먼저 보겠습니다.
예제 1 – enum 대체하기
저희 팀에서는 상수 객체 선언 시에 enum
을 사용하는 대신에 자바스크립트의 객체와 const assertion
을 사용하여 상수 객체를 관리하고 있습니다. 현재 자바스크립트 코드를 타입스크립트로 마이그레이션하는 중이므로 기존 코드의 변경사항을 최소화하면서 string enum
과 유사하게 사용하기 위함인데요, 대략 아래와 같은 형태입니다.
// enum 사용 시
enum RegistryType {
Bin = "bin",
Store = "store",
Module = "module",
Decorator = "decorator",
Executer = "executer",
}
// object + const assertion
const REGISTRY_TYPE = {
BIN: "bin",
STORE: "store",
MODULE: "module",
DECORATOR: "decorator",
EXECUTER: "executer",
} as const;
enum
의 경우 값 공간에서는 값으로 타입 공간에서는 타입으로 모두 사용이 가능하지만, 일반 객체를 사용하는 경우 해당 객체의 프로퍼티 값들만 허용할 수 있는 타입이 필요합니다. 이런 타입을 쉽게 만들기 위해 아래와 같은 유틸리티 타입을 만들었습니다.
type TValues<T extends object> = T[keyof T];
TValues
는 객체 타입을 타입 파라미터로 받아 해당 객체의 모든 프로퍼티 값의 유니온 타입을 반환합니다. 간단히 동작을 살펴보겠습니다. keyof
키워드는 앞서 설명한 것과 같이 객체 타입의 모든 프로퍼티 키를 문자열 리터럴 타입의 유니온으로 반환하는데요, 객체 타입의 프로퍼티에 유니온 타입으로 접근하면 타입스크립트의 분배(distribution) 특성에 따라 다음과 같이 동작합니다.
분배(distribution
)
타입 분배는 유니온 타입 전체가 아닌 유니온 타입의 각 항목에 연산을 적용하는 타입스크립트 컴파일러의 동작을 의미합니다. 객체 타입 프로퍼티 접근 외에도 Condtional Type, Template Literal Type 등에서 이러한 특성이 나타납니다.
Conditional Type
type IsArray<T> = T extends readonly any[] ? "yes" : "no";
type Result = IsArray<number | string[]>;
// type Result = "no" | "yes"
다음과 같은 순서로 동작합니다.
IsArray<number> | IsArray<string[]>
(number extends readonly any[] ? "yes" : "no") |
(string[] extends readonly any[] ? "yes" : "no")
"no" | "yes"
Template Literal Type
type Color = "White" | "Black";
type Size = "S" | "M" | "L";
type Result = `${Color}-${Size}`;
// type Result = "White-S" | "White-M" | "White-L" | "Black-S" | "Black-M" | "Black-L"
예를 들어, Person["age" | "name"]
와 같이 접근하는 경우 다음과 같은 순서로 동작하게 됩니다.
Person["age"] | Person["name"]
number | string
type Person = {
name: string;
age: number;
alive: boolean;
};
type V1 = Person["age" | "name"];
// type V1 = string | number
type V2 = Person[keyof Person];
// type V2 = string | number | boolean
즉, 아래와 같이 사용할 수 있습니다. 또한, 여기서 값 공간에 속하는 REGISTRY_TYPE
을 타입 공간에서 사용하기 위해 typeof
연산자를 사용하여 타입으로 변환한 것도 확인할 수 있습니다.
const REGISTRY_TYPE = {
BIN: "bin",
STORE: "store",
MODULE: "module",
DECORATOR: "decorator",
EXECUTER: "executer",
} as const;
type TRegistryType = TValues<typeof REGISTRY_TYPE>;
// type TRegistryType = "bin" | "store" | "module" | "decorator" | "executer"
function getRegistry(type: TRegistryType) {
// ...
}
이 정도도 충분히 유용하지만, 배열이나 튜플 타입에서도 사용할 수 있도록 하고 싶습니다. 배열이나 튜플의 인덱스도 결국 객체의 프로퍼티이므로 number
타입으로 접근하여 값들의 유니온 타입을 얻어올 수 있습니다.
const LANG_CODES = ["ko", "en", "ja", "zh"] as const;
type TLangCode = typeof LANG_CODES[number];
// type TLangCode = "ko" | "en" | "ja" | "zh"
이제 위의 TValues
타입을 배열이나 튜플에도 동일하게 사용할 수 있게 개선해 보겠습니다.
type TValues<T extends object> = T extends readonly any[] ? T[number] : T[keyof T];
간단하게 Conditional Type으로 분기를 하나 추가하여 일반 객체 타입뿐만 아니라 배열이나 튜플 타입에서도 값들의 유니온 타입을 얻어올 수 있게 되었습니다.
다음은 조금 더 복잡한 예제입니다.
예제 2 – 안전한 get 메서드 타입 정의하기
SmartEditor는 다양한 기능을 가지고 있는 만큼 환경 정보나 제약 조건 혹은 특정 기능의 사용 여부 등 여러 가지 설정을 제공합니다. 예를 들면, 아래와 유사한 형태의 config 객체를 서비스로부터 주입받습니다.
interface IConfig {
documentConfig: {
width: number;
height: number;
layout: TLayout;
language: TLanguage;
// ...
};
pluginConfig: {
text: {
maxLength: number;
// ...
};
image: {
maxCount: number;
maxSize: number;
allowedExtensions: string[];
// ...
};
// ...
};
// ...
}
이러한 많은 설정값을 관리하기 위해 내부적으로 ConfigAgent
라는 클래스를 만들어 사용하고 있습니다. 다음 코드는 타입스크립트로 전환하기 전 사용하던 자바스크립트 코드의 일부입니다.
class ConfigAgent {
// ...
get(path) {
// ...
}
}
const configAgent = new ConfigAgent(config);
const imageConfig = {
maxSize: configAgent.get("pluginConfig.image.maxSize"),
maxCount: configAgent.get("pluginConfig.image.maxCount"),
allowedExtensions: configAgent.get("pluginConfig.image.allowedExtensions"),
};
configAgent
객체의 get
메서드를 통해 원하는 설정값을 얻어와 사용할 수 있습니다. 여기서 get
메서드는 lodash
의 get
함수와 같은 방식으로 config
객체의 속성 경로를 문자열 형태의 파라미터로 전달하여 접근합니다. 하지만 이러한 방식은 어떤 경로에 어떤 설정들이 존재하는지 일일이 기억하기 어렵고, 오탈자가 발생해도 바로 확인하기 어려운 문제가 있었습니다.
저는 이런 문제를 타입스크립트로 전환하면서 해결하고자 했는데요, 단순히 전달된 파라미터 타입에 대한 검증만 하는 것이 아니라, 더 유용한 타입을 만들어 다음 두 가지 동작이 가능하게 하려고 했습니다.
- 실제로 사용 가능한 프로퍼티 경로에 대한 문자열만 받도록 하고, 가능한 모든 프로퍼티 경로에 대한 자동완성을 제공합니다.
- 특정 프로퍼티 경로를 받아 해당하는 경로에 위치한 타입을 반환합니다.
이를 위해 다음과 같은 두 가지 타입을 만들었습니다.
1. TPropPaths
먼저 1번 동작이 가능하도록 특정 객체 타입을 받아 해당 객체의 가능한 모든 프로퍼티 경로를 문자열 리터럴 타입의 유니온 타입으로 반환하는 커스텀 유틸리티 타입을 만들었습니다.
구현은 다음과 같습니다.
type TPrimitive = string | number | boolean | bigint | symbol | undefined | null;
type TLeafType = TPrimitive | Function | readonly any[];
type TPropPaths<TObject> = {
[TKey in keyof TObject]: TObject[TKey] extends TLeafType
? TKey
: TKey | `${TKey & string}.${TPropPaths<TObject[TKey]> & string}`;
}[keyof TObject];
그럼 이제 위 타입이 어떻게 동작하는지 아래의 obj
객체를 통해 알아보겠습니다.
const obj = {
a1: {
b1: {
c1: "a1-b1-c1",
c2: "a1-b1-c2",
c3: {
d1: "a1-b2-c3-d1",
d2: "a1-b2-c3-d2",
},
},
},
a2: {
b2: "a2-b2",
},
} as const;
type TPaths = TPropPaths<typeof obj>;
// type TPaths = "a1" | "a1.b1" | "a1.b1.c1" | "a1.b1.c2" | "a1.b1.c3" | "a1.b1.c3.d1" | "a1.b1.c3.d2" | "a2" | "a2.b2"
- 대상 객체 타입인
TObject
를 받아 Mapped Type으로 키를 순회하며 해당 프로퍼티 타입이TLeafType
인지 확인합니다. (여기서TLeafType
은 더 이상 재귀적으로 들어갈 필요가 없는 설정값의 끝에 있는 타입을 의미합니다.) TLeafType
이라면 현재 키를 반환합니다.- 아니라면
TObject[TKey]
는 객체이므로 다시 재귀적으로TPropPaths
타입을 호출하여 템플릿 리터럴 타입을 사용해 현재 키와.
으로 연결하여 반환합니다. 이때, 중간 경로를 모두 포함해야 하므로 현재 키(TKey
)와의 유니온 타입을 반환합니다. - Mapped Type으로 만들어진 객체 타입의 모든 프로퍼티에
keyof
를 통해 접근하여 값 타입의 유니온 타입을 생성합니다. (앞선 예제인TValues
와 같은 동작) - 위 과정을 재귀적으로 반복하여 다음과 같은 형태의 템플릿 리터럴 타입이 생성됩니다.
`a1 | b1.${c1 | c2 | c3 | c3.${d1 | d2}}| a2 | a2.${b2}`
- 여기서도 템플릿 리터럴의 분배(distribution) 특성이 적용되므로 최종적으로 다음과 같은 문자열 리터럴 타입의 유니온 타입을 반환합니다.
"a1" | "a1.b1" | "a1.b1.c1" | "a1.b1.c2" | "a1.b1.c3" | "a1.b1.c3.d1" | "a1.b1.c3.d2" | "a2" | "a2.b2"
참고 : 템플릿 리터럴 타입에서 TKey & string
을 사용하는 이유
여기서 TKey & string
과 같은 인터섹션 타입을 사용하는 이유는 템플릿 리터럴 타입이 허용하지 않는 타입을 걸러내기 위함입니다.
TKey
는 객체의 프로퍼티 키로 사용할 수 있는 string | number | symbol
타입으로 추론되지만, 템플릿 리터럴 타입에서는 symbol
타입을 허용하지 않습니다. 따라서 string
과 인터섹션을 통해 해당 에러가 발생하지 않도록 처리합니다.
type Test = (number | string | symbol) & string;
// type Test = string
이를 통해 특정 객체의 가능한 모든 프로퍼티 경로를 문자열 리터럴 타입의 유니온 타입으로 얻을 수 있게 되었습니다.
2. TPropTypeAtPath
이번에는 2번 동작이 가능하도록 대상 객체 타입과 얻고자 하는 경로에 해당하는 문자열 리터럴 타입을 받아 해당 경로의 프로퍼티 타입을 반환하는 유틸리티 타입을 만들었습니다.
구현은 다음과 같습니다.
type TPropTypeAtPath<TObject, TPath> = TPath extends keyof TObject
? TObject[TPath]
: TPath extends `${infer TKey}.${infer TRest}`
? TKey extends keyof TObject
? TPropTypeAtPath<TObject[TKey], TRest>
: unknown
: unknown;
이번에도 마찬가지로 위에서 본 obj
객체를 기준으로 동작을 살펴보겠습니다.
type PropType = TPropTypeAtPath<typeof obj, "a1.b1.c1">;
// type PropType = "a1-b1-c1"
- 대상 객체 타입
TObject
와 프로퍼티 경로에 해당하는 문자열 리터럴 타입TPath
를 인자로 받습니다. TPath
가TObject
의 프로퍼티 키에 해당하는 경우 해당 프로퍼티의 타입을 반환합니다.- 그렇지 않고
TPath
가${infer TKey}.${infer TRest}
형태의 템플릿 리터럴 타입으로 표현 가능한 경우, 경로에서 가장 앞에 있는 키(TKey
)와 나머지 경로(TRest
)를 추론합니다.
(위 예시에서는TKey
에"a1"
이 추론되고,TRest
에"b1.c1"
이 추론되겠죠?) TKey
가TObject
의 프로퍼티 키에 해당하는 경우, 다시TPropTypeAtPath
를 호출하여 해당 프로퍼티 타입 내부의 경로를 찾습니다. 이 과정을 필요한 만큼 재귀적으로 반복합니다.
이를 통해obj["a1"]["b1"]["c1"]
의 타입에 해당하는"a1-b1-c1"
이라는 문자열 리터럴 타입을 반환하게 됩니다.- 그 외의 경우, 해당 경로를 찾을 수 없으므로
unknown
타입을 반환합니다.
위의 두 가지 타입을 사용하여 기존 자바스크립트 코드를 아래와 같은 형태의 타입스크립트 코드로 전환할 수 있습니다.
class ConfigAgent<TConfig extends object> {
private _config: TConfig;
constructor(config: TConfig) {
this._config = config;
}
// ...
get<TPath extends TPropPaths<TConfig>>(path: TPath): TPropTypeAtPath<TConfig, TPath> {
// ...
}
}
위의 get
메서드에서는 생성자에서 받은 config
객체의 타입을 기반으로 가능한 프로퍼티 경로에 대한 문자열만 인자로 전달할 수 있으며 해당 경로에 있는 프로퍼티 타입을 정확히 추론할 수 있습니다. 앞에서 보여드린 IConfig
타입의 config
객체를 전달한 경우 IDE에서는 다음과 같이 동작합니다.

이제 가능한 모든 프로퍼티 경로가 자동완성이 되며 입력한 경로에 해당하는 정확한 타입까지 추론되는 것을 확인할 수 있습니다.
4. 마치며
지금까지 타입스크립트에서 타입을 프로그래밍하는 방법과 활용 사례에 대해 알아보았습니다. 이를 잘 활용하면 타입 안정성과 편의성 등 많은 이점을 얻을 수 있습니다. 그러나 불필요하게 복잡한 타입 정의는 오히려 가독성을 악화시키고, 함께 일하는 팀원들에게 혼란을 줄 수 있습니다.
따라서 적절한 수준에서 타입을 다루는 것이 중요하며, 타입 안정성과 편의성이 주는 장점보다 가독성 저하로 인한 단점이 커지지 않도록 균형을 잘 맞춰야 합니다.
이 글을 통해 타입스크립트를 이해하고 활용하는 데에 조금이나마 도움이 되셨기를 바랍니다. 긴 글 읽어주셔서 감사합니다!
참고 자료
글의 흐름 및 일부 예제는 다음 글을 참고했습니다:
타입 프로그래밍을 연습하고 싶다면 다음 링크에서 다양한 문제를 풀어볼 수 있습니다: