ReactNextCentral

타입 다루기

Published on
타입스크립트에서 타입을 다루는 방법에 대해 배우는 가이드입니다. 제네릭을 비롯해 `keyof`, `typeof` 연산자, 인덱스 접근 타입, 조건부 타입 등을 통해 보다 유연하고 강력한 타입 시스템을 활용하는 방법을 다룹니다. 이 가이드는 타입스크립트의 타입 생성 기능을 깊이 있게 이해하고자 하는 개발자들에게 유용한 정보를 제공합니다.
Table of Contents

타입에서 타입 생성하기

타입스크립트의 타입 시스템은 다른 타입을 통해 타입을 표현할 수 있기 때문에 매우 강력합니다.

이 아이디어의 가장 간단한 형태는 제네릭입니다. 또한, 다양한 타입 연산자를 사용할 수 있습니다. 이미 가지고 있는 값에 기반하여 타입을 표현하는 것도 가능합니다.

여러 타입 연산자를 결합함으로써, 복잡한 연산과 값을 간결하고 유지 관리하기 쉬운 방식으로 표현할 수 있습니다. 이 섹션에서는 기존 타입이나 값에 기반하여 새로운 타입을 표현하는 방법을 다룰 것입니다.

  • 제네릭: 매개변수를 취하는 타입
  • Keyof 타입 연산자: keyof 연산자를 사용하여 새로운 타입 생성
  • Typeof 타입 연산자: typeof 연산자를 사용하여 새로운 타입 생성
  • 인덱스 접근 타입: Type['a'] 문법을 사용하여 타입의 부분 집합에 접근
  • 조건부 타입: 타입 시스템에서 if 문처럼 작동하는 타입
  • 매핑된 타입: 기존 타입의 각 속성을 매핑하여 타입 생성
  • 템플릿 리터럴 타입: 템플릿 리터럴 문자열을 통해 속성을 변경하는 매핑된 타입

제네릭

소프트웨어 엔지니어링의 중요한 부분은 잘 정의되고 일관된 API를 가지며 재사용 가능한 컴포넌트를 구축하는 것입니다. 오늘날의 데이터뿐만 아니라 내일의 데이터에서도 작동할 수 있는 컴포넌트는 대규모 소프트웨어 시스템을 구축하기 위한 가장 유연한 기능을 제공합니다.

C#이나 자바와 같은 언어에서 재사용 가능한 컴포넌트를 만들기 위한 주요 도구 중 하나는 제네릭입니다. 즉, 단일 타입이 아닌 다양한 타입에 대해 작동할 수 있는 컴포넌트를 생성할 수 있습니다. 이를 통해 사용자는 이러한 컴포넌트를 사용하고 자신의 타입을 사용할 수 있습니다.

제네릭의 "Hello World"

우리의 첫걸음은 제네릭의 "hello world", 즉 항등 함수입니다. 항등 함수는 들어온 것을 그대로 반환하는 함수입니다. 이를 에코 명령어와 비슷하게 생각할 수 있습니다.

제네릭을 사용하지 않으면 항등 함수에 특정 타입을 지정해야 합니다.

function identity(arg: number): number {
  return arg;
}

또는 항등 함수를 any 타입을 사용하여 설명할 수 있습니다.

function identity(arg: any): any {
  return arg;
}

any를 사용하는 것은 분명 모든 타입의 arg를 함수가 받아들이게 만들기 때문에 제네릭이라고 할 수 있지만, 함수가 반환될 때 그 타입에 대한 정보를 실제로 잃어버립니다. 숫자를 넘겼다면 반환된 것이 어떤 타입이든 될 수 있다는 것만 알 수 있습니다.

대신, 인수의 타입을 캡처하는 방식이 필요하며, 이를 통해 반환되는 것이 무엇인지 표시하는 데에도 사용할 수 있습니다. 여기서 우리는 타입 변수를 사용할 것입니다. 타입 변수는 값이 아닌 타입에 작동하는 특별한 종류의 변수입니다.

function identity<Type>(arg: Type): Type {
  return arg;
}

이제 항등 함수에 타입 변수 타입을 추가했습니다. 이 타입을 통해 사용자가 제공하는 타입(예: number)을 캡처하여 나중에 그 정보를 사용할 수 있습니다. 여기서 다시 타입을 반환 타입으로 사용합니다. 검사해 보면, 인수와 반환 타입에 동일한 타입이 사용되었음을 알 수 있습니다. 이를 통해 함수 한쪽에서 타입 정보를 받아 다른 한쪽으로 전달할 수 있습니다.

이 버전의 항등 함수를 제네릭이라고 합니다. 왜냐하면 다양한 범위의 타입에 대해 작동하기 때문입니다. ₩를 사용하는 것과 달리, 첫 번째 항등 함수가 인수와 반환 타입에 숫자를 사용했던 것처럼 정확합니다(즉, 정보를 잃지 않습니다).

제네릭 항등 함수를 작성한 후에는 두 가지 방법 중 하나로 호출할 수 있습니다. 첫 번째 방법은 함수에 타입 인수를 포함한 모든 인수를 전달하는 것입니다.

let output = identity<string>("myString");

여기서 함수 호출에 대한 인수로 <>를 사용하여 타입을 명시적으로 문자열로 설정했습니다.

두 번째 방법이 가장 일반적일 수도 있습니다. 여기서 우리는 타입 인수 추론, 즉 컴파일러가 우리 대신에 타입의 값을 자동으로 설정하게 하고 싶습니다. 인수를 기반으로 합니다.

let output = identity("myString");

꺾쇠 괄호(<>)를 명시적으로 전달할 필요가 없었음을 알 수 있습니다. 컴파일러는 "myString" 값을 보고 타입을 그 타입으로 설정했습니다. 타입 인수 추론은 코드를 더 짧고 읽기 쉽게 유지하는 데 도움이 될 수 있지만, 컴파일러가 타입을 추론하지 못하는 더 복잡한 예제에서는 이전 예제처럼 타입 인수를 명시적으로 전달해야 할 수도 있습니다.

제네릭 타입 변수 다루기

제네릭을 사용하기 시작하면, 항등 함수(identity) 같은 제네릭 함수를 만들 때 컴파일러가 함수 본문 내에서 제네릭 타입 매개변수를 올바르게 사용하도록 강제한다는 것을 알게 될 것입니다. 즉, 이 매개변수들을 모든 가능한 타입으로 취급해야 합니다.

앞서의 항등 함수를 다시 살펴봅시다.

function identity<Type>(arg: Type): Type {
  return arg;
}

각 호출마다 인수 arg의 길이를 콘솔에 로그하고 싶다면 어떨까요? 다음과 같이 작성하고 싶을 수 있습니다.

function loggingIdentity<Type>(arg: Type): Type {
  console.log(arg.length);
  // 'length' 속성은 'Type' 타입에 존재하지 않습니다.
  return arg;
}

이렇게 하면 컴파일러는 arg.length 멤버를 사용하고 있지만, arg가 이 멤버를 가지고 있다고 언급한 곳이 없다는 오류를 발생시킵니다. 앞서 말했듯이, 이 타입 변수들은 모든 가능한 타입을 대신하기 때문에, 이 함수를 사용하는 사람이 .length 멤버가 없는 숫자를 넘길 수도 있습니다.

이 함수가 실제로는 Type 직접이 아닌 Type의 배열에서 작동하도록 의도했다고 합시다. 배열을 다루고 있으니, .length 멤버는 사용 가능해야 합니다. 다른 타입의 배열을 생성하는 것처럼 이를 설명할 수 있습니다.

function loggingIdentity<Type>(arg: Type[]): Type[] {
  console.log(arg.length);
  return arg;
}

loggingIdentity의 타입을 "제네릭 함수 loggingIdentity는 타입 매개변수 Type을 받고, Types의 배열인 인수 arg를 받아, Types의 배열을 반환합니다."라고 읽을 수 있습니다. 숫자의 배열을 넘겼다면, 숫자의 배열을 반환받게 되며, Type은 숫자에 바인딩됩니다. 이를 통해 전체 타입이 아닌 우리가 다루고 있는 타입의 일부로 제네릭 타입 변수 Type을 사용할 수 있어, 더 큰 유연성을 제공합니다.

또 다른 방식으로 이 예제를 다음과 같이 작성할 수도 있습니다.

function loggingIdentity<Type>(arg: Array<Type>): Array<Type> {
  console.log(arg.length); // Array는 .length를 가지므로 더 이상 오류가 발생하지 않습니다.
  return arg;
}

다른 언어에서 이 스타일의 타입에 이미 익숙할 수 있습니다. 다음 섹션에서는 Array<Type>과 같은 자신만의 제네릭 타입을 어떻게 생성할 수 있는지 다룰 것입니다.

제네릭 타입

이전 섹션에서는 다양한 타입에 걸쳐 작동하는 제네릭 항등 함수를 만들었습니다. 이 섹션에서는 함수 자체의 타입과 제네릭 인터페이스를 생성하는 방법을 탐구합니다.

제네릭 함수의 타입은 비제네릭 함수의 타입과 마찬가지로, 함수 선언과 비슷하게 타입 매개변수를 먼저 나열합니다.

function identity<Type>(arg: Type): Type {
  return arg;
}

let myIdentity: <Type>(arg: Type) => Type = identity;

타입의 제네릭 타입 매개변수에 다른 이름을 사용할 수도 있습니다. 타입 변수의 수와 타입 변수의 사용 방식이 일치하는 한입니다.

function identity<Input>(arg: Input): Input {
  return arg;
}

let myIdentity: <Input>(arg: Input) => Input = identity;

제네릭 타입을 객체 리터럴 타입의 호출 시그니처로도 작성할 수 있습니다.

function identity<Type>(arg: Type): Type {
  return arg;
}

let myIdentity: { <Type>(arg: Type): Type } = identity;

이를 통해 첫 번째 제네릭 인터페이스를 작성하는 데 이르게 됩니다. 이전 예제의 객체 리터럴을 인터페이스로 옮겨 봅시다.

interface GenericIdentityFn {
  <Type>(arg: Type): Type;
}

function identity<Type>(arg: Type): Type {
  return arg;
}

let myIdentity: GenericIdentityFn = identity;

비슷한 예제에서는 전체 인터페이스의 매개변수로 제네릭 매개변수를 옮기고 싶을 수도 있습니다. 이를 통해 우리가 어떤 타입(예: Dictionary<string> 대신 Dictionary)에 대해 제네릭인지 볼 수 있습니다. 이는 타입 매개변수를 인터페이스의 다른 멤버에게도 보이게 합니다.

interface GenericIdentityFn<Type> {
  (arg: Type): Type;
}

function identity<Type>(arg: Type): Type {
  return arg;
}

let myIdentity: GenericIdentityFn<number> = identity;

우리의 예제가 약간 다르게 변경되었음을 알 수 있습니다. 제네릭 함수를 설명하는 대신에, 이제 우리는 제네릭 타입의 일부인 비제네릭 함수 시그니처를 가지고 있습니다. GenericIdentityFn을 사용할 때는 이제 해당 타입 인수(여기서는 number)를 지정해야 하며, 이는 기본 호출 시그니처가 사용할 타입을 고정시킵니다. 호출 시그니처에 직접 타입 매개변수를 두는 경우와 인터페이스 자체에 두는 경우를 이해하는 것은 타입의 어떤 측면이 제네릭인지를 설명하는 데 도움이 될 것입니다.

제네릭 인터페이스 외에도 제네릭 클래스를 생성할 수 있습니다. 제네릭 열거형과 네임스페이스를 생성하는 것은 가능하지 않다는 점을 참고하세요.

제네릭 클래스

제네릭 클래스는 제네릭 인터페이스와 유사한 구조를 가집니다. 제네릭 클래스는 클래스 이름 뒤에 꺾쇠 괄호(<>) 안에 제네릭 타입 매개변수 목록을 가집니다.

class GenericNumber<NumType> {
  zeroValue: NumType;
  add: (x: NumType, y: NumType) => NumType;
}

let myGenericNumber = new GenericNumber<number>();
myGenericNumber.zeroValue = 0;
myGenericNumber.add = function (x, y) {
  return x + y;
};

이는 GenericNumber 클래스의 매우 직관적인 사용이지만, 이 클래스가 오직 number 타입만 사용하도록 제한하는 것은 아니라는 점을 알 수 있습니다. 대신 string이나 더 복잡한 객체를 사용할 수도 있습니다.

let stringNumeric = new GenericNumber<string>();
stringNumeric.zeroValue = "";
stringNumeric.add = function (x, y) {
  return x + y;
};

console.log(stringNumeric.add(stringNumeric.zeroValue, "test"));

인터페이스와 마찬가지로 클래스 자체에 타입 매개변수를 두면 클래스의 모든 속성이 같은 타입으로 작동하도록 할 수 있습니다.

클래스에 관한 섹션에서 다루듯이, 클래스는 타입의 두 가지 측면, 즉 정적 측면과 인스턴스 측면을 가집니다. 제네릭 클래스는 정적 측면이 아닌 인스턴스 측면에 대해서만 제네릭하므로, 클래스를 다룰 때 정적 멤버는 클래스의 타입 매개변수를 사용할 수 없습니다.

제네릭 제약 조건

앞서의 예제에서 기억할 수 있듯이, 때때로 특정 집합의 타입들에 대해 일부 기능에 대한 지식이 있는 제네릭 함수를 작성하고 싶을 수 있습니다. 우리의 loggingIdentity 예제에서는 arg.length 속성에 접근하고 싶었지만, 컴파일러는 모든 타입이 .length 속성을 가지고 있다는 것을 증명할 수 없었기 때문에 이러한 가정을 할 수 없다고 경고했습니다.

function loggingIdentity<Type>(arg: Type): Type {
  // 'length' 속성은 'Type' 타입에 존재하지 않습니다.
  console.log(arg.length);
  return arg;
}

모든 타입과 함께 작업하는 대신, .length 속성을 가진 모든 타입으로 이 함수의 작업 범위를 제한하고 싶습니다. 타입이 이 멤버를 가지고 있기만 하면 허용되지만, 최소한 이 멤버를 가지고 있어야 합니다. 이를 위해서는 Type이 될 수 있는 것에 대한 제약 조건으로 우리의 요구 사항을 나열해야 합니다.

이를 위해 우리의 제약 조건을 설명하는 인터페이스를 생성할 것입니다. 여기서는 단일 .length 속성을 가진 인터페이스를 생성한 다음 이 인터페이스와 extends 키워드를 사용하여 우리의 제약 조건을 나타낼 것입니다.

interface Lengthwise {
  length: number;
}

function loggingIdentity<Type extends Lengthwise>(arg: Type): Type {
  // 이제 .length 속성이 있다는 것을 알기 때문에 더 이상 오류가 발생하지 않습니다.
  console.log(arg.length); 
  return arg;
}

제네릭 함수가 이제 제약을 받기 때문에, 더 이상 모든 타입에 대해 작동하지 않습니다.

// 'number' 타입의 인수는 'Lengthwise' 타입의 매개변수에 할당할 수 없습니다.
loggingIdentity(3);

대신, 필요한 모든 속성을 가진 타입의 값을 전달해야 합니다.

loggingIdentity({ length: 10, value: 3 });

제네릭 제약 조건에서 타입 매개변수 사용하기

다른 타입 매개변수에 의해 제약되는 타입 매개변수를 선언할 수 있습니다. 예를 들어, 주어진 이름을 가진 객체에서 속성을 가져오고 싶을 때, 우리는 실수로 obj에 존재하지 않는 속성을 가져오지 않도록 하고 싶습니다. 그래서 두 타입 사이에 제약을 둘 것입니다.

function getProperty<Type, Key extends keyof Type>(obj: Type, key: Key) {
  return obj[key];
}

let x = { a: 1, b: 2, c: 3, d: 4 };

getProperty(x, "a"); // 작동함
// '"m"' 타입은 '"a" | "b" | "c" | "d"' 타입의 매개변수에 할당할 수 없습니다.
getProperty(x, "m");

제네릭에서 클래스 타입 사용하기

제네릭을 사용하여 TypeScript에서 팩토리를 생성할 때, 클래스 타입을 생성자 함수로 참조해야 합니다. 예를 들어,

function create<Type>(c: { new (): Type }): Type {
  return new c();
}

더 고급 예제는 생성자 함수와 클래스 타입의 인스턴스 측면 사이의 관계를 추론하고 제약하기 위해 프로토타입 속성을 사용합니다.

class BeeKeeper {
  hasMask: boolean = true;
}

class ZooKeeper {
  nametag: string = "Mikle";
}

class Animal {
  numLegs: number = 4;
}

class Bee extends Animal {
  numLegs = 6;
  keeper: BeeKeeper = new BeeKeeper();
}

class Lion extends Animal {
  keeper: ZooKeeper = new ZooKeeper();
}

function createInstance<A extends Animal>(c: new () => A): A {
  return new c();
}

createInstance(Lion).keeper.nametag; // 동작함
createInstance(Bee).keeper.hasMask; // 동작함

이 패턴은 믹스인 디자인 패턴을 구현하는 데 사용됩니다.

제네릭 매개변수 기본값

제네릭 타입 매개변수에 기본값을 선언함으로써 해당 타입 인수를 명시적으로 지정하지 않아도 되게 만들 수 있습니다. 예를 들어, 새로운 HTMLElement를 생성하는 함수가 있습니다. 인수 없이 함수를 호출하면 HTMLDivElement를 생성하고, 첫 번째 인수로 요소를 전달하면 인수 타입의 요소를 생성합니다. 또한 자식 요소 목록을 선택적으로 전달할 수도 있습니다. 이전에는 함수를 다음과 같이 정의해야 했습니다.

declare function create(): Container<HTMLDivElement, HTMLDivElement[]>;
declare function create<T extends HTMLElement>(element: T): Container<T, T[]>;
declare function create<T extends HTMLElement, U extends HTMLElement>(
  element: T,
  children: U[]
): Container<T, U[]>;

제네릭 매개변수 기본값을 사용하면 다음과 같이 간소화할 수 있습니다.

declare function create<T extends HTMLElement = HTMLDivElement, U = T[]>(
  element?: T,
  children?: U
): Container<T, U>;

const div = create();

const p = create(new HTMLParagraphElement());

제네릭 매개변수 기본값은 다음 규칙을 따릅니다.

  • 기본값이 있는 타입 매개변수는 선택적으로 간주됩니다.
  • 필수 타입 매개변수는 선택적 타입 매개변수를 따라올 수 없습니다.
  • 타입 매개변수에 대한 기본 타입은 타입 매개변수에 제약이 있다면 그 제약을 만족해야 합니다.
  • 타입 인수를 지정할 때 필수 타입 매개변수에 대해서만 타입 인수를 지정해야 합니다. 지정되지 않은 매개변수는 기본 타입으로 결정됩니다.
  • 기본 타입이 지정되어 있고 추론이 후보를 선택할 수 없는 경우 기본 타입이 추론됩니다.
  • 기존 클래스 또는 인터페이스 선언과 병합하는 클래스 또는 인터페이스 선언은 기존 타입 매개변수에 대한 기본값을 도입할 수 있습니다.
  • 기존 클래스 또는 인터페이스 선언과 병합하는 클래스 또는 인터페이스 선언은 기본값을 지정하는 한 새로운 타입 매개변수를 도입할 수 있습니다.

keyof 타입 연산자

keyof 연산자는 객체 타입을 받아 그 키들의 문자열 또는 숫자 리터럴 유니온을 생성합니다. 다음 타입 P는 "x" | "y"와 같은 타입입니다.

type Point = { x: number; y: number };
type P = keyof Point;

타입이 문자열 또는 숫자 인덱스 서명을 가지고 있다면, keyof는 해당 타입을 반환합니다.

type Arrayish = { [n: number]: unknown };
type A = keyof Arrayish;
// type A = number

type Mapish = { [k: string]: boolean };
type M = keyof Mapish;
// type M = string | number

이 예제에서 M은 string | number입니다 — 이는 자바스크립트 객체 키가 항상 문자열로 강제 변환되기 때문이며, obj[0]은 항상 obj["0"]과 같습니다.

keyof 타입은 매핑된 타입과 결합될 때 특히 유용해집니다. 매핑된 타입에 대해서는 나중에 더 자세히 알아볼 것입니다.

typeof 타입 연산자

자바스크립트에는 이미 표현식 컨텍스트에서 사용할 수 있는 typeof 연산자가 있습니다.

// "string" 출력
console.log(typeof "Hello world");

타입스크립트는 타입 컨텍스트에서 변수나 속성의 타입을 참조하는 데 사용할 수 있는 typeof 연산자를 추가합니다.

let s = "hello";
let n: typeof s;
// let n: string

이것은 기본 타입에 대해 그다지 유용하지 않지만, 다른 타입 연산자와 결합하여 typeof를 사용하면 많은 패턴을 편리하게 표현할 수 있습니다. 예를 들어, 미리 정의된 타입 ReturnType<T>를 살펴봅시다. 이 타입은 함수 타입을 취하고 그 반환 타입을 생성합니다.

type Predicate = (x: unknown) => boolean;
type K = ReturnType<Predicate>;
// type K = boolean

함수 이름에 ReturnType을 사용해보면 교육적인 오류를 볼 수 있습니다.

function f() {
  return { x: 10, y: 3 };
}
type P = ReturnType<f>;
// 'f'는 값으로 참조되지만 여기서는 타입으로 사용되고 있습니다. 'typeof f'를 의미하셨나요?

값과 타입은 같은 것이 아니라는 것을 기억하세요. 값 f가 가진 타입을 참조하려면 typeof를 사용합니다.

function f() {
  return { x: 10, y: 3 };
}
type P = ReturnType<typeof f>;
// type P = {
//     x: number;
//     y: number;
// }

제한사항

타입스크립트는 의도적으로 typeof를 사용할 수 있는 표현식의 종류를 제한합니다.

구체적으로, typeof는 식별자(즉, 변수 이름)나 그 속성에만 사용할 수 있습니다. 실행되는 것으로 생각했지만 실행되지 않는 혼란스러운 함정을 피하기 위함입니다.

// ReturnType<typeof msgbox>를 사용하려 했습니다
let shouldContinue: typeof msgbox("Are you sure you want to continue?");
// ','가 예상됩니다.

인덱스 접근 타입

다른 타입의 특정 속성을 찾아보기 위해 인덱스 접근 타입을 사용할 수 있습니다.

type Person = { age: number; name: string; alive: boolean };
type Age = Person["age"];
// type Age = number

인덱싱 타입은 자체적으로 타입이므로, 유니온, keyof, 또는 완전히 다른 타입을 사용할 수 있습니다.

type I1 = Person["age" | "name"];
// type I1 = string | number

type I2 = Person[keyof Person];
// type I2 = string | number | boolean

type AliveOrName = "alive" | "name";
type I3 = Person[AliveOrName];
// type I3 = string | boolean

존재하지 않는 속성을 인덱싱하려고 하면 오류가 발생합니다.

type I1 = Person["alve"];
// 'alve' 속성은 'Person' 타입에 존재하지 않습니다.

임의의 타입을 사용하여 인덱싱하는 또 다른 예는 숫자를 사용하여 배열 요소의 타입을 가져오는 것입니다. typeof와 결합하여 배열 리터럴의 요소 타입을 편리하게 캡처할 수 있습니다.

const MyArray = [
  { name: "Alice", age: 15 },
  { name: "Bob", age: 23 },
  { name: "Eve", age: 38 },
];

type Person = typeof MyArray[number];
// type Person = {
//     name: string;
//     age: number;
// }
type Age = typeof MyArray[number]["age"];
// type Age = number
// 또는
type Age2 = Person["age"];
// type Age2 = number

인덱싱할 때는 타입만 사용할 수 있으므로, 변수 참조를 만들기 위해 const를 사용할 수 없습니다.

const key = "age";
type Age = Person[key];
// 'key'는 인덱스 타입으로 사용될 수 없습니다.
// 'key'는 값으로 참조되지만 여기서는 타입으로 사용되고 있습니다. 'typeof key'를 의미하셨나요?

그러나 유사한 스타일의 리팩토링을 위해 타입 별칭을 사용할 수 있습니다.

type key = "age";
type Age = Person[key];

조건부 타입

대부분의 유용한 프로그램의 핵심에서는 입력을 바탕으로 결정을 내려야 합니다. 자바스크립트 프로그램도 다르지 않지만, 값이 쉽게 검사될 수 있다는 사실을 감안할 때 이러한 결정들은 입력의 타입에 기반하여 내려집니다. 조건부 타입은 입력의 타입과 출력의 타입 사이의 관계를 기술하는 데 도움을 줍니다.

interface Animal {
  live(): void;
}
interface Dog extends Animal {
  woof(): void;
}

type Example1 = Dog extends Animal ? number : string;
// type Example1 = number

type Example2 = RegExp extends Animal ? number : string;
// type Example2 = string

조건부 타입은 자바스크립트의 조건식(condition ? trueExpression : falseExpression)과 비슷한 형태를 취합니다.

SomeType extends OtherType ? TrueType : FalseType;

extends 왼쪽의 타입이 오른쪽의 타입에 할당 가능하면, 첫 번째 분기(“참” 분기)의 타입을 얻게 되고, 그렇지 않으면 뒤쪽 분기(“거짓” 분기)의 타입을 얻게 됩니다.

위의 예제에서 조건부 타입이 즉시 유용해 보이지 않을 수 있습니다 - DogAnimal을 확장하는지 여부를 우리 스스로 알 수 있으며, number 또는 string을 선택할 수 있습니다! 하지만 조건부 타입의 힘은 제네릭과 함께 사용할 때 나타납니다.

예를 들어, 다음과 같은 createLabel 함수를 살펴봅시다.

interface IdLabel {
  id: number; /* 몇몇 필드 */
}
interface NameLabel {
  name: string; /* 다른 필드 */
}

function createLabel(id: number): IdLabel;
function createLabel(name: string): NameLabel;
function createLabel(nameOrId: string | number): IdLabel | NameLabel;
function createLabel(nameOrId: string | number): IdLabel | NameLabel {
  throw "미구현";
}

이 오버로드들은 입력의 타입에 기반하여 선택을 내리는 단일 자바스크립트 함수를 기술합니다. 몇 가지 사항을 주목하세요.

  • API 전반에 걸쳐 라이브러리가 동일한 유형의 선택을 반복해야 한다면, 이는 번거로워집니다.
  • 확실한 타입을 위한 오버로드(하나는 string을 위한 것, 또 다른 하나는 number를 위한 것)와 가장 일반적인 경우(string | number를 취하는 경우)를 위한 오버로드, 총 세 개를 만들어야 합니다. createLabel이 처리할 수 있는 새로운 타입이 추가될 때마다 오버로드의 수는 기하급수적으로 증가합니다.

대신, 우리는 그 논리를 조건부 타입에 인코딩할 수 있습니다.

type NameOrId<T extends number | string> = T extends number
  ? IdLabel
  : NameLabel;

그런 다음 우리는 오버로드를 단일 함수로 간소화하여 해당 조건부 타입을 사용할 수 있습니다.

function createLabel<T extends number | string>(idOrName: T): NameOrId<T> {
  throw "미구현";
}

let a = createLabel("typescript");
// let a: NameLabel

let b = createLabel(2.8);
// let b: IdLabel

let c = createLabel(Math.random() ? "hello" : 42);
// let c: NameLabel | IdLabel

조건부 타입 제약 조건

종종 조건부 타입의 검사는 우리에게 새로운 정보를 제공합니다. 타입 가드로 타입을 좁히는 것이 더 구체적인 타입을 제공하는 것처럼, 조건부 타입의 참 분기는 검사 대상 타입으로 제네릭을 더욱 제약합니다.

예를 들어 다음을 살펴봅시다.

type MessageOf<T> = T["message"];
// 'T' 타입에 'message'라는 속성을 사용할 수 없습니다.

이 예제에서, TypeScript는 Tmessage라는 속성을 가지고 있다는 것을 알 수 없기 때문에 오류를 발생시킵니다. T를 제약하면 TypeScript는 더 이상 불평하지 않습니다.

type MessageOf<T extends { message: unknown }> = T["message"];

interface Email {
  message: string;
}

type EmailMessageContents = MessageOf<Email>;
// type EmailMessageContents = string

그러나 MessageOf가 어떤 타입이든 취하고, message 속성이 없으면 never와 같은 것으로 기본값을 설정하고 싶다면 어떨까요? 제약을 외부로 옮기고 조건부 타입을 도입함으로써 이를 수행할 수 있습니다.

type MessageOf<T> = T extends { message: unknown } ? T["message"] : never;

interface Email {
  message: string;
}

interface Dog {
  bark(): void;
}

type EmailMessageContents = MessageOf<Email>;
// type EmailMessageContents = string

type DogMessageContents = MessageOf<Dog>;
// type DogMessageContents = never

참 분기 내에서, TypeScript는 Tmessage 속성을 가질 것임을 압니다.

다른 예로, 배열 타입을 그 요소 타입으로 평탄화하고 그렇지 않은 경우에는 그대로 두는 Flatten이라는 타입을 작성할 수도 있습니다.

type Flatten<T> = T extends any[] ? T[number] : T;

// 요소 타입을 추출합니다.
type Str = Flatten<string[]>;
// type Str = string

// 타입을 그대로 둡니다.
type Num = Flatten<number>;
// type Num = number

Flatten이 배열 타입을 받으면, number를 사용한 인덱스 접근으로 string[]의 요소 타입을 추출합니다. 그렇지 않으면, 주어진 타입을 그대로 반환합니다.

조건부 타입 내에서 추론하기

우리는 조건부 타입을 사용하여 제약을 적용하고 타입을 추출하는 상황에 자주 처하게 됩니다. 이는 매우 일반적인 작업이어서 조건부 타입은 이를 더 쉽게 만들어 줍니다.

조건부 타입은 참 분기에서 비교 대상 타입에서 infer 키워드를 사용하여 추론할 수 있는 방법을 제공합니다. 예를 들어, Flatten에서 요소 타입을 "수동"으로 인덱스 접근 타입으로 가져오는 대신 추론할 수 있었습니다.

type Flatten<Type> = Type extends Array<infer Item> ? Item : Type;

여기서는 infer 키워드를 사용하여 Item이라는 새 제네릭 타입 변수를 선언적으로 도입했는데, 이는 참 분기 내에서 Type의 요소 타입을 어떻게 검색할지 지정하는 대신 사용됩니다. 이는 우리가 관심 있는 타입의 구조를 파고들고 조사하는 방법에 대해 생각할 필요가 없게 해줍니다.

infer 키워드를 사용하여 유용한 도우미 타입 별칭을 작성할 수 있습니다. 예를 들어, 간단한 경우에는 함수 타입에서 반환 타입을 추출할 수 있습니다.

type GetReturnType<Type> = Type extends (...args: never[]) => infer Return
  ? Return
  : never;

type Num = GetReturnType<() => number>;
// type Num = number

type Str = GetReturnType<(x: string) => string>;
// type Str = string

type Bools = GetReturnType<(a: boolean, b: boolean) => boolean[]>;
// type Bools = boolean[]

여러 호출 시그니처(예: 오버로드된 함수의 타입과 같은)를 가진 타입에서 추론할 때, 추론은 마지막 시그니처(가장 포괄적인 캐치올 경우일 것으로 가정)에서 이루어집니다. 인수 타입 목록을 기반으로 오버로드 해상도를 수행할 수는 없습니다.

declare function stringOrNum(x: string): number;
declare function stringOrNum(x: number): string;
declare function stringOrNum(x: string | number): string | number;

type T1 = ReturnType<typeof stringOrNum>;
// type T1 = string | number

분배 조건부 타입

조건부 타입이 제네릭 타입에 작용할 때 합집합 타입이 주어지면 분배가 발생합니다. 예를 들어 다음을 살펴봅시다.

type ToArray<Type> = Type extends any ? Type[] : never;

합집합 타입을 ToArray에 적용하면, 조건부 타입이 그 합집합의 각 멤버에 적용됩니다.

type ToArray<Type> = Type extends any ? Type[] : never;

type StrArrOrNumArr = ToArray<string | number>;
// type StrArrOrNumArr = string[] | number[]

여기서 ToArray는 다음에 분배됩니다.

string | number;

그리고 합집합의 각 멤버 타입을 다음과 같이 매핑합니다.

ToArray<string> | ToArray<number>;

이는 우리에게 다음을 남깁니다.

string[] | number[];

일반적으로, 분배성은 원하는 동작입니다. 이 동작을 피하려면, extends 키워드의 양쪽을 대괄호로 둘러싸세요.

type ToArrayNonDist<Type> = [Type] extends [any] ? Type[] : never;

// 'ArrOfStrOrNum'은 더 이상 합집합이 아닙니다.
type ArrOfStrOrNum = ToArrayNonDist<string | number>;
// type ArrOfStrOrNum = (string | number)[]

매핑된 타입

때로는 자신을 반복하고 싶지 않을 때, 어떤 타입이 다른 타입에 기반해야 할 필요가 있습니다.

매핑된 타입은 인덱스 시그니처의 문법을 기반으로 합니다. 인덱스 시그니처는 사전에 선언되지 않은 속성의 타입을 선언하는 데 사용됩니다.

type OnlyBoolsAndHorses = {
  [key: string]: boolean | Horse;
};

const conforms: OnlyBoolsAndHorses = {
  del: true,
  rodney: false,
};

매핑된 타입은 제네릭 타입으로, PropertyKeys의 유니온(주로 keyof를 통해 생성됨)을 사용하여 키를 반복하며 타입을 생성합니다.

type OptionsFlags<Type> = {
  [Property in keyof Type]: boolean;
};

이 예제에서, OptionsFlagsType에서 모든 속성을 가져와 그 값을 boolean으로 변경합니다.

type Features = {
  darkMode: () => void;
  newUserProfile: () => void;
};

type FeatureOptions = OptionsFlags<Features>;
// type FeatureOptions = {
//     darkMode: boolean;
//     newUserProfile: boolean;
// }

이 방식을 통해, 기존 타입의 속성을 기반으로 새로운 타입을 효율적으로 정의할 수 있으며, 타입 선언의 중복을 줄이는 데 도움이 됩니다.

매핑 수정자

매핑 과정에서 readonly?와 같은 두 가지 추가 수정자를 적용할 수 있으며, 이는 각각 변경 불가성과 선택성에 영향을 줍니다.

이러한 수정자는 - 또는 +를 앞에 붙여 추가하거나 제거할 수 있습니다. 접두사를 추가하지 않으면 +가 가정됩니다.

// 타입 속성에서 'readonly' 속성 제거
type CreateMutable<Type> = {
  -readonly [Property in keyof Type]: Type[Property];
};

type LockedAccount = {
  readonly id: string;
  readonly name: string;
};

type UnlockedAccount = CreateMutable<LockedAccount>;
// type UnlockedAccount = {
//     id: string;
//     name: string;
// }
// 타입 속성에서 'optional' 속성 제거
type Concrete<Type> = {
  [Property in keyof Type]-?: Type[Property];
};

type MaybeUser = {
  id: string;
  name?: string;
  age?: number;
};

type User = Concrete<MaybeUser>;
// type User = {
//     id: string;
//     name: string;
//     age: number;
// }

이 방식을 통해 타입의 속성에 대한 변경 불가성을 관리하거나 선택적 속성을 필수적인 속성으로 변환하는 등, 타입의 속성을 보다 유연하게 조절할 수 있습니다. 이러한 기능은 타입스크립트에서 타입의 재사용성과 유지보수성을 높이는 데 큰 도움이 됩니다.

as를 통한 키 재매핑

타입스크립트 4.1 이상에서는 매핑된 타입 내에서 as 절을 사용하여 키를 재매핑할 수 있습니다.

type MappedTypeWithNewProperties<Type> = {
    [Properties in keyof Type as NewKeyType]: Type[Properties]
}

템플릿 리터럴 타입과 같은 기능을 활용하여 이전의 속성 이름에서 새 속성 이름을 생성할 수 있습니다.

type Getters<Type> = {
    [Property in keyof Type \
      as `get${Capitalize<string & Property>}`]: () => Type[Property]
};

interface Person {
    name: string;
    age: number;
    location: string;
}

type LazyPerson = Getters<Person>;
// type LazyPerson = {
//     getName: () => string;
//     getAge: () => number;
//     getLocation: () => string;
// }

조건부 타입을 통해 never를 생성함으로써 키를 필터링할 수 있습니다.

// 'kind' 속성 제거
type RemoveKindField<Type> = {
    [Property in keyof Type as Exclude<Property, "kind">]: Type[Property]
};

interface Circle {
    kind: "circle";
    radius: number;
}

type KindlessCircle = RemoveKindField<Circle>;
// type KindlessCircle = {
//     radius: number;
// }

문자열, 숫자, 심볼의 합집합 뿐만 아니라 어떤 타입의 합집합에 대해서도 매핑을 적용할 수 있습니다.

type EventConfig<Events extends { kind: string }> = {
    [E in Events as E["kind"]]: (event: E) => void;
}

type SquareEvent = { kind: "square", x: number, y: number };
type CircleEvent = { kind: "circle", radius: number };

type Config = EventConfig<SquareEvent | CircleEvent>
// type Config = {
//     square: (event: SquareEvent) => void;
//     circle: (event: CircleEvent) => void;
// }

추가 탐색

매핑된 타입은 타입 조작 섹션의 다른 기능과 잘 작동합니다. 예를 들어, 객체가 pii 속성을 리터럴 true로 설정했는지 여부에 따라 true 또는 false를 반환하는 조건부 타입을 사용하는 매핑된 타입입니다.

type ExtractPII<Type> = {
  [Property in keyof Type]: Type[Property] extends { pii: true } ? true : false;
};

type DBFields = {
  id: { format: "incrementing" };
  name: { type: string; pii: true };
};

type ObjectsNeedingGDPRDeletion = ExtractPII<DBFields>;
// type ObjectsNeedingGDPRDeletion = {
//     id: false;
//     name: true;
// }

템플릿 리터럴 타입

템플릿 리터럴 타입은 문자열 리터럴 타입을 기반으로 하며, 합집합을 통해 많은 문자열로 확장될 수 있는 능력을 갖습니다.

자바스크립트의 템플릿 리터럴 문자열과 같은 문법을 가지고 있지만, 타입 위치에서 사용됩니다. 구체적인 리터럴 타입과 함께 사용될 때, 템플릿 리터럴은 내용을 연결하여 새로운 문자열 리터럴 타입을 생성합니다.

type World = "world";

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

보간된 위치에 합집합이 사용될 때 타입은 각 합집합 멤버가 표현할 수 있는 모든 가능한 문자열 리터럴의 집합입니다.

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"

일반적으로 큰 문자열 합집합에 대해서는 사전 생성을 권장하지만 작은 경우에는 이 기능이 유용합니다.

문자열 합집합 타입

템플릿 리터럴 타입의 힘은 타입 내부의 정보를 기반으로 새로운 문자열을 정의할 때 나타납니다.

예를 들어, 어떤 함수(makeWatchedObject)가 전달된 객체에 on()이라는 새로운 함수를 추가한다고 가정해 봅시다. 자바스크립트에서의 호출 방식은 makeWatchedObject(baseObject)와 같을 것입니다. 기본 객체는 다음과 같이 생겼을 것입니다.

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

기본 객체에 추가될 on 함수는 두 개의 인수, eventName(문자열)과 callback(함수)을 기대합니다.

eventName은 기본 객체의 속성에서 파생된 "속성이름Changed" 형식이어야 합니다. 따라서 기본 객체의 firstName 속성에서 파생된 firstNameChanged와 같습니다.

callback 함수는 호출될 때:

  • 속성이름과 관련된 타입의 값을 전달받아야 합니다. 예를 들어, firstName이 문자열로 타입이 지정되었으므로 firstNameChanged 이벤트의 callback은 호출 시 문자열을 전달받을 것으로 기대합니다. age와 관련된 이벤트는 숫자 인수를 기대합니다.
  • 반환 타입은 void여야 합니다(간단한 설명을 위해).

따라서 on()의 단순한 함수 시그니처는 on(eventName: string, callback: (newValue: any) => void)일 수 있습니다. 그러나 앞서 설명한 중요한 타입 제약 조건을 코드에 문서화하고자 합니다. 템플릿 리터럴 타입을 사용하면 이러한 제약 조건을 코드에 반영할 수 있습니다.

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

// makeWatchedObject는 익명 객체에 `on`을 추가했습니다

person.on("firstNameChanged", (newValue) => {
  console.log(`firstName이 ${newValue}로 변경되었습니다!`);
});

on은 이벤트 "firstNameChanged"를 수신하며, 단순히 "firstName"가 아닙니다. on()의 단순한 명세를 더욱 견고하게 만들고자 한다면, 관찰된 객체의 속성 이름 합집합에 "Changed"를 더한 끝으로 가능한 이벤트 이름 집합을 제한하려고 할 것입니다. 자바스크립트에서는 Object.keys(passedObject).map(x => '${x}Changed')와 같은 계산을 편안하게 수행하지만, 타입 시스템 내의 템플릿 리터럴을 사용하여 유사한 방식으로 문자열 조작을 수행할 수 있습니다.

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

/// 변경 사항을 감시하기 위한 'on' 메서드가 있는 "관찰된 객체"를 생성합니다.
declare function makeWatchedObject<Type>(obj: Type)
: Type & PropEventSource<Type>;

이를 통해 잘못된 속성이 주어졌을 때 오류가 발생하는 것을 구축할 수 있습니다.

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

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

// 키 대신 이벤트 이름을 사용하는 인간의 실수를 방지합니다
person.on("firstName", () => {});
// 'firstName' 타입은 '"firstNameChanged" | 
// "lastNameChanged" | "ageChanged"' 타입에 할당할 수 없습니다.

// 오타에 강합니다
person.on("frstNameChanged", () => {});
// '"frstNameChanged"' 타입은 '"firstNameChanged" 
// | "lastNameChanged" | "ageChanged"' 타입에 할당할 수 없습니다.

템플릿 리터럴로 추론하기

원래 전달된 객체에서 제공된 모든 정보를 활용하지 못했습니다. 예를 들어, firstName(즉, firstNameChanged 이벤트)의 변경 사항이 있을 때 콜백은 문자열 타입의 인수를 받아야 합니다. 마찬가지로, age의 변경 사항에 대한 콜백은 숫자 인수를 받아야 합니다. 우리는 콜백의 인수를 타입 any로 단순화하여 사용하고 있습니다. 다시 말하지만, 템플릿 리터럴 타입을 사용하면 속성의 데이터 타입이 해당 속성의 콜백 첫 번째 인수의 타입과 동일할 것임을 보장할 수 있습니다.

이를 가능하게 하는 핵심 통찰은 다음과 같습니다. 제네릭을 사용하는 함수에서,

  • 첫 번째 인수에 사용된 리터럴은 리터럴 타입으로 캡처됩니다
  • 해당 리터럴 타입은 제네릭의 유효한 속성의 합집합에 있는지 검증될 수 있습니다
  • 검증된 속성의 타입은 인덱스 접근을 사용해 제네릭 구조 내에서 조회될 수 있습니다
  • 이 타이핑 정보는 콜백 함수의 인수가 동일한 타입이 되도록 적용될 수 있습니다
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 => {
    console.log(`새 이름은 ${newName.toUpperCase()}입니다.`);
});

person.on("ageChanged", newAge => {
    if (newAge < 0) {
        console.warn("경고! 음수 나이");
    }
})

여기서 on을 제네릭 메서드로 만들었습니다.

사용자가 문자열 "firstNameChanged"로 호출할 때, 타입스크립트는 Key에 대한 올바른 타입을 추론하려고 시도합니다. 이를 위해 Key"Changed" 앞의 내용과 매치하여 문자열 "firstName"을 추론합니다. 타입스크립트가 이를 파악하면, 원래 객체의 firstName 속성의 타입을 가져올 수 있습니다. 이 경우에는 문자열입니다. 마찬가지로 "ageChanged"로 호출될 때, age 속성의 타입을 찾아내어 숫자로 판단합니다.

추론은 서로 다른 방식으로 결합될 수 있으며 종종 문자열을 분해하고 다른 방식으로 재구성하는 데 사용됩니다.

본질적인 문자열 조작 타입

문자열 조작을 돕기 위해 타입스크립트에는 문자열 조작에 사용될 수 있는 일련의 타입들이 포함되어 있습니다. 이 타입들은 컴파일러에 내장되어 있어 성능을 위해 사용되며, 타입스크립트와 함께 제공되는 .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"

본질적인 문자열 조작 타입에 대한 기술적 세부 사항

이 타입들은 타입스크립트에서 문자열을 조작하는 강력한 도구를 제공합니다. 개발자는 이를 통해 타입 수준에서 문자열의 형태를 세밀하게 제어할 수 있게 되며, 이는 코드의 안정성과 가독성을 향상시킵니다.