TypeScript 핸드북 10 - 고급 타입

고급 타입(Advanced Type)

Intersection Types

Intersection 타입은 여러 타입을 하나로 결합합니다. 이렇게하면 기존 타입을 모두 추가하여 필요한 모든 기능을 갖춘 단일 타입을 얻을 수 있습니다. 예를 들어, Person & Serializable & LoggablePersonSerializable이며 Loggable입니다. 즉, 이 타입의 객체는 세 가지 타입의 모든 멤버를 갖게됩니다.

Intersection 타입의 대부분은 mixin과 고전적인 객체 지향 모습에 맞지 않는 형태에서 보게 됩니다.(JavaScript에는 이런 것들이 많이 있습니다!) 다음은 mixin을 만드는 방법을 보여주는 간단한 예제입니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
function extend<T, U>(first: T, second: U): T & U {
let result = <T & U>{};
for (let id in first) {
(<any>result)[id] = (<any>first)[id];
}
for (let id in second) {
if (!result.hasOwnProperty(id)) {
(<any>result)[id] = (<any>second)[id];
}
}
return result;
}
class Person {
constructor(public name: string) { }
}
interface Loggable {
log(): void;
}
class ConsoleLogger implements Loggable {
log() {
// ...
}
}
var jim = extend(new Person("Jim"), new ConsoleLogger());
var n = jim.name;
jim.log();

Union Types

Union 타입은 Intersection 타입과 밀접한 관련이 있지만 매우 다르게 사용됩니다. 때로는 파라미터가 number 또는 string이 될 것으로 기대하는 라이브러리를 실행하게 될때도 있습니다. 예를 들어, 다음과 같은 함수를 살펴보겠습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
/**
* 문자열을 가져 와서 왼쪽에 'padding'을 추가합니다.
* 'padding'이 문자열이면 'padding'이 왼쪽에 추가됩니다.
* 'padding'이 숫자 인 경우 해당 개수의 공백이 왼쪽에 추가됩니다.
*/
function padLeft(value: string, padding: any) {
if (typeof padding === "number") {
return Array(padding + 1).join(" ") + value;
}
if (typeof padding === "string") {
return padding + value;
}
throw new Error(`Expected string or number, got '${padding}'.`);
}
padLeft("Hello world", 4); // returns " Hello world"

padLeft의 문제점은 padding 파라미터가 any로 입력된다는 것입니다. 즉, numberstring이 아닌 파라미터를 사용하여 호출할 수 있지만 TypeScript는 해당 파라미터를 수용합니다.

1
let indentedString = padLeft("Hello world", true); // 컴파일 타임에는 통과 하지만 runtime에 실패가 발생합니다.

전통적인 객체 지향 코드에서는 타입의 계층 구조를 만들어 두가지 타입을 추상화할 수 있습니다. 이것이 훨씬 더 명확하지만, 그것은 또한 약간 과잉대응입니다. 이러한 접근법은 이미 다른 곳에있는 함수를 사용하려는 경우에도 도움이되지 않습니다. padLeft의 원래 버전에 대한 좋은 점 중 하나는 우리가 Primitive를 전달할 수 있다는 것이었습니다. 이는 사용법이 간단하고 간결하다는 것을 의미합니다.

any 대신에 padding 파라미터에 Union 타입을 사용할 수 있습니다.

1
2
3
4
5
6
7
8
9
10
/**
* 문자열을 가져 와서 왼쪽에 "패딩"을 추가합니다.
* 'padding'이 문자열이면 'padding'이 왼쪽에 추가됩니다.
* 'padding'이 숫자 인 경우 해당 개수의 공백이 왼쪽에 추가됩니다.
*/
function padLeft(value: string, padding: string | number) {
// ...
}
let indentedString = padLeft("Hello world", true); // errors during compilation

Union 타입은 여러 타입 중 하나 일 수 있는 값을 나타냅니다. 수직 막대 (|)를 사용하여 각 타입을 구분하므로 number | string | booleannumber,string 또는boolean 일 수있는 값의 타입입니다.

만일 우리가 Union 타입을 가진 값을 가지고 있다면, Union의 모든 타입에 공통적인 멤버들만 접근할 수 있습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
interface Bird {
fly();
layEggs();
}
interface Fish {
swim();
layEggs();
}
function getSmallPet(): Fish | Bird {
// ...
}
let pet = getSmallPet();
pet.layEggs(); // okay
pet.swim(); // errors

Union 타입은 약간 까다로울 수 있지만 익숙해지기 위해서는 약간의 직감이 필요합니다. 타입이 A|B인 값을 가지고 있다면, 우리는 AB 둘 다 특정 멤버가 있음을 확실히 알 수 있습니다. 이 예제에서 Bird에는 fly라는 멤버가 있습니다. 그리고 Bird | Fish 타입에는 fly 메서드가 있음을 확신할 수 없습니다. 그렇기 때문에 런타임에 변수가 실제로 Fish 인 경우 pet.fly()를 호출하면 실패할 수 있습니다.

타입 가드와 차별 타입 (Type Guards and Differentiating Types)

Union 타입은 값들이 겹쳐 질 수있는 상황을 모델링하는데 유용합니다. 우리가 Fish를 가지고 있는지 여부를 구체적으로 알아야할 때 어떻게 해야 할까요? 두가지 값을 구별하는 JavaScript의 일반적인 방법은 멤버의 존재 여부를 확인하는 것입니다. 위에서 언급했듯이, Union 타입은 모든 구성 요소에 포함될 수 있는 멤버만 액세스할 수 있습니다.

1
2
3
4
5
6
7
8
9
let pet = getSmallPet();
// 이러한 각 프로퍼티의 액세스는 오류를 발생시킵니다.
if (pet.swim) {
pet.swim();
}
else if (pet.fly) {
pet.fly();
}

위 코드가 작동하도록 하려면 타입 어설션을 사용해야합니다.

1
2
3
4
5
6
7
8
let pet = getSmallPet();
if ((<Fish>pet).swim) {
(<Fish>pet).swim();
}
else {
(<Bird>pet).fly();
}

사용자 정의 타입 가드(User-Defined Type Guard)

타입 어설션을 여러번 사용해야 한다는 점에 주목하십시오. 일단 우리가 체크를 수행할때 각 지점 내에서 pet의 타입을 알 수 있으면 훨씬 더 좋을 것입니다.

TypeScript에는 타입 가드(Type guard)가 있습니다. 타입 가드(Type guard)는 어떤 Scope에서 타입을 보증하는 런타임 체크를 수행하는 몇 가지 표현식입니다. 타입 가드를 정의하기 위해서, 리턴 타입이 Type predicate인 함수를 정의할 필요가 있습니다.

1
2
3
function isFish(pet: Fish | Bird): pet is Fish {
return (<Fish>pet).swim !== undefined;
}

pet is Fish는 위 예에서 Type predicate입니다. PredicateparameterName is Type의 형식을 취합니다. 여기서 parameterName은 현재 함수 Signature의 파라미터 이름이어야 합니다.

isFish가 어떤 변수와 함께 호출될 때마다, 원래 타입이 호환 가능하다면 TypeScript은 그 변수를 그 특정 타입으로 추정할 것입니다.

1
2
3
4
5
6
7
8
// 'swim'과 'fly'에 대한 호출은 이제 모두 괜찮습니다.
if (isFish(pet)) {
pet.swim();
}
else {
pet.fly();
}

TypeScript는 petif문에서 Fish라는 것을 알고 있을뿐만 아니라, else에서는 Fish가 아니기 때문에 Bird가 있어야합니다.

타입 가드의 typeof

뒤로 돌아가서 Union 타입을 사용하는 padLeft 버전의 코드를 작성해 보겠습니다. 다음과 같이 Type predicates를 써서 쓸 수 있습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
function isNumber(x: any): x is number {
return typeof x === "number";
}
function isString(x: any): x is string {
return typeof x === "string";
}
function padLeft(value: string, padding: string | number) {
if (isNumber(padding)) {
return Array(padding + 1).join(" ") + value;
}
if (isString(padding)) {
return padding + value;
}
throw new Error(`Expected string or number, got '${padding}'.`);
}

그러나 타입이 Primitive 인지 알아내는 함수를 정의하는 것은 고통입니다. 다행스럽게도, TypeScript가 인식하기 때문에, typeof x === "number"를 자신의 함수로 추상화할 필요가 없습니다. 즉, 이 체크를 인라인으로 작성할 수 있음을 의미합니다.

1
2
3
4
5
6
7
8
9
function padLeft(value: string, padding: string | number) {
if (typeof padding === "number") {
return Array(padding + 1).join(" ") + value;
}
if (typeof padding === "string") {
return padding + value;
}
throw new Error(`Expected string or number, got '${padding}'.`);
}

typeof 타입 가드typeof v === "typename"typeof v !== "typename" 두 가지 형태로 인식됩니다. 여기서 "typename""number", "string", "boolean", 또는 "symbol"이어야 합니다. TypeScript는 여러분이 다른 문자열과 비교하는 것을 못하게 하지 않지만 TypeScript는 해당 표현을 타입 가드로 인식하지 않습니다.

타입 가드instanceof

typeof 타입 가드를 읽었고 JavaScript에서 instanceof 연산자에 익숙하다면 아마 여기서 설명하는 내용이 익숙할 것입니다.

instanceof 타입 가드는 생성자 함수를 사용하여 타입을 좁히는 방법입니다. 예를 들어, 이전의 문자열 padding 예제를 살펴보겠습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
interface Padder {
getPaddingString(): string
}
class SpaceRepeatingPadder implements Padder {
constructor(private numSpaces: number) { }
getPaddingString() {
return Array(this.numSpaces + 1).join(" ");
}
}
class StringPadder implements Padder {
constructor(private value: string) { }
getPaddingString() {
return this.value;
}
}
function getRandomPadder() {
return Math.random() < 0.5 ?
new SpaceRepeatingPadder(4) :
new StringPadder(" ");
}
// 'SpaceRepeatingPadder | StringPadder' 타입입니다.
let padder: Padder = getRandomPadder();
if (padder instanceof SpaceRepeatingPadder) {
padder; // 타입이 'SpaceRepeatingPadder'로 좁혀졌습니다.
}
if (padder instanceof StringPadder) {
padder; // 타입이 'StringPadder'로 좁혀졌습니다.
}

instanceof의 오른쪽은 생성자 함수여야하며, TypeScript는 다음으로 순서로 범위를 좁힙니다.

  1. 타입이 any가 아닌 경우 함수의 prototype 프로퍼티 타입
  2. 그 타입의 생성자 Signatures 의해 리턴되는 타입의 Union 타입

Nullable types

TypeScript에는 nullundefined 값을 가질수 있는 두 가지 특별한 타입인 nullundefined 타입이 있습니다.Basic Types에서 간단히 언급했습니다. 기본적으로 타입 checker는
nullundefined를 어떤것이든 할당할수 있다고 간주합니다. 그리고,nullundefined는 모든 타입의 유효한 값입니다. 즉, 이 값의 할당을 막고 싶을 때조차도 any 타입에 할당되는 것을 막을 수 없다는 것을 의미합니다. null의 고안자인 토니 호아레 (Tony Hoare)는 이것을 “billion dollar mistake” 라고 부르기도 했습니다.

--strictNullChecks 플래그는 이 문제를 해결할 수 있습니다. 변수를 선언하면 null 또는 undefined가 자동으로 포함되지 않습니다. 하지만
Union 타입을 사용하여 명시적으로 포함 시킬수 있습니다.

1
2
3
4
5
6
let s = "foo";
s = null; // error, 'null' is not assignable to 'string'
let sn: string | null = "bar";
sn = null; // ok
sn = undefined; // error, 'undefined' is not assignable to 'string | null'

TypeScript는 JavaScript 의미와 일치시키기 위해 nullundefined를 다르게 취급합니다. string | nullstring | undefinedstring | undefined | null과 다른 타입입니다.

Optional 파라미터와 프로퍼티

--strictNullChecks 옵션은 자동으로 | undefined를 포함 시킵니다.

1
2
3
4
5
6
7
function f(x: number, y?: number) {
return x + (y || 0);
}
f(1, 2);
f(1);
f(1, undefined);
f(1, null); // error, 'null'은 'number | undefined' 타입에 할당할 수 없습니다.

Optional 프로퍼티에 대해서도 마찬가지입니다.

1
2
3
4
5
6
7
8
9
10
class C {
a: number;
b?: number;
}
let c = new C();
c.a = 12;
c.a = undefined; // error, 'undefined' is not assignable to 'number'
c.b = 13;
c.b = undefined; // ok
c.b = null; // error, 'null' is not assignable to 'number | undefined'

타입 가드와 타입 어설션 (Type guards and type assertions)

Nullable 타입은 Union으로 구현 되었기 때문에 타입 가드를 사용하여 null을 제거해야합니다. 다행히도 JavaScript에서 작성하는 코드와 똑같습니다.

1
2
3
4
5
6
7
8
function f(sn: string | null): string {
if (sn == null) {
return "default";
}
else {
return sn;
}
}

위 코드에서 null 제거 코드는 명확하지만 더 간단한 연산자를 사용할 수 있습니다.

1
2
3
function f(sn: string | null): string {
return sn || "default";
}

컴파일러가 null 또는 undefined를 제거할 수없는 경우, 타입 선언 연산자를 사용하여 수동으로 제거할 수 있습니다. 구문은 변수
뒤에 !를 붙이는 것입니다. identifier!는 식별자의 타입에서 nullundefined를 제거합니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
function broken(name: string | null): string {
function postfix(epithet: string) {
return name.charAt(0) + '. the ' + epithet; // error, 'name' is possibly null
}
name = name || "Bob";
return postfix("great");
}
function fixed(name: string | null): string {
function postfix(epithet: string) {
return name!.charAt(0) + '. the ' + epithet; // ok
}
name = name || "Bob";
return postfix("great");
}

컴파일러가 중첩 함수 내에서 null을 제거할 수 없으므로 (즉시 함수 호출 표현식 제외) 이 예제에서는 중첩 함수를 사용합니다. 중첩된 함수에 대한 모든 호출을 추적할 수 없기 때문입니다. 특히 외부 함수에서 반환하는 경우가 그렇습니다. 함수가 호출되는 위치를 알지 못하면 본문이 실행될 때 name의 타입이 무엇인지 알 수 없습니다.

Type Aliases

타입 Alias는 타입의 새이름을 작성합니다. 타입 Alias는 때때로 인터페이스와 비슷하지만, Primitive, Union, Tuple, 그리고 여러분이 직접 작성한 타입에 이름을 붙일 수 있습니다.

1
2
3
4
5
6
7
8
9
10
11
type Name = string;
type NameResolver = () => string;
type NameOrResolver = Name | NameResolver;
function getName(n: NameOrResolver): Name {
if (typeof n === "string") {
return n;
}
else {
return n();
}
}

타입 Alias는 실제로 새 타입을 작성하지 않으며 해당 타입을 참조하는 새 이름을 작성합니다. Primitive의 Alias는 사용될 수 있지만 딱히 유용성은 없습니다.

인터페이스와 마찬가지로 타입 Alias도 Generic을 사용할 수 있습니다. 타입 파라미터를 추가하고 Alias 선언의 오른쪽에 사용할 수 있습니다.

1
type Container<T> = { value: T };

또한 프로퍼티에서 타입 Alias를 참조할 수 있습니다.

1
2
3
4
5
type Tree<T> = {
value: T;
left: Tree<T>;
right: Tree<T>;
}

Intersection 타입과 함께 우리는 Mind-bending 타입도 만들 수 있습니다.

1
2
3
4
5
6
7
8
9
10
11
type LinkedList<T> = T & { next: LinkedList<T> };
interface Person {
name: string;
}
var people: LinkedList<Person>;
var s = people.name;
var s = people.next.name;
var s = people.next.next.name;
var s = people.next.next.next.name;

그러나 타입 Alias가 선언의 오른쪽에 있는 곳은 사용할 수 없습니다.

1
type Yikes = Array<Yikes>; // error

Interfaces vs. Type Aliases

앞서 언급했듯이 타입 Alias는 인터페이스와 비슷한 일을할 수 있습니다. 그러나 약간의 차이가 있습니다.

한가지 차이점은 인터페이스는 어디에서나 사용되는 새로운 이름을 생성한다는 것입니다. 하지만 타입 Alias는 새 이름을 만들지 않습니다. 예를 들어 오류 메시지는 Alias를 사용하지 않습니다. 아래의 코드는 편집기에서 interfaced 위로 마우스를 가져 가면 Interface 를 반환한다고 나오지만 aliased는 객체 리터럴 타입을 반환한다는 것을 보여줄 것입니다.

1
2
3
4
5
6
type Alias = { num: number }
interface Interface {
num: number;
}
declare function aliased(arg: Alias): Alias;
declare function interfaced(arg: Interface): Interface;

두번째로 중요한 차이점은 타입 Aliase를 확장하거나 구현할 수 없습니다. (다른 타입을 확장/구현할 수도 없습니다).
소프트웨어의 이상적인 특성은 확장에 열려 있기 때문에 가능한 경우 항상 타입 Alias 대신 인터페이스를 사용해야합니다.

반면에, 인터페이스로 어떤 모양을 표현할 수 없고 Union이나 Tuple 타입을 사용해야 한다면, 일반적으로 타입 Aliase를 사용할 수 있습니다.

문자열 리터럴 타입

문자열 리터럴 타입을 사용하면 문자열에 있어야하는 정확한 값을 지정할 수 있습니다. 실제로 문자열 리터럴 타입은 Union 타입, 타입 가드타입 Alias와 잘 결합됩니다. 이러한 기능을 함께 사용하여 문자열에서 Enum 타입과 같이 작동할 수 있습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
type Easing = "ease-in" | "ease-out" | "ease-in-out";
class UIElement {
animate(dx: number, dy: number, easing: Easing) {
if (easing === "ease-in") {
// ...
}
else if (easing === "ease-out") {
}
else if (easing === "ease-in-out") {
}
else {
// error! null 또는 undefined를 넘겨서는 안됩니다.
}
}
}
let button = new UIElement();
button.animate(0, 0, "ease-in");
button.animate(0, 0, "uneasy"); // error: "uneasy"는 여기에 사용할 수 없습니다.

세가지 허용되는 문자열 중 하나는 전달할 수 있지만 다른 문자열은 오류가 발생합니다.

1
Argument of type '"uneasy"' is not assignable to parameter of type '"ease-in" | "ease-out" | "ease-in-out"'

오버로드를 구별하기 위해 동일한 방법으로 문자열 리터럴 타입을 사용할 수 있습니다.

1
2
3
4
5
6
function createElement(tagName: "img"): HTMLImageElement;
function createElement(tagName: "input"): HTMLInputElement;
// ... more overloads ...
function createElement(tagName: string): Element {
// ... code goes here ...
}

Discriminated Union

문자열 리터럴 타입, Union 타입, 타입 가드타입 Alias을 결합하여 Tagged union 또는 Algebraic 데이터 타입이라 불리는 Discriminated union이라는 고급 패턴을 빌드할 수 있습니다. Discriminated union은 함수형 프로그래밍에 유용합니다. 일부 언어는 자동으로 Discriminated union을 사용합니다. TypeScript는 현재 존재하는 JavaScript 패턴을 기반으로 합니다. 세가지 형식이 있습니다.

  1. 일반적인 문자열 리터럴 프로퍼티가 있는 타입 - Discriminated
  2. 타입의 합집합을 취하는 타입 Alias - Union
  3. 공통 프로퍼티의 타입 가드.
1
2
3
4
5
6
7
8
9
10
11
12
13
interface Square {
kind: "square";
size: number;
}
interface Rectangle {
kind: "rectangle";
width: number;
height: number;
}
interface Circle {
kind: "circle";
radius: number;
}

먼저 우리가 결합할 인터페이스를 선언합니다. 각 인터페이스는 다른 문자열 리터럴 타입을 가진 kind 프로퍼티을 가지고 있습니다. kind 프로퍼티는 Discriminant 또는 Tag라고 불립니다. 다른 프로퍼티는 각 인터페이스에 고유합니다. 인터페이스는 현재 서로 관련이 없습니다. 이제 그들을 결합 하겠습니다.

1
type Shape = Square | Rectangle | Circle;

이제 Discriminated union을 사용합니다.

1
2
3
4
5
6
7
function area(s: Shape) {
switch (s.kind) {
case "square": return s.size * s.size;
case "rectangle": return s.height * s.width;
case "circle": return Math.PI * s.radius ** 2;
}
}

철저한 검사(Exhaustiveness checking)

컴파일러가 Discriminated union의 모든 변종을 커버하지 않을 때 우리에게 알려주고 싶습니다. 예를 들어 ShapeTriangle을 추가하면area도 업데이트 해야합니다.

1
2
3
4
5
6
7
8
9
type Shape = Square | Rectangle | Circle | Triangle;
function area(s: Shape) {
switch (s.kind) {
case "square": return s.size * s.size;
case "rectangle": return s.height * s.width;
case "circle": return Math.PI * s.radius ** 2;
}
// should error here - we didn't handle case "triangle"
}

두 가지 방법이 있습니다. 첫 번째는 --strictNullChecks를 켜고 리턴 타입을 지정하는 것입니다.

1
2
3
4
5
6
7
function area(s: Shape): number { // error: returns number | undefined
switch (s.kind) {
case "square": return s.size * s.size;
case "rectangle": return s.height * s.width;
case "circle": return Math.PI * s.radius ** 2;
}
}

switch가 더 이상 철저하지 않기 때문에, TypeScript는 함수가 때때로 undefined를 리턴할 수 있다는 것을 알고 있습니다. 명시적 리턴 타입number를 가지고 있다면 리턴 타입이 실제로 number | undefined입니다. 그러나 이 방법은 조금 미묘하며, 게다가 --strictNullChecks가 오래된 코드에서 항상 작동하는 것은 아닙니다.

두번째 방법은 컴파일러가 철저히 검사하기 위해 사용하는 never 타입을 사용합니다.

1
2
3
4
5
6
7
8
9
10
11
function assertNever(x: never): never {
throw new Error("Unexpected object: " + x);
}
function area(s: Shape) {
switch (s.kind) {
case "square": return s.size * s.size;
case "rectangle": return s.height * s.width;
case "circle": return Math.PI * s.radius ** 2;
default: return assertNever(s); // error here if there are missing cases
}
}

여기서 assertNeversnever 타입인지 검사합니다 - 다른 모든 케이스가 제거된 후에 남아있는 타입입니다. 여러분이 case를 잊어 버리면 s는 실제 타입을 가지게되고 타입 에러가 발생합니다. 이 방법을 사용하려면 추가 기능을 정의해야하지만 잊어 버렸을 때 훨씬 더 확실히 알수 있습니다.

this 타입의 다형성

this 타입의 다형성은 포함하는 클래스 또는 인터페이스의 subtype을 나타냅니다. 이를 F-바운드 다형성 (F-bounded polymorphism)이라고합니다. 따라서 계층적 인터페이스를 훨씬 쉽게 표현할 수 있습니다. 각 연산 후에 this를 반환하는 간단한 계산기가 있습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
class BasicCalculator {
public constructor(protected value: number = 0) { }
public currentValue(): number {
return this.value;
}
public add(operand: number): this {
this.value += operand;
return this;
}
public multiply(operand: number): this {
this.value *= operand;
return this;
}
// ... other operations go here ...
}
let v = new BasicCalculator(2)
.multiply(5)
.add(1)
.currentValue();

클래스는 this 타입을 사용하기 때문에 클래스를 확장할 수 있고 새로운 클래스는 변경없이 이전 메서드를 사용할 수 있습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class ScientificCalculator extends BasicCalculator {
public constructor(value = 0) {
super(value);
}
public sin() {
this.value = Math.sin(this.value);
return this;
}
// ... other operations go here ...
}
let v = new ScientificCalculator(2)
.multiply(5)
.sin()
.add(1)
.currentValue();

this 타입이 없으면 ScientificCalculatorBasicCalculator를 확장하고 인터페이스를 유지할 수 없었을 것입니다. multiplysin 메서드가 없는 BasicCalculator를 리턴했을 것입니다. 그러나, this 타입을 사용하면 multiplythis를 반환하는데, 이것은 ScientificCalculator입니다.

인덱스 타입

인덱스 타입을 사용하면 컴파일러에서 동적 프로퍼티 이름을 사용하는 코드를 확인하도록 할 수 있습니다. 예를 들어 아래의 코드는 일반적인 JavaScript 패턴에서 객체 프로퍼티의 하위 집합을 선택하는 것입니다.

1
2
3
function pluck(o, names) {
return names.map(n => o[n]);
}

다음은 인덱스 타입 쿼리 및 인덱싱된 액세스 연산자를 사용하여 TypeScript에서 이 함수를 작성하고 사용하는 방법입니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
function pluck<T, K extends keyof T>(o: T, names: K[]): T[K][] {
return names.map(n => o[n]);
}
interface Person {
name: string;
age: number;
}
let person: Person = {
name: 'Jarid',
age: 35
};
let strings: string[] = pluck(person, ['name']); // ok, string[]

컴파일러는 실제로 그 이름이 Person의 프로퍼티인지 확인합니다. 이 예제는 몇 가지 새로운 타입 연산자를 도입합니다. 첫 번째는 인덱스 타입 쿼리 연산자인 keyof T입니다. 어떤 타입의 T에 대해서, keyof TT의 알려진 공개 프로퍼티 이름들의 합집합입니다.

1
let personProps: keyof Person; // 'name' | 'age'

keyof Person'name' | 'age'와 완벽하게 호환됩니다. 차이점은 Person에 또 다른 프로퍼티 address : string를 추가하면 keyof Person이 자동으로 'name' | 'age' | 'address'로 업데이트 된다는 것입니다. 그리고 pluck과 같은 generic 문장에서 keyof를 사용할 수 있습니다. 여기서 pluck는 그 이전에 프로퍼티 이름을 알 수 없습니다. 즉, 컴파일러는 올바른 프라퍼티 집합을 pluck에 전달했는지 확인합니다.

1
pluck(person, ['age', 'unknown']); // error, 'unknown' is not in 'name' | 'age'

두 번째 연산자는 인덱싱된 액세스 연산자인 T[K]입니다. 여기에서 type syntax는 expression syntax를 반영합니다. 즉, person['name']Person['name'] 타입을 가지고 있습니다. 이 예제에서는 단지 문자열입니다. 그리고 인덱스 타입의 질의와 마찬가지로 T[K]를 generic 문장에서 사용할 수 있습니다. 이 문장이 실제로 힘이 생기는 곳입니다. 타입 변수 K extends keyof T를 확실히 해야합니다. 다음은 getProperty라는 함수를 가진 또 다른 예제입니다.

1
2
3
function getProperty<T, K extends keyof T>(o: T, name: K): T[K] {
return o[name]; // o[name] is of type T[K]
}

getProperty에서 o:T 그리고 name:Ko[name]:T[K]를 의미합니다. T[K] 결과를 반환하면 컴파일러는 실제 키 타입을 인스턴스화 할 것이므로 getProperty의 리턴 타입은 요청한 프로퍼티에 따라 달라집니다.

1
2
3
let name: string = getProperty(person, 'name');
let age: number = getProperty(person, 'age');
let unknown = getProperty(person, 'unknown'); // error, 'unknown' is not in 'name' | 'age'

인덱스 타입 및 문자열 인덱스 시그니처

keyofT[K]는 문자열 인덱스 시그니처와 상호 작용합니다. 문자열 인덱스 시그니처를 가진 타입을 가지고 있다면, keyof T는 단지 문자열일 것입니다. 그리고 T[string]은 단지 인덱스 시그니처 타입입니다.

1
2
3
4
5
interface Map<T> {
[key: string]: T;
}
let keys: keyof Map<number>; // string
let value: Map<number>['foo']; // number

Mapped type

일반적인 작업은 기존 타입을 가져 와서 각 프로퍼티를 선택적으로 만드는 것입니다.

1
2
3
4
interface PersonPartial {
name?: string;
age?: number;
}

또는 읽기 전용 버전을 원할 수도 있습니다.

1
2
3
4
interface PersonReadonly {
readonly name: string;
readonly age: number;
}

이것은 JavaScript에서 종종 자주 발생합니다. TypeScript는 이전 타입의 Mapped type을 기반으로 새로운 타입을 생성할 수 있는 방법을 제공합니다. Mapped type에서 새 타입은 이전 타입의 각 특성을 동일한 방식으로 변환합니다. 예를 들어 readonly 또는 optional타입의 모든 프로퍼티를 만들 수 있습니다. 다음은 몇 가지 예입니다.

1
2
3
4
5
6
type Readonly<T> = {
readonly [P in keyof T]: T[P];
}
type Partial<T> = {
[P in keyof T]?: T[P];
}

그리고 사용하려면

1
2
type PersonPartial = Partial<Person>;
type ReadonlyPerson = Readonly<Person>;

가장 단순한 Mapped type과 그 부분을 살펴 보겠습니다.

1
2
type Keys = 'option1' | 'option2';
type Flags = { [K in Keys]: boolean };

구문은 내부에 for..in이 있는 인덱스 시그니처의 구문과 유사합니다. 세 부분으로 나뉩니다.

  1. 타입 변수 K는 차례대로 각 프로퍼티에 바인딩됩니다.
  2. 반복 처리할 프로퍼티의 이름이 들어있는 문자열 리터럴 Union Keys입니다.
  3. 프로퍼티의 결과 타입

이 간단한 예제에서 Keys는 하드코딩된 프로퍼티 이름 목록이고 프로퍼티 타입은 항상 boolean이므로 이 Mapped type은 다음과 같습니다.

1
2
3
4
type Flags = {
option1: boolean;
option2: boolean;
}

그러나 실제 응용 프로그램은 위의 Readonly 또는 Partial 처럼 보입니다. 그들은 기존의 타입을 기반으로하며, 어떤 방식으로든 필드를 변형합니다. 그것은 keyof와 indexed access type이 들어있는 곳입니다.

1
2
type NullablePerson = { [P in keyof Person]: Person[P] | null }
type PartialPerson = { [P in keyof Person]?: Person[P] }

그러나 일반적인 버전을 사용하는 것이 더 유용할 수도 있습니다.

1
2
type Nullable<T> = { [P in keyof T]: T[P] | null }
type Partial<T> = { [P in keyof T]?: T[P] }

이 예제들에서, 프로퍼티 리스트는 keyof T이고 결과 타입은 T[P]의 변형입니다. 이것은 Mapped type의 일반적인 사용을 위한 좋은 템플릿입니다. 왜냐하면 이러한 종류의 변환은 Homomorphic이기 때문에 매핑은 T의 프로퍼티에만 적용되고 다른 프로퍼티는 적용되지 않습니다. 컴파일러는 새로운 프로퍼티를 추가하기 전에 모든 기존 프로퍼티 modifier를 복사할 수 있음을 알고 있습니다. 예를 들어, Person.name이 읽기 전용이면, Partial<Person>.name은 읽기 전용이고 선택적입니다.

다음은 T [P]Proxy <T>클래스에 싸여있는 또 하나의 예입니다.

1
2
3
4
5
6
7
8
9
10
11
type Proxy<T> = {
get(): T;
set(value: T): void;
}
type Proxify<T> = {
[P in keyof T]: Proxy<T[P]>;
}
function proxify<T>(o: T): Proxify<T> {
// ... wrap proxies ...
}
let proxyProps = proxify(props);

Readonly <T>Partial <T>는 매우 유용하며, PickRecord와 함께 TypeScript의 표준 라이브러리에 포함되어 있습니다.

1
2
3
4
5
6
type Pick<T, K extends keyof T> = {
[P in K]: T[P];
}
type Record<K extends string | number, T> = {
[P in K]: T;
}

Readonly, PartialPick은 Homomorphic이고 Record는 그렇지 않습니다. Record가 Homomorphic이 아닌 이유는 프로퍼티를 복사하는 입력 타입을 취하지 않는다는 것입니다.

1
type ThreeStringProps = Record<'prop1' | 'prop2' | 'prop3', string>

Non-homomorphic 타입은 본질적으로 새로운 속성을 생성하므로 아무 곳에서나 프로퍼티 modifier를 복사할 수 없습니다.

Mapped type의 추론

이제 타입의 프로퍼티를 Wrapping하는 방법을 알았으므로 다음으로해야 할 일은 Unwrapping하는 것입니다. 다행히도, 그것은 꽤 쉽습니다.

1
2
3
4
5
6
7
8
9
function unproxify<T>(t: Proxify<T>): T {
let result = {} as T;
for (const k in t) {
result[k] = t[k].get();
}
return result;
}
let originalProps = unproxify(proxyProps);

이 Unwrapping 추론은 Homomorphic Mapped type에서만 작동합니다. Wrapping된 타입이 Homomorphic이 아닌 경우에는 Unwrapping 함수에 명시적 타입 파라미터를 지정해야합니다.


이 내용은 나중에 참고하기 위해 제가 공부하며 정리한 내용입니다.
의역, 오역, 직역이 있을 수 있음을 알려드립니다.
This post is a translation of this original article [https://www.typescriptlang.org/docs/handbook/advanced-types.html]

참고

공유하기