TypeScript 핸드북 3 - 인터페이스

인터페이스 (Interface)

소개

TypeScript의 핵심 원리 중 하나는 type-checking이 값의 형태(shape)에 초점을 맞춘다는 것입니다. 이것은 때때로 “duck typing“또는 “structural subtyping“라고도 합니다. TypeScript에서 인터페이스는 이러한 타입의 이름을 지정하는 역할을 하며 외부의 코드와 여러분의 코드의 약속을 정의하는 강력한 방법입니다.

첫번째 인터페이스

인터페이스의 작동 원리를 이해하기위해 간단한 예제를 보겠습니다.

1
2
3
4
5
6
function printLabel(labelledObj: { label: string }) {
console.log(labelledObj.label);
}
let myObj = {size: 10, label: "Size 10 Object"};
printLabel(myObj);

type-checkerprintLabel에 대한 호출을 확인합니다. printLabel 함수에 전달된 객체는 string 타입의 label이라는 프로퍼티가 하나 있습니다. 객체는 실제로 이것보다 더 많은 프로퍼티를 가지고 있을수 있지만 컴파일러는 적어도 필요한 것들이 있는지와 타입만 일치하는지 확인합니다. 하지만 TypeScript가 관대하지 않는 경우도 있습니다.

이번에는 인터페이스를 사용하여 label 프로퍼티가 문자열을 가져야 한다는 요구 사항을 설명하는, 동일한 예제를 다시 작성해 보겠습니다.

1
2
3
4
5
6
7
8
9
10
interface LabelledValue {
label: string;
}
function printLabel(labelledObj: LabelledValue) {
console.log(labelledObj.label);
}
let myObj = {size: 10, label: "Size 10 Object"};
printLabel(myObj);

LabelledValue 인터페이스는 이전 예제의 요구 사항을 충족하는 이름입니다. 여전히 string 타입의 label 이라는 하나의 프로퍼티가 있다는 것을 보여줍니다. printLabel에 전달한 객체가 다른 언어와 같이 이 인터페이스를 구현한다고 명시 적으로 말할 필요가 없었습니다. 여기서 중요 것은 형태일 뿐입니다. 함수에 전달된 객체가 요구 사항을 충족하면 됩니다.

Type-checker는 프로퍼티가 어떤 순서로든 상관하지 않으며 인터페이스에 필요한 프로퍼티가 있고 정확한 타입이 필요하다는 점을 지적한다는 것이 중요합니다.

선택적 프로퍼티

인터페이스의 모든 프로퍼티가 필수일 필요는 없습니다. 일부는 특정 조건 하에서 존재하거나 전혀 존재하지 않을 수도 있습니다. 이러한 Optional propery는 “option bags”와 같이 두개의 프로퍼티가 있는 객체를 함수에 전달하는 패턴을 생성할때 많이 사용됩니다.

아래의 예제는 이러한 패턴의 일반적인 형태입니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
interface SquareConfig {
color?: string;
width?: number;
}
function createSquare(config: SquareConfig): {color: string; area: number} {
let newSquare = {color: "white", area: 100};
if (config.color) {
newSquare.color = config.color;
}
if (config.width) {
newSquare.area = config.width * config.width;
}
return newSquare;
}
let mySquare = createSquare({color: "black"});

Optional propery를 가지는 인터페이스는 다른 인터페이스와 비슷하게 작성되며, 단지 프로퍼티 선언의 이름 끝에 ?가 추가 됩니다.

Optional propery의 장점은 인터페이스의 일부가 아닌 프로퍼티의 사용을 방지하고 사용 가능한 프로퍼티을 열거할 수 있다는 것입니다. 예를 들어 createSquare에서 color 프로퍼티의 이름을 잘못 입력했다면 다음과 같은 오류 메시지가 표시됩니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
interface SquareConfig {
color?: string;
width?: number;
}
function createSquare(config: SquareConfig): { color: string; area: number } {
let newSquare = {color: "white", area: 100};
if (config.color) {
// Error: Property 'clor' does not exist on type 'SquareConfig'
newSquare.color = config.clor;
}
if (config.width) {
newSquare.area = config.width * config.width;
}
return newSquare;
}
let mySquare = createSquare({color: "black"});

읽기전용 프로퍼티

일부 프로퍼티는 객체를 처음 만들 때만 수정할 수 있어야 할 수 있습니다. 프로퍼티 이름 앞에 readonly를 두어 읽기전용으로 지정할 수 있습니다.

1
2
3
4
interface Point {
readonly x: number;
readonly y: number;
}

객체 리터럴을 할당하여 Point를 생성할 수 있습니다. 할당 후에 xy는 바꿀 수 없습니다.

1
2
let p1: Point = { x: 10, y: 20 };
p1.x = 5; // error!

TypeScript에는 Array<T>와 동일하지만 모든 수정가능한 메서드가 제거된 ReadonlyArray<T> 타입이 있으므로 배열 생성 후 배열을 변경하지 않도록 할 수 있습니다.

1
2
3
4
5
6
let a: number[] = [1, 2, 3, 4];
let ro: ReadonlyArray<number> = a;
ro[0] = 12; // error!
ro.push(5); // error!
ro.length = 100; // error!
a = ro; // error!

위 코드의 마지막 줄을 보면 ReadonlyArray ro를 일반 배열로 다시 할당하는 것조차도 에러가 발생하는 것을 볼수 있습니다. 하지만 아래와 같이 Type assertion으로 Override 할 수 있습니다.

1
a = ro as number[];

readonly vs const

readonlyconst중에 어떤걸 사용할지 결정하는 가장 쉬운 방법은 변수 또는 프로퍼티 중 어떤 형태를 사용하느냐에 따라 결정합니다. 변수는 const를 사용하고 프로퍼티는 readonly를 사용합니다.

프로퍼티 접근 체크

첫번째 예제는 단지 {label : string; }가 필요한 상황에서 {size: number; label: string; }이 같은 객체를 함수의 파라미터로 넘기는 것을 보았습니다. 그리고 Optional Property에 대해서도 알게 되었고 “option bags” 패턴에 유용하다는 것을 설명 했습니다.

그러나 두 가지를 순수하게 결합하여 JavaScript에서와 같은 방법으로 사용할 수 있습니다. 예를 들어, createSquare를 사용한 마지막 예제를 보겠습니다.

1
2
3
4
5
6
7
8
9
10
interface SquareConfig {
color?: string;
width?: number;
}
function createSquare(config: SquareConfig): { color: string; area: number } {
// ...
}
let mySquare = createSquare({ colour: "red", width: 100 });

createSquare에 주어진 파라미터는 color 대신 colour라고 쓰여 있습니다.

입력한 파라미터의 width 속성은 호환 가능하고 color 속성은 없으며, 여분의 colour 속성은 중요하지 않으므로 이 프로그램이 올바르게 작성되었다고 주장할 수 있습니다.

그러나 TypeScript는 이 코드에 버그가 있음을 알수 있습니다. 객체 리터럴은 다른 변수에 할당하거나 파라미터로 전달될 때 특별한 처리를 받아 프로퍼티 접근 Checking을 받습니다. 객체 리터럴에 “대상 타입”에 없는 속성이 있으면 오류가 발생합니다.

1
2
// error: 'colour' not expected in type 'SquareConfig'
let mySquare = createSquare({ colour: "red", width: 100 });

이 *Check를 피하는 방법은 아주 간단합니다. 가장 쉬운 방법은 Type assertion을 사용하는 것입니다.

1
let mySquare = createSquare({ width: 100, opacity: 0.5 } as SquareConfig);

그러나 객체에 다른 용도로 사용되는 몇 가지 추가 속성이 있다고 확신하는 경우 string에 대한 Index signature을 추가하는 것이 더 나은 방법 일 수 있습니다. SquareConfigs가 위의 타입과 함께 colorwidth 속성을 가질 수 있지만 다른 속성도 가질 수 있다면 다음과 같이 정의할 수 있습니다.

1
2
3
4
5
interface SquareConfig {
color?: string;
width?: number;
[propName: string]: any;
}

Index signature에 대해서는 나중에 설명 하겠지만 여기서는 SquareConfig가 여러 속성을 가질 수 있으며 color또는 width가 아닌 다른 프로퍼티들의 타입은 중요하지 않습니다.

조금 놀랍게도 Check를 피하는 마지막 방법은 다른 변수에 객체를 할당하는 것입니다. 아래 코드의 squareOptions는 초과된 프로퍼티를 Check 하지 않기 때문에 컴파일러는 에러를 발생하지 않습니다.

1
2
let squareOptions = { colour: "red", width: 100 };
let mySquare = createSquare(squareOptions);

하지만 위의 코드와 같이 이러한 Check를 피하는 시도를 하지 않아야 합니다. 메서드와 상태를 유지하는 더 복잡한 객체 리터럴의 경우 이러한 기법을 염두에 두어야할 수도 있지만 대부분의 초과 프로퍼티 오류는 실제로 버그입니다. 즉, Option bag 과 같은 것에 대해 초과 속성 검사 문제가 발생한다면, 타입 선언의 일부를 수정해야할 수도 있습니다. 이 경우, color 또는 colour 속성을 가진 객체를createSquare에 전달하면, 그것을 반영하기 위해SquareConfig의 정의를 수정해야합니다.

함수 타입

인터페이스는 JavaScript 객체가 취할 수있는 다양한 형태로 설명할 수 있습니다. 프로퍼티을 가진 객체를 설명하는 것 외에도 인터페이스는 함수 타입을 정의할 수 있습니다.

TypeScript는 인터페이스에 있는 함수 타입을 정의하기 위해 Call signature 인터페이스를 제공합니다. 이것은 주어진 파라미터 목록과 리턴 타입만 있는 함수 선언과 같습니다. 파라미터 목록의 각 파라미터는 이름과 타입이 모두 필요합니다.

1
2
3
interface SearchFunc {
(source: string, subString: string): boolean;
}

일단 정의되면 우리는 다른 인터페이스 처럼 이 함수 타입 인터페이스를 사용할 수 있습니다. 아래 코드는 함수 타입의 변수를 만들고 같은 타입의 함수를 할당하는 방법을 보여줍니다.

1
2
3
4
5
let mySearch: SearchFunc;
mySearch = function(source: string, subString: string) {
let result = source.search(subString);
return result > -1;
}

함수 타입이 올바른지 확인하기 위해 파라미터의 이름이 같을 필요는 없습니다. 예를 들어 위의 예제를 다음과 같이 작성할 수 있습니다.

1
2
3
4
5
let mySearch: SearchFunc;
mySearch = function(src: string, sub: string): boolean {
let result = src.search(sub);
return result > -1;
}

함수 파라미터는 한 번에 하나씩 , 서로 대응하는 각 해당 파라미터의 위치와 타입이 Check됩니다. 타입을 전혀 지정하지 않은 경우 TypeScript의 Contextual typing이 함수 SearchFunc 타입의 파라미터에 직접 지정되므로 파라미터 타입을 유추할 수 있습니다. 또한 함수 표현식의 리턴 타입은 반환하는 값 (여기에는 falsetrue)에 의해 암묵적으로 유추됩니다. 함수 표현식이 숫자나 문자열을 반환했다면, Type-checker는 리턴 타입이 SearchFunc 인터페이스에서 기술된 리턴 타입과 일치하지 않는다고 경고했을 것입니다.

1
2
3
4
5
let mySearch: SearchFunc;
mySearch = function(src, sub) {
let result = src.search(sub);
return result > -1;
}

Indexable 타입

함수 타입을 설명하기 위해 인터페이스를 사용하는 방법과 마찬가지로, [10], 또는 ageMap[ "daniel"] 처럼 “인덱스”할 수있는 타입을 정의할 수 있습니다. 인덱싱 가능 유형에는 인덱싱할 때 대응되는 리턴 타입과 함께 객체에 대해 인덱싱하는 데 사용할 수있는 타입을 설명하는 Index signature가 있습니다. 예를 들어 보겠습니다.

1
2
3
4
5
6
7
8
interface StringArray {
[index: number]: string;
}
let myArray: StringArray;
myArray = ["Bob", "Fred"];
let myStr: string = myArray[0];

위에서 Index signature를 갖는 StringArray 인터페이스가 있습니다. 이 Index signatureStringArraynumber로 인덱스 될 때 string을 리턴한다는 것을 나타냅니다.

지원되는 Index signature에는 문자열과 숫자의 두 가지 타입이 있습니다. 두 가지 타입의 인덱서를 모두 지원하는 경우에는 숫자 인덱서에서 반환된 형태는 문자열 인덱서에서 반환된 형태의 하위 형태이어야 합니다. 이는 number를 사용하여 색인을 생성할 때 JavaScript가 객체로 색인하기 전에 실제로 JavaScript를 문자열로 변환하기 때문입니다. 즉, 100 (number)로 인덱싱하는 것은 "100" (string)으로 인덱싱하는 것과 동일하므로 두 가지가 일관성이 있어야합니다.

1
2
3
4
5
6
7
8
9
10
11
12
class Animal {
name: string;
}
class Dog extends Animal {
breed: string;
}
// Error: "문자열"로 색인을 생성하면 가끔 "Dog"가 생깁니다.
interface NotOkay {
[x: number]: Animal;
[x: string]: Dog;
}

문자열 Index signature은 “Dictionary” 패턴을 설명하는 강력한 방법이지만 모든 프로퍼티가 리턴 타입과 일치 해야 합니다. 이것은 문자열 인덱스 obj.propertyobj["property"]로 사용 가능하다는 것을 선언되기 때문입니다. 다음 예제에서 name의 형식은 문자열 인덱스의 형식과 일치하지 않으며 Type-checker에서 오류가 발생합니다.

1
2
3
4
5
interface NumberDictionary {
[index: string]: number;
length: number; // ok, length is a number
name: string; // error, 'name'의 타입은 인덱서의 하위 타입이 아닙니다.
}

마지막으로 인덱스에 할당하지 못하도록 Index signature을 읽기 전용으로 만들 수 있습니다.

1
2
3
4
5
interface ReadonlyStringArray {
readonly [index: number]: string;
}
let myArray: ReadonlyStringArray = ["Alice", "Bob"];
myArray[2] = "Mallory"; // error!

Index signature이 읽기 전용이기 때문에 myArray[2]에 값을 설정할 수 없습니다.

클래스 타입

Interface 구현

C# 및 Java와 같은 언어로 인터페이스를 사용하는 가장 일반적인 방법 중 하나는 클래스가 특정 계약을 준수하도록 명시적으로 적용하는 것입니다. TypeScript에서도 가능합니다.

1
2
3
4
5
6
7
8
interface ClockInterface {
currentTime: Date;
}
class Clock implements ClockInterface {
currentTime: Date;
constructor(h: number, m: number) { }
}

아래 예제에서 setTime과 마찬가지로 클래스에 구현된 인터페이스의 메소드를 설명할 수도 있습니다.

1
2
3
4
5
6
7
8
9
10
11
12
interface ClockInterface {
currentTime: Date;
setTime(d: Date);
}
class Clock implements ClockInterface {
currentTime: Date;
setTime(d: Date) {
this.currentTime = d;
}
constructor(h: number, m: number) { }
}

인터페이스는 클래스의 publicprivate이 아니라 public만을 기술할 수 있습니다. 그렇기 때문에 클래스를 사용하여 클래스 인스턴스의 private에 특정 타입이 있는지 확인할 수 없습니다.

클래스의 Static과 Instance의 차이점

클래스와 인터페이스로 작업할 때 클래스에는 Static 측면의 타입과 Instance 측면의 타입이라는 두 가지 유형이 있음을 명심하세요. Construct signature를 사용하여 인터페이스를 만들고 이 인터페이스를 Implements하는 클래스를 만들려고 하면 오류가 발생할 수 있습니다.

1
2
3
4
5
6
7
8
interface ClockConstructor {
new (hour: number, minute: number);
}
class Clock implements ClockConstructor {
currentTime: Date;
constructor(h: number, m: number) { }
}

이는 클래스가 인터페이스를 Implements할 때 클래스의 Instance 적인 면만 검사하기 때문입니다. 생성자는 Static 측면에 있으므로 이 Check에 포함되지 않습니다.

대신 클래스의 Static인 측면에서 직접 작업해야 합니다. 아래 예제에서 우리는 생성자를 위한 ClockConstructor와 인스턴스 메서드를 위한ClockInterface라는 두 개의 인터페이스를 정의합니다. 편의상 우리는 전달된 타입의 인스턴스를 생성하는 생성자 함수 createClock도 같이 정의합니다.

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
interface ClockConstructor {
new (hour: number, minute: number): ClockInterface;
}
interface ClockInterface {
tick();
}
function createClock(ctor: ClockConstructor, hour: number, minute: number): ClockInterface {
return new ctor(hour, minute);
}
class DigitalClock implements ClockInterface {
constructor(h: number, m: number) { }
tick() {
console.log("beep beep");
}
}
class AnalogClock implements ClockInterface {
constructor(h: number, m: number) { }
tick() {
console.log("tick tock");
}
}
let digital = createClock(DigitalClock, 12, 17);
let analog = createClock(AnalogClock, 7, 32);

createClock의 첫번째 파라미터는 ClockClonstructor 타입이고 createClock(AnalogClock, 7, 32)AnalogClock이 올바른 Construct signature를 가지고 있는지를 검사합니다.

인터페이스 확장

클래스와 마찬가지로 인터페이스는 서로를 확장할 수 있습니다. 이렇게 하면 한 인터페이스의 구성원을 다른 인터페이스로 복사할 수 있으므로 인터페이스를 재사용 가능한 구성 요소로 분리하는 방법을 보다 유연하게 사용할 수 있습니다.

1
2
3
4
5
6
7
8
9
10
11
interface Shape {
color: string;
}
interface Square extends Shape {
sideLength: number;
}
let square = <Square>{};
square.color = "blue";
square.sideLength = 10;

인터페이스는 여러 인터페이스를 확장하고 모든 인터페이스를 조합하여 만들 수 있습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
interface Shape {
color: string;
}
interface PenStroke {
penWidth: number;
}
interface Square extends Shape, PenStroke {
sideLength: number;
}
let square = <Square>{};
square.color = "blue";
square.sideLength = 10;
square.penWidth = 5.0;

결합 (Hybrid) 타입

이전에 언급했듯이 인터페이스는 실제 JavaScript에서 제공되는 풍부한 타입을 나타낼 수 있습니다. JavaScript의 역동적이고 유연한 특성으로 인해 위에 설명된 일부 타입의 조합으로 작동하는 객체가 종종 발생할 수 있습니다.

이러한 예는 추가 프로퍼티을 사용하여 함수와 객체의 역할을 모두 수행하는 객체입니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
interface Counter {
(start: number): string;
interval: number;
reset(): void;
}
function getCounter(): Counter {
let counter = <Counter>function (start: number) { };
counter.interval = 123;
counter.reset = function () { };
return counter;
}
let c = getCounter();
c(10);
c.reset();
c.interval = 5.0;

타사 JavaScript 라이브러리와 상호 작용할 때 타입 모양을 완전히 설명하려면 위와 같은 패턴을 사용해야 할 수도 있습니다.

클래스 확장 인터페이스

인터페이스 유형이 클래스 타입을 확장할 때 클래스 타입을 상속하지만 Implements된 내용은 상속되지 않습니다. 마치 인터페이스가 implements를 제공하지 않고 클래스의 모든 멤버를 선언 한 것과 같습니다. 인터페이스는 기본 클래스의 private 및 protected 멤버도 상속합니다. 즉, private 또는 protected 멤버가 있는 클래스를 확장하는 인터페이스를 만들면 해당 인터페이스 유형은 해당 클래스 또는 해당 클래스의 하위 클래스에서만 implements 할 수 있습니다.

이는 많은 상속 계층이 있지만 코드가 특정 프로퍼티가 있는 하위 클래스에서만 작동하도록 지정하려는 경우에 유용합니다. 서브 클래스는 기본 클래스에서 상속하는 것 외에 관련 될 필요가 없습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
class Control {
private state: any;
}
interface SelectableControl extends Control {
select(): void;
}
class Button extends Control {
select() { }
}
class TextBox extends Control {
select() { }
}
class Image {
select() { }
}
class Location {
select() { }
}

위의 예제에서, SelectableControl은 private state 속성을 포함하여 Control의 모든 멤버를 포함합니다. state는 private 멤버이기 때문에 Control의 자손만 SelectableControl을 구현할 수 있습니다. 왜냐하면 Control의 자손들만이 같은 선언에서 유래된 private state 멤버를 가질 것이기 때문입니다. 이는 private 멤버들이 호환 가능해야 한다는 요구 사항입니다.

Control 클래스 안에서 SelectableControl 인스턴스를 통해 private state 멤버에 접근할 수 있습니다. 효율적으로 SelectableControl은 select 메소드를 가진 것으로 알려진 Control과 같은 역할을 합니다. ButtonTextBox 클래스는SelectableControl의 하위 타입입니다. (왜냐하면 둘 다 Control을 상속 받았고 select 메소드를 가졌기 때문입니다), 하지만 Image 클래스와 Location 클래스는 그렇지 않습니다.


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

참고

공유하기