...
타입스크립트 Interface
인터페이스는 자바(JAVA)를 배운 분들은 매우 익숙하면서도 반가운 단어일 것이다.
다만 자바에서의 인터페이스는 추상 메소드와 상수만을 정의한 클래스를 위주로 다루지만, 타입스크립트(TypeScript)의 인터페이스는 객체를 위주로 다룬다는 점에서 차이가 있다.
타입스크립트의 인터페이스는 두개의 시스템 사이에 상호 간에 정의한 약속 혹은 규칙을 포괄하여 의미한다. 혹은 객체의 껍데기 또는 설계도라고 할 수 있다.
즉, 프로그래밍에서 클래스 또는 함수의 '틀' 을 정의하는 것처럼, 타입의 '틀'로서 사용할 수 있는 것이 인터페이스 인 것이다.
여러 함수가 특정한 시그니처를 동일하게 가져야 할 경우 또는 여러 클래스가 동일한 명세를 정의해야하는 경우 인터페이스를 통해서 정의할 수 있다. 그래서 인터페이스에 선언된 프로퍼티 또는 메소드의 구현을 강제하여 각 클래스와 함수간의 일관성을 유지할 수 있도록 하는 것이다.
또한 인터페이스는 TS 문법이기 때문에 컴파일 될 때 js로 컴파일이 되지 않는다. 컴파일된 js파일을 살펴보면 확인할 수 있다.
타입 스크립트에서의 인터페이스는 보통 다음과 같은 범주에 대해 약속을 정의할 수 있다.
- 객체의 스펙(속성과 속성의 타입)
- 함수의 파라미터
- 함수의 스펙(파라미터, 반환 타입 등)
- 배열과 객체를 접근하는 방식
- 클래스
// 인터페이스명은 대문자로 짓는다
interface Human {
name: string; // name 키는 문자열 타입
age: number; // age 키는 넘버 타입
boo(): void; // boo 함수는 void 타입
}
// 인터페이스 자체를 타입으로 줘서 객체 생성
const person: Human = {
name: "da",
age: 5,
boo: () => console.log("this is boo"),
};
// 매개변수에서 인터페이스를 타입으로 받는다.
function booboo(a: Human): void {
console.log(`${a.name} is ${a.age} years old`);
};
booboo(person); // da is 5 years old
person.boo(); // this is boo
[인터페이스명에 대한 토론]
보통 인터페이스명을 명명할때 앞에다가 항상 대문자 I 를 붙이는 암묵적인 약속이 행하여 왔다. 위의 코드를 예를 들면 Human이 아니라 IHuman 이런식으로 말이다.
왜냐하면 첫글자에 대문자 I를 붙여 이 단어는 인터페이스임을 쉽게 알아볼수 있기 때문이었다.
실제로 C#을 하다 오신 개발자분들은 C#에 익숙하여 대문자 I 를 붙이기도 한다.
하지만 요즘 들어서는 대문자 I 를 빼는 추세로 되바뀌었다. 왜냐하면 어차피 에디터에서 마우스 커서를 대면 타입 구조가 나오기 때문에 굳이 가독에 안좋게 뭣하러 앞에 붙이냐는 의견에 대세가 기울었기 때문이다.
인터페이스 vs 타입 별칭
객체 구조 타입을 선언해서 사용한다고 가정할때, 이는 인터페이스 로도 구현이 가능하고, type alias 로도 구현이 가능하다.
다음은 똑같은 객체 구조 타입을 각각 리터럴, 타입 별칭, 인터페이스로 구현한 예제 코드이다.
// 리터럴 객체 타입
const a1: {
name: string;
age: number;
talk: () => void;
} = {
name: '홍길동',
age: 12,
talk() {},
};
// type alias 객체 타입
type Ty = {
name: string;
age: number;
talk: () => void;
};
const a2: Ty = {
name: '홍길동',
age: 12,
talk() {},
};
// 인터페이스 객체 타입
interface In {
name: string;
age: number;
talk: () => void;
}
const a3: In = {
name: '홍길동',
age: 12,
talk() {},
};
위와 같이 눈으로 보기에는 별 차이없는, 인터페이스와 type 별칭 중에 어느것을 사용하는 것이 좋을지 한번쯤은 고민 해본적이 있을 것이다.
필자의 대답은 무조건 인터페이스이다.
왜냐하면 타입 별칭과 달리 인터페이스는 확장이 가능하기 때문이다.
이제부터 배울 extends 나 implement 등 인터페이스는 단순히 객체 타입을 표현을 떠나 다양하게 활용 될 수 있으며 지원하는 문법 또한 다양하다.
그래서 대부분의 타입스크립트 라이브러리를 보면 대부분 인터페이스로 타입을 선언하는걸 볼수 있다.
물론 type 별칭도 타입스크립트 2.7 버전부터 교차 타입을 생성함으로써 extend 할 수 있다.
예를 들어, type Cat = { nyan: false } 와 type Bird = { purrs: true } 가 있으면 type Fusion = Cat & Bird 이런식으로 and로 묶으면 이는 곧 확장이 되기 때문이다.
그리고 만약 객체 타입이 아닌 단일 타입을 유니언이나 튜플 타입으로 묶어 사용해야 한다면 타입 별칭을 사용하면 된다.
그러나 소프트웨어의 이상적인 특징은 확장에 개방되어 있기 때문에, 가능하면 타입 별칭보다 인터페이스를 사용해야 하는 것이 옳은 측면으로 볼 수있다.
선택적 프로퍼티 (?:)
인터페이스를 사용할 때 인터페이스에 정의되어 있는 속성을 반드시 모두 다 꼭 사용해야 한다면 코드의 유연성이 사라지기 마련이다. 너무 강하게 하나하나 따지면 일일히 구조마다 인터페이스를 생성해야 할지도 모른다.
선택적 프로퍼티(Optional Properties)는 이를 해결할 수 있는 간편한 연산자 이다.
콜론(:) 앞에 물음표(?) 를 붙이면 이 속성은 옵션 속성이 된다.
interface CraftBeer {
name: string;
hope?: number; // hope 속성은 명시해도 되고 안해도 상관없다 (옵션)
}
function brewBeer(beer: CraftBeer) {
console.log(beer.name);
}
let myBeer = { name: 'Saporo' }; // hope 속성을 명시 안함
brewBeer(myBeer); // Saporo : 문제없이 동작한다.
interface User {
name: string;
age: number;
gender?: string;
}
let user: User = {
name: 'jeff',
age: 30,
};
user.age = 10;
user.gender = "male"; // 선택적 프로퍼티에 의해서 나중에 속성값을 넣어줄수도 있다.
user.lalala = ''; // 그렇다고 해서 아예 정의되지도 않는 속성을 마음대로 집어넣을 수는 없다.
선택 속성의 원리는 의외로 간단하다.
플레이어 객체가 name은 무조건 가지고 있지만 age는 있을수도 있고 없을수도 있을때 다음과 같이 쓸수 있는데, 이는 사실 Union 타입으로 number | undefined 선언과 마찬가지임을 알수 있다.
// 둘이 표현하고자 하는 타입 구조는 같다.
interface IUser {
name: string;
age?: number;
}
type TUser = {
name: string;
age: number | undefiened;
}
그런데 주의 할점은 age는 선택 속성이기 때문에 다음과 같이 age 속성을 사용하는 로직을 짜게 되면, 컴파일러는 age 속성이 확실하게 쓰이는지 안쓰이는지 추론을 할수 없기 때문에 에러를 내뿜게 된다.
이때는 타입 가드 기법으로서, 간단하게 player.age 속성이 존재할때만 조건식 비교를 하라고 로직을 짜주면 해결 할 수 있다.
읽기 전용 프로퍼티 (readonly)
읽기 전용 속성(readonly property)은 단어 그대로, 인터페이스로 객체를 처음 생성할 때만 값을 할당하고 그 이후에는 변경할 수 없는 속성을 의미한다.
다음과 같이 readonly 속성을 앞에 붙이면 간단하게 적용 된다.
인터페이스로 객체를 처음 선언하여 값을 대입할때는 문제가 없다. 그러나 그후에 따로 프로퍼티에 접근해서 수정하려고 하면 오류가 나게 된다.
interface User {
name: string;
age: number;
gender?: string;
readonly birthYear: number; // 읽기 전용 속성
}
let user: User = {
name: 'jeff',
age: 30,
birthYear: 2010, // 최초에 값을 초기화 할때만 할당이 가능
};
user.birthYear = 1999; // Error - 이후에는 수정이 불가능
[readonly vs const]
readonly와 const 는 처음 초기화 할때만 값을 선언하고, 그후에는 값을 수정하지 못한다는 점에서 유사하다.
다만 이 둘은 사용처가 다른데, 변수는 const를 쓰고 프로퍼티는 readonly를 사용된다는 점만 기억하면 된다.
readonly 활용
만약 모든 속성이 readonly일 경우, 일일히 프로퍼티마다 readonly 를 찍어주지말고, 따로 유틸리티(Utility)나 단언(Assertion) 타입을 활용해 구현이 가능하다.
이들은 고급 기법이므로 지금은 이런식으로 활용이 가능하다 정도 까지만 알자
// readonly 무식하게 찍기
interface IUser {
readonly name: string,
readonly age: number
}
let user: IUser = {
name: 'Neo',
age: 36
};
user.age = 85; // Error
user.name = 'Evan'; // Error
// Readonly 유틸리티(Utility) 활용
interface IUser {
name: string,
age: number
}
let user: Readonly<IUser> = { // Array 처럼 따로 Readonly 라는 자료형이 있다고 생각하면 된다
name: 'Neo',
age: 36
};
user.age = 85; // Error
user.name = 'Evan'; // Error
// Type assertion
let user = {
name: 'Neo',
age: 36
} as const; // 따로 인터페이스를 이용하지 않고 객체 데이터 자체에 as const를 붙이게 되면 이 자체가 리터럴 타입이 되게 된다.
user.age = 85; // Error
user.name = 'Evan'; // Error
읽기 전용 배열
배열을 선언할 때 위에서 소개했던 ReadonlyArray<T> 유틸리티 타입을 사용하면 읽기 전용 배열을 생성할 수 있다.
선언하는 시점에만 값을 정의할 수 있고, 후에 배열의 내용을 변경할 수 없다.
let arr: ReadonlyArray<number> = [1,2,3]; // 읽기 전용 배열
arr.splice(0,1); // error
arr.push(4); // error
arr[0] = 100; // error
인터페이스 호환
같은 이름의 인터페이스를 여러 개 만들 수도 있다.
이들은 중복이되면, 안의 선언된 프로퍼티가 하나로 합쳐진다고 보면된다.
기존에 만들어진 인터페이스에 내용을 추가하는 경우에 유용하게 자주 쓰이는 기법이다.
interface IFullName {
firstName: string,
lastName: string
}
interface IFullName {
middleName: string
}
const fullName: IFullName = {
firstName: 'Tomas',
middleName: 'Sean',
lastName: 'Connery'
};
인터페이스 확장 (extends)
클래스와 마찬가지로 인터페이스도 인터페이스 간 확장이 가능하다.
자바스크립트 클래스에서 상속을 할때 extends 키워드를 사용하는데 그대로 인터페이스에서도 사용해주면 된다.
interface Person {
name: string;
}
interface Developer extends Person {
skill: string;
}
let fe: Developer = { name: 'josh', skill: 'TypeScript' };
만일 인터페이스 확장기능을 타입 별칭으로 구현하려고 하면 인터렉션 타입(&)을 이용하여 다음과 같이 할 수 있다.
type Person = { name: string };
type Developer = Person & { skill: string };
let fe: Developer = { name: 'josh', skill: 'TypeScript' };
특이한 점은 인터페이스 확장은 여러개를 extends 가 가능하다는 점이다. (클래스는 반드시 하나만 extends 할 수 있다)
interface Person {
name: string;
age: number;
}
interface Programmer {
favoriteProgrammingLanguage: string;
}
interface Korean extends Person, Programmer { // 두개의 인터페이스를 받아 확장
isLiveInSeoul: boolean;
}
const person: Korean = {
name: '홍길동',
age: 33,
favoriteProgrammingLanguage: 'kor',
isLiveInSeoul: true,
};
인터페이스 함수 타입
호출 시그니처 (Call Signature)
인터페이스는 함수의 타입을 정의할 때에도 사용할 수 있다.
어렵게 이해할 필요없이, 자바(JAVA) 에서도 다음과 같이 인터페이스를 정의할때 메소드 모양을 넣어줬던 것처럼, 인터페이스도 함수의 모양(타입)을 정의한다고 이해하면 된다.
이를 전문 용어로 호출 시그니처 라고 부른다.
public interface login {
public Boolean loginUser(); // 자바는 함수명만 쓰지만
}
interface login {
(username: string, password: string): boolean; // 타입스크립트는 함수명이 아닌 함수의 모양(인자, 리턴) 타입을 쓴다
}
// 매개 변수 이름이 인터페이스와 일치할 필요가 없다.
// 또한 타입 추론을 통해 선언할 함수에 타입을 굳이 쓸 필요가 없다.
let loginUser: login = function(id, pw) {
console.log('로그인 했습니다');
return true;
}
자바스크립트에서는 함수도 속성값을 가질수 있다.
인터페이스를 함수 자체 타입을 정의할때 다음과 같이 함수 속성 값도 정의할수 있다.
interface GetText {
(name: string, age: number): string;
totalCall?: number; // 함수 속성 값 정의
}
const getText: GetText = function (name, age) {
if (getText.totalCall !== undefined) {
getText.totalCall += 1;
console.log(`totalCall: ${getText.totalCall}`);
}
return '';
};
getText.totalCall = 0; // 함수 속성값
getText('', 0); // 1
getText('', 0); // 2
호출 시그니처는, 자바스크립트의 객체의 prototype에 메소드를 추가적으로 등록할때도 유용히 사용된다.
다음은 자바스크립트의 기본 객체인 Object 에 getshortKeys() 라는 새로운 메소드를 프로토타입으로 등록하는 예제이다.
interface Object { // 인터페이스 호환을 통해 기존의 Object 인터페이스에 다음 호출 시그니처를 등록
getShortKeys(this: object): string[];
}
Object.prototype.getShortKeys = function () {
return Object.keys(this).filter((key) => key.length <= 3);
};
const obj = {
a: 1,
bb: 2,
ccc: 3,
dddd: 4,
};
console.log(obj.getShortKeys()); // [ 'a', 'bb', 'ccc' ]
인터페이스 함수 오버로드
인터페이스 내에서도 함수 자체 타입을 오버로드 시켜 멀티 함수를 구현할 수 있다.
interface Add {
(x: number, y: number): number;
(x: string, y: string): string;
}
const add: Add = (x: any, y: any) => x + y;
실제로 미리정의된 타입스크립트 인터페이스 파일 lib.es5.d.ts 을 보면, 자바스크립트의 filter 함수가 오버로드로 두개가 정의 되어 있음을 알 수 있다.
타입스크립트 함수 문법에 대해서 자세히 알고 싶다면 다음 포스팅을 참고하기 바란다.
인터페이스 클래스 타입
C#이나 자바처럼 타입스크립트에서도 클래스가 일정 조건을 만족하도록 타입 규칙을 정할 수 있다.
인터페이스로 클래스를 정의하는 경우, implements 키워드를 사용해 클래스 정의 옆에다 붙여주면 된다.
interface IUser {
name: string;
getName(): string;
}
// IUser 인터페이스를 implements 하면 User 클래스의 프로퍼티 구조는 반드시 IUser에 정의된 대로 따라야 한다.
// 즉, 반드시 name 변수와 getName() 메소드를 클래스에 기본값으로 구현해야 한다.
class User implements IUser {
name: string;
constructor(name: string) {
this.name = name;
}
getName() {
return this.name;
}
}
const neo = new User('Neo');
neo.getName(); // Neo
구성 시그니처(Consruct Signature)
위의 호출 시그니처는 함수 타입 구조를 정의하는 것이라면 구성 시그니처는 new 클래스() 생성자 함수 타입 구조를 정의 하는 것이다.
이를 응용해 함수 매개변수를 클래스를 받아 대신 초기화를 진행이 가능하다.
interface ICatConstructor {
new (name: string): Cat; // 구성 시그니처
}
class Cat {
constructor(public name: string) {} // 생성자 함수
}
// 클래스를 인수로 받고 대신 초기화 해주는 함수
function makeKitten(c: ICatConstructor, n: string) {
return new c(n); // ok
}
const kitten = makeKitten(Cat, 'Lucy');
console.log(kitten.name); // Lucy
인덱스(Indexable) 타입
지금까지 사용한 인터페이스는 직접 속성의 타입을 하나하나 지정해주어 사용하였다.
하지만 인터페이스에서 정의할 속성들이 엄청 많아 질경우 애로사항이 생길수 있는데, 이때 규칙이 있는 속성이라면, 우리가 마치 변수라는 곳에 값을 넣어 유기적으로 이용하듯이, 인터페이스도 이를 활용할 수 있다.
예를들어 학년별 (1학년 ~ 4학년) 점수를 (A ~ F) 인터페이스로 구현한다면 다음과 같이 될 수 있다.
type Score = 'A' | 'B' | 'C' | 'D' | 'F';
interface User {
name: string;
[grade: number]: Score; // indexable 타입 (선택적 프로퍼티 처럼 반드시 선언 안해줘도 된다.)
}
const user1: User = {
name: '홍길동',
1: 'A',
};
const user2: User = {
name: '임꺾정',
3: 'F',
};
const user3: User = {
name: '박혁거세',
2: 'B',
};
대괄호 [] 안에 key의 명칭을 grade로 정해주고 이 key의 타입을 number로 정해주었다. (key의 이름은 꼭 grade 일 필요없다)
그리고 key의 value 타입을 문자열 리터럴 타입으로 지정해 주었다.
그럼 위와 같이 보다 체계적이게 객체를 구성이 가능하다.
객체 뿐만 아니라 배열도 인덱서블 타입으로 지정이 가능하다.
interface IItem {
[itemIndex: number]: string // Index signature
}
let item: IItem = ['a', 'b', 'c']; // Indexable type
console.log(item[0]);
console.log(item[1]);
console.log(item['0']); // Error - itemIndex 인덱서블 속성은 number 타입이라 에러
interface IItem {
[itemIndex: number]: string | boolean | number[] // 여러개의 타입을 유니온
}
let item: IItem = ['Hello', false, [1, 2, 3]];
console.log(item[0]); // Hello
console.log(item[1]); // false
console.log(item[2]); // [1, 2, 3]
이러한 구성이 가능한 이유는, 자바스크립트의 배열도 결국 객체의 일종이기 때문에, 자동으로 key 부분에 인덱스 숫자들이 자동으로 채워져서 그렇다.
[인덱서]의 타입은 string과 number만 지정할 수 있다는 점에 유의하자.
이는 생각해보면 당연한 원리인데, key는 문자만 되고(object.key), 배열은 인덱스(array[0])는 숫자이기 때문이다.
인덱스 시그니처(indexable type)를 사용하면 인터페이스에 정의되지 않은 속성들을 유기적으로 사용할 때 유용하다는 장점이 있다. (단, 해당 속성이 인덱스 시그니처에 정의된 반환 값을 가져야 한다)
interface IUser {
name: string;
age: number;
[userProp: string]: string | number;
}
let user: IUser = {
name: 'Neo',
age: 123,
// 인터페이스에 직접 정의되지 않는 속성이라도, 인덱서블 타입에만 잘 맞추면 얼마든지 추가가 가능하다.
email: 'inpa@gmail.com',
gender: 'F',
height: 180,
};
console.log(user.email); // inpa@gmail.com
console.log(user.height); // 180
인덱스 시그니처(indexable type)는 조금 생소한 개념이니 코드 예시를 더 들어보겠다.
interface DictIndex {
[whatever: string]: { id: number; name: string }; // key가 string 이고 value가 {id, name} 객체 꼴
}
const dict: DictIndex = {
first: { id: 1, name: "darren" },
second: { id: 2, name: "mary" },
};
interface StringRegexDict {
[key: string]: RegExp; // 정규식도 가능
}
const object: StringRegexDict = {
sth: /abc/,
};
// 배열의 인덱싱에 사용하는 경우
interface StringArray {
[index: number]: string | number; // key가 숫자 / value가 string 또는 number
}
let array: StringArray = [];
array[0] = "hi";
array[1] = 3;
interface PhoneNumberDictionary {
[phone: string]: { // 문자열을 key로 받고, 객체 타입 value 로 구성된 인덱서블 시그니처
num: string;
};
}
interface Contact {
name: string; // 이름
address: string; // 주소
phones: PhoneNumberDictionary; // 집번호, 회사번호 ..등 중첩 객체 구조로 구성
}
// 아래와 같은 꼴을 타이핑할 수 있게 됨
const inform = {
name: 'Tony',
address: 'Malibu',
// [phone: string]: { num: number };
phones: {
home: {
num: '033-1111-1111',
},
office: {
num: '011-1111-1222',
},
},
};
# 참고자료
https://typescript-kr.github.io/pages/interfaces.html
https://heropy.blog/2020/01/27/typescript/
이재승 타입스크립트 시작하기
제로초 타입스크립트 올인원
쉽게 시작하는 타입스크립트 (길벗, 2023, 캡틴판교 지음)
이 글이 좋으셨다면 구독 & 좋아요
여러분의 구독과 좋아요는
저자에게 큰 힘이 됩니다.