기본적인 인터페이스 사용 방법
타입 검사는 printLabel을 호출합니다. printLabel 함수는 string 타입 label을 갖는 객체를 하나의 매개변수로 가집니다. 이 객체가 실제로는 더 많은 프로퍼티를 갖고 있지만, 컴파일러는 최소한 필요한 프로퍼티가 있는지와 타입이 잘 맞는지만 검사합니다.
function printLabel(labeledObj: {label: string}) {
console.log(labeledObj.label);
}
let myObj = {size:10, label: "Size 10"};
printLabel(myObj);
LabeledValue 인터페이스는 숫자 타입의 size 프로퍼티와 문자열 타입의 label 프로퍼티를 가지고 있습니다. 다른 언어처럼 printLabel에 전달한 객체가 이 인터페이스를 구현해야 한다고 명시적으로 얘기할 필요가 없고, 여기서 중요한 것은 형태뿐입니다. 함수에 전달된 객체가 요구 조건을 충족하면, 허용됩니다. 타입 검사는 프로퍼티들의 순서를 요구하지 않습니다. 단지, 인터페이스가 요구하는 프로퍼티들이 존재하는지와 프로퍼티들이 요구하는 타입을 가졌는지만 확인합니다.
interface LabeledValue {
size: number,
label: string,
}
function printLabel(labeledObj: LabeledValue) {
console.log(labeledObj.size);
console.log(labeledObj.label);
}
let myObj = {size:10, label: "Size is 10."};
printLabel(myObj);
선택적 프로퍼티 (Optional Properties)
인터페이스의 모든 프로퍼티가 필요한 것은 아닙니다. 어떤 조건에서만 존재하거나 아예 없을 수도 있습니다. SquareConfig 인터페이스의 프로퍼티들은 선택적, 필수 프로퍼티를 가지고 있습니다. 이는 TypeScript의 타입 시스템에서 프로퍼티에 ?를 붙이느냐 안 붙이느냐로 결정되며, "객체 내부에 최소 1개의 필수 프로퍼티가 있어야 한다"는 규칙은 없습니다. 각 프로퍼티의 필수 여부는 단순히 해당 프로퍼티에 ? 연산자가 있는지 없는지에 따라 결정됩니다.
interface SquareConfig {
color?: string, // 선택적 프로퍼티
size: { // 필수 프로퍼티
width?: number; // 선택적 프로퍼티
height: number; // 필수 프로터피
}
}
function createSquare(config: SquareConfig): {color: string; area: number} {
let newSquare = {
color: "white",
area: 100
};
if (config.color) {
newSquare.color = config.color;
}
const width = config.size.width ?? 10;
newSquare.area = width * config.size.height;
return newSquare;
}
let mySquare = createSquare({color: "black", size: {height: 50}});
console.log(mySquare)
그렇다면 아래와 같은 코드도 동작할까요? 정답은 '네! 동작합니다!' 하지만, 논리적으로 개선이 필요해보이는 코드입니다. size는 필수 프로퍼티이지만, 그 내부의 모든 프로퍼티가 선택적이라면 size가 필수 프로퍼티일 이유가 없는 것 같습니다. 단순하게 이런 코드도 동작한다는 작은 예시일 뿐, 실제 프로젝트에서 아래와 같은 코드는 작성하지 않을 것입니다.
interface SquareConfig {
color?: string,
size: {
width?: number;
height?: number;
}
}
size 프로퍼티의 내부 프로퍼티 둘 중 하나는 반드시 존재해야 한다면 어떻게 할까요? 아래와 같이 작성할 수 있습니다.정말 유연하죠?
interface SquareConfig {
color?: string,
size: {
width: number;
height?: number;
} | {
width?: number;
height: number;
}
}
읽기 전용 프로퍼티 (Readonly properties)
일부 프로퍼티들은 객체가 처음 생성될 때만 수정 가능합니다. 프로퍼티 이름 앞에 readonly를 넣어서 이를 지정할 수 있습니다. TypeScript에서 ReadonlyArray<T>는 일반 배열(Array<T>)에서 배열을 변경할 수 있는 모든 베서드들을 제거한 타입입니다. 하지만, arr = [] 와 같은 동작은 허용됩니다. 그 이유는 arr이 let으로 선언되었기 때문입니다. 만약 변수 자체로 수정 불가능하게 하고 싶다면 const를 사용해야 합니다. arr 자체를 재할당은 가능하지만, arr 내부의 프로퍼티들은 Readonly로 선언되었기에, 삽입/삭제 연산이 불가능하고 인덱스를 통한 요소 수정이 불가능한 것입니다.
변수는 const, 프로퍼티는 readonly를 사용한다고 생각하면 되겠습니다. 여기서 프로퍼티란 객체가 갖고 있는 키-값을 의미합니다.
interface Point {
readonly x: number;
readonly y: number;
}
let p1: Point = {x:10, y:20};
// p1.x = 20; 불가능
let arr: ReadonlyArray<Number> = [1,2,3,45];
// arr[2] = 1; 불가능
// arr.push(10); 불가능
// arr.length = 100; 불가능
arr = [];
console.log(arr);
여기서 의문!! 배열이 왜 객체인가요?
자바/타입스크립트에서 배열은 특별한 형태의 객체입니다. 배열의 각 요소는 인덱스를 키로 가지는 프로퍼티입니다. 내부적으로 아래와 같은 형태라고 합니다. 정말인지 한번 확인해볼까요?
let arr = [10, 20, 30];
let arrAsObject = {
'0': 10,
'1': 20,
'2': 30,
length: 3
};
정말 신기합니다.. 자바스크립트의 세계.... 무튼 배열에 대해 다시 한번 정의하자면, 배열은 숫자 인덱스를 가진 특별한 객체라고 보면 되겠습니다.
let arr = [10, 20, 30];
console.log(arr[0]);
console.log(arr['0']); // 인덱스를 문자열로 접근해도 동일
console.log(Object.keys(arr));
console.log(Object.values(arr));
console.log(arr.length);
// 10
// 10
// [ '0', '1', '2' ]
// [ 10, 20, 30 ]
// 3
초과 프로퍼티 검사
초과 프로퍼티 검사란 타입스크립트에서 객체 리터럴을 인터페이스나 타입에 할당할 때, 정의되지 않은 프로퍼티가 있는지 검사하는 것입니다. 개발자에 따라 개발 방식이 상이하겠지만, 타입 제한이 느슨하면 유지보수도 어려울 거고, 의도하지 않은 동작을 초래할 수도 있을 것입니다. 이러한 이유로 타입스크립트의 장점을 극대화하는 중요한 기능이라고 생각합니다.
interface SquareConfig {
color?: string;
width?: number;
height?: number;
[provName: string]: any; // 문자열 인덱스 서명 방식에서만 사용.
}
function createSquare(config: SquareConfig): {color: string; area: number} {
let newSquare = {
color: "white",
area: 100
};
if (config.color) {
newSquare.color = config.color;
}
const width = config.width ?? 10;
const height = config.height ?? 20;
newSquare.area = width * height;
return newSquare;
}
// 방법1. 타입 단언 사용
// let mySquare = createSquare({ colour: "red", width: 100 } as SquareConfig);
// 방법2. 문자열 인덱스 서명 추가
let mySquare = createSquare({ colour: "red", width: 100 });
// 방법3. 변수 할당 후 전달
// 초과 프로퍼티 검사는 객체 리터럴을 직접 할당할 때만 발생하며, 변수를 통해 전달할 때는 검사하지 않습니다.
// let squareOptions = { colour: "red", width: 100 };
// let mySquare = createSquare(squareOptions);
console.log(mySquare);
함수 타입
인터페이스에 함수 타입을 정의할 수 있습니다. JAVA에서 인터페이스를 정의하면 해당 인터페이스를 구현하는 클래스는 필수적으로 추상 메소드들을 구현해야 했습니다. 자바스크립트는 파라미터와 반환 타입만을 정의하여 개발자가 보다 유연하게 인터페이스를 활용할 수 있도록 도와줍니다.
interface SearchFunc {
(source: string, substring: string): number;
}
let mySearch: SearchFunc;
mySearch = function(source: string, sub: string) {
let result = source.search(sub);
return result;
}
const result = mySearch("hello world", "World");
console.log(result);
인덱서블 타입 (Indexable Types)
인터페이스로 함수 타입을 설명하는 방법과 유사하게, a[10] 이나 ageMap["daniel"] 처럼 타입을 인덱스로 기술할 수 있습니다. 인덱서블 타입은 인덱싱 할 때 해당 반환 유형과 함께 객체를 인덱싱하는 데 사용할 수 있는 타입을 기술하는 인덱스 시그니처를 가지고 있습니다. 인덱서블 타입은 미리 정확히 알 수 없는 키-값 쌍을 가진 객체를 다룰 때 사용합니다. API 응답, 환경 변수, 설정 객체 등이 대표적인 예시입니다.
인덱스 시그니처
[indexName: indexer type] : return type 형식으로 선언합니다. name과 같이 직접 표기한 것은 똑같이 명시해야 하지만 인덱서블 타입은 추가를 원할 때 사용하기 때문에 옵셔널 타입이라고 볼 수 있습니다. 다만 인덱서블 타입은 string과 number 두 가지만 사용할 수 있으며 선언된 즉시 인터페이스 안의 값들은 모두 인덱서 타입의 서브 타입이 되어야 합니다. 따라서 보통의 경우, 인덱서블의 리턴 타입을 확장하거나 any를 사용합니다.
interface StringDictionary {
name: string;
[key: string]: string; // 인덱스 시그니처
}
인덱서 타입의 서브 타입이 되는 경우
interface NumberOrStringDictionary {
[index: string]: number | string;
length: number; // 성공, length는 숫자입니다
name: string; // 성공, name은 문자열입니다
}
조금 더 복잡한 값을 가진 인덱서블 타입
interface EmployeeIndex {
[key:string]: {id: number, name: string}; // key가 string, value가 {id, name}
}
const employees: EmployeeIndex = {
first: {id: 1, name: "park"},
second: {id: 2, name: "jin"},
};
배열의 인덱싱에 사용
interface StringList {
[index: number]: string;
}
let words: StringList = [];
words[0] = "hi";
// words[1] = 3; // ERROR! 'number' 형식은 'string'형식에 할당 불가.
클래스 타입 (Class Types)
인터페이스 구현하기
다른 언어에서 인터페이스를 사용하는 가장 일반적인 방법입니다.
interface ClockInterface {
currentTime: Date;
}
class Clock implements ClockInterface {
currentTime: Date = new Date();
constructor(h: number, m: number) { }
}
클래스의 이중성과 그 해결 방법
클래스는 정적 측면에서 클래스 자체의 메서드와 인스턴스 생성을 위한 생성자를 가질 수 있고, 인스턴스 측면에서 생성된 인스턴스의 메서드와 생성된 인스턴스의 속성을 가질 수 있습니다. 이해를 돕기 위해 간단한 예시로 알아보겠습니다.
// 1. 먼저 '강아지 만드는 설계도'의 형태를 정의
interface DogConstructor {
new (name: string): DogInterface;
}
// 2. '강아지가 할 수 있는 행동들'을 정의
interface DogInterface {
bark(): void;
sit(): void;
}
타입스크립트에서는 Static 타입과 인스턴스 타입, 두 가지를 명확하게 구분해서 다뤄야 합니다. 왜 그럴까요?
// 에러!
class Poodle implements DogConstructor {
constructor(name: string) {}
bark() { console.log("짖다!"); }
sit() { console.log("앉다"); }
}
에러가 나는 이유는 implements는 강아지가 할 수 있는 행동들, 다시 말해서 인스턴스 타입만 검사할 수 있는데 DogConstructor는 강아지를 만드는 설계도, Static 타입(생성자)을 검하려고 하기 때문입니다. 그래서 이러한 해결책을 사용할 수 있습니다.
// '강아지 공장' 만들기
interface DogInterface {
bark(): void;
sit(): void;
}
interface DogConstructor {
new (name: string): DogInterface;
}
function createDog( constructor: DogConstructor, name: string): DogInterface {
return new constructor(name);
}
// '할 수 있는 행동'만 신경 쓰면 됩니다
class Poodle implements DogInterface {
constructor(name: string) {
this.name = name;
}
name: string;
bark() {
console.log("왈왈!");
}
sit() {
console.log("앉았습니다");
}
}
const myPoodle = createDog(Poodle, "푸들이");
myPoodle.bark(); // "왈왈!" 출력
결론은 클래스의 두 가지 측면(생성자, 인스턴스)을 이해하고 이를 명확히 구분해서 다루라는 것입니다. 그리고 이를 위해 팩토리 패턴을 사용해서 타입의 안전성을 높이고, 코드 관리를 용이하게 하라는 것입니다.
자세한 예제가 필요하다면 아래 링크를 참고하시면 됩니다. '클래스의 스태틱과 인스턴스의 차이점' 이라는 소제목에서 확인할 수 있고, 주석은 직접 공부하면서 작성한 것입니다.
https://typescript-kr.github.io/pages/interfaces.html
// 클래스의 생성자 형태를 정의하는 인터페이스
// new 키워드는 이 인터페이스가 생성자를 위한 것임을 나타냄.
// hour와 minute를 받아서 ClockInterface 타입의 인스턴스를 반환하는 생성자여야 함을 명시.
interface ClockConstructor {
new (hour: number, minute: number): ClockInterface;
}
// 시계 인스턴스가 가져야 할 메서드를 정의하는 인터페이스
// tick() 메소드를 반드시 구현해야 함.
interface ClockInterface {
tick(): void;
}
// 시계 인스턴스를 생성하는 팩토리 함수
// ctor: 생성자 함수를 받는다. (DigitalClock or AnalogClock)
// hour, minute: 시계 초기값을 받는다.
// 반환값으로 ClockInterface를 구현한 인스턴스를 줌.
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("tik tok");
}
}
// 디지털 시계 생성 요청
let digital = createClock(DigitalClock, 12, 17);
// 실행 순서:
// 1. createClock 함수가 호출됨
// 2. 함수는 DigitalClock 생성자, 12, 17을 매개변수로 받음
// 3. 함수 내부에서 new DigitalClock(12, 17) 실행
// 4. DigitalClock의 constructor(12, 17) 실행
// 5. 새로운 DigitalClock 인스턴스가 반환됨
// 6. 이 인스턴스가 digital 변수에 할당됨
// tick 메서드 호출
digital.tick(); // "beep beep" 출력
// 실행 순서:
// 1. digital 객체의 tick 메서드 찾기
// 2. DigitalClock 클래스에 정의된 tick 메서드 실행
// 3. "beep beep" 콘솔 출력
인터페이스 확장
클래스처럼, 인터페이스들도 extend가 가능합니다. 이는 한 인터페이스의 멤버를 다른 인터페이스에 복사하는 것을 가능하게 해주는데, 인터페이스를 재사용성 높은 컴포넌트로 쪼갤 때, 유연함을 제공해줍니다.
interface Shape {
color: string;
}
interface PenStroke {
penWidth: number;
}
interface Square extends Shape, PenStroke {
sideLength: number;
}
let square = {} as Square;
square.color = "blue";
square.sideLength = 10;
square.penWidth = 5.0;
하이브리드 타입
자바스크립트의 동적이고 유연한 특성을 보여주는 기능입니다. 이러한 기능을 사용하는 이유는 유연한 API 설계가 가능하고, 함수와 객체의 장점을 모두 활용할 수 있다고 합니다. 지금까지 JAVA로 백엔드 개발을 해 온 필자는 혼란스럽습니다....... 코드가 엄청 복잡해질 것 같은데 실무에서 하이브리드 타입을 직접적으로 사용하는 지 궁금해지네요....
// Counter 인터페이스 정의
interface Counter {
(start: number): string; // Counter는 함수로도 호출될 수 있음 (start를 받아서 string 반환)
interval: number; // interval이라는 숫자 속성을 가짐
reset(): void; // reset이라는 메서드를 가짐
}
// Counter 타입의 객체를 생성하는 함수
function getCounter(): Counter {
// 빈 함수를 만들고 Counter 타입으로 타입 단언(as)
let counter = (
function (start: number) {
console.log(`함수 호출: ${start}`)
}
) as Counter;
// counter 객체에 interval 속성 추가
counter.interval = 123;
// counter 객체에 reset 메서드 추가
counter.reset = function () {
console.log("RESET");
};
// 완성된 counter 객체 반환
return counter;
}
// Counter 인스턴스 생성
let c = getCounter();
// Counter 사용
c(10); // 함수로 호출
c.reset(); // 메서드 호출
c.interval = 5.0; // 속성 값 변경
console.log(c, c.interval);
클래스를 확장한 인터페이스
클래스를 상속받은 인터페이스라는 개념도 존재합니다. (그만....... 제발 멈춰!!!)
인터페이스 타입이 클래스 타입을 확장하면, 클래스의 멤버는 상속받지만 구현은 상속받지 않습니다. 이것은 인터페이스가 구현을 제공하지 않고, 클래스의 멤버 모두를 선언한 것과 마찬가지입니다.
인터페이스는 기초 클래스의 private과 protected 멤버도 상속받습니다. 이것은 인터페이스가 private 혹인 protected 멤버를 포함한 클래스를 확장할 수 있다는 뜻이고, 인터페이스 타입은 그 클래스나 하위 클래스에 의해서만 구현될 수 있습니다.
class Control {
private state: any;
}
interface SelectableControl extends Control {
select(): void;
}
class Button extends Control implements SelectableControl {
select() { }
}
class TextBox extends Control {
select() { }
}
// Class 'Apple' incorrectly implements interface 'SelectableControl'.
// Types have separate declarations of a private property 'state'.
// class Apple implements SelectableControl {
// private state: any;
// select() { }
// }
// Apple 클래스가 SelectableControl 인터페이스를 올바르게 구현하려면 Control 클래스를 상속받아야 합니다.
class Apple extends Control implements SelectableControl {
select() { }
}
class Car {
}
참고 자료
- https://typescript-kr.github.io/pages/interfaces.html
- https://velog.io/@hb-developer/%ED%83%80%EC%9E%85%EC%8A%A4%ED%81%AC%EB%A6%BD%ED%8A%B8-tnsk260u
- https://ppassongssong.tistory.com/28
- https://inpa.tistory.com/entry/TS-%F0%9F%93%98-%ED%83%80%EC%9E%85%EC%8A%A4%ED%81%AC%EB%A6%BD%ED%8A%B8-%EC%9D%B8%ED%84%B0%ED%8E%98%EC%9D%B4%EC%8A%A4-%F0%9F%92%AF-%ED%99%9C%EC%9A%A9%ED%95%98%EA%B8%B0
- https://eunhye919.tistory.com/111
- https://redjen8.github.io/posts/ts-handbook/interface/