최근에 TypeScript 관련 단톡방에서 type과 interface 타입을 정의하고 그 값을 조건문 분기 하는 질문 글이 올라왔는데 나도 그 문제에 대해 풀어보려고 여러 가지 시도를 하다 보니 내가 TypeScript의 class, interface, type 등 아직 잘 모른다고 생각해 이펙티브 타입스크립트 책을 확인하던 중에 나에게 딱 필요한 부분이 있어서 이펙티브 타입스크립트 아이템 8을 토대로 글을 작성하려 한다.
앞으로 얘기하는 심벌은 ES6에서 추가된 자료형 심벌(symbol)이 아니라 그냥 그 값, 상징 실제 영어단어의 symbol을 뜻한다고 생각하면 좋을 것이다.
책에서는 타입스크립트 심벌(symbol)은 타입 공간이나 값 공간 중의 한 곳에 존재한다고 말한다. 그래서 심벌은 이름이 같더라도 속하는 공간에 따라 다른 것을 나타낼 수 있기 때문에 혼란을 야기한다.
interface Cylinder {
radius: number;
height: number;
}
const Cylinder = (radius:number, height:number) => ({radius,height});
function calculateVolume(shape: unknown) {
if(shape instaceof Cylinder) {
shape.radius -> // '{}' 형식에 'radius' 속성이 없습니다.
}
}
위 코드 예시에서 interface Cylinder에서 Cylinder는 타입으로 쓰이고, const Cylinder에서 Cylinder는 값으로 쓰이며 이름은 같지만 서로 아무런 연관도 없다. 상황에 따라 Cylinder는 타입 혹은 값으로 쓰일 수 있어 이런 점이 오류를 야기한다고 한다. 함수를 보면 instanceof로 shape의 타입이 Cylinder인지 체크하려고 했지만 안타깝게도 instanceof는 자바스크립트의 런타임 연사자이고, 값에 대해서 연산을 하기에 instanceof Cylinder는 interface로 선언된 타입이 아니라 const로 선언된 함수를 참조하는 것이다.
(여기서 꼭 명심하자 instanceof는 런타임 연산자이고 값에 대한 연산이라는 것을 내가 정말 정말 많이 한 실수이다. type, interface에 instanceof사용하려는 시도....)
이러한 이유들 때문에 한 심벌이 타입인지 값인지 언뜻 봐서는 알 수 없고, 어떤 형태로 쓰이는지 문맥을 봐야 한다.
type T1 = 'string literal'; // 여기서는 문자열 리터럴 타입
type T2 = 123;
const v1 = 'string literal'; // 여기서는 문자열 리터럴
const v2 = 123;
일반적으로 type이나 interface 다음에 나오는 심벌은 타입인 반면, const나 let 선언에 쓰이는 것은 값이다.
class와 enum은 상황에 따라 타입과 값 두 가지 모두 가능한 예약어이다.
class Cylinder {
radius=1;
height=1;
}
function calculateVolume(shape: unknown) {
if(shape instanceof Cylinder) {
shape // 정상, 타입은 Cylinder
shape.radius // 정상, 타입은 number
}
}
클래스가 타입으로 쓰일 때는 형태(속성과 메서드)가 사용되는 반면, 값으로 쓰일 때는 생성자가 사용된다.
한편, 연산자 중에서도 타입에서 쓰일 때와 값에서 쓰일 때 다른 기능을 하는 것들이 있는 데, 그 예 중 하나로 typeof를 들 수 있다.
interface Person {
first: string;
last: string;
}
const p: Person = {first: 'Jane', last: 'Jacobs'};
function email(p: Person, Subject: string, body: string): Response {
}
type T1 = typeof p; // 타입은 Person
type T2 = typeof email;
// 타입은 (p: Person, Subject: string, body: string) => Response
const v1 = typeof p; // 값은 "object"
const v2 = typeof email; // 값은 "function"
타입의 관점에서 typeof는 값을 읽어서 타입스크립트 타입을 반환하고, 값의 관점에서 typeof는 자바스크립트 런타임의 typeof 연산자가 되어 대상 심벌의 런타임 타입을 가리키는 문자열을 반환한다. 또한 아까 말한 것처럼 class 키워드는 값과 타입 두 가지 모두 사용되기에 클래스에 대한 typeof는 상황에 따라 다르게 동작한다.
class Cylinder {
radius=1;
height=1;
}
const v = typeof Cylinder; // 값이 "function"
type T = typeof Cylinder; // 타입이 typeof Cylinder
declare let fn: T;
const c = new fn(); // 타입이 Cylinder
// 제너릭을 사용해 성성자 타입과 인스턴스 타입을 전환
type C = InstanceType<typeof Cylinder>; // 타입이 Cylinder
클래스가 자바스크립트에서는 실제 함수로 구현되기 때문에 첫 번째 줄의 값은 "function"이 되고 두 번째 줄의 타입은 왜 typeof Cylinder일까 궁금할 것이다. 여기서 중요한 것은 Cylinder가 인스턴스의 타입이 아니라는 점이다. 실제로는 new 키워드를 사용할 때 볼 수 있는 생성자 함수이다.
속성접근자 []는 타입으로 쓰일 때에도 동일하게 동작한다. 그러나 obj['field']와 obj.field는 값이 동일하더라고 타입은 다를 수 있다. 따라서 타입의 속성을 얻을 때에는 반드시 첫 번째 방법(obj['field'])을 사용해야 한다.
interface Person {
first: string;
last: string;
}
const p: Person = {first: 'Jane', last: 'Jacobs'};
const first: Person['first'] = p['first'];
이 속성접근자 부분 글이 도대체 뭘 말하는 건지 모르겠어서 한참을 생각했는데 그러니까 속성에 접근하는 obj['field']나 obj.field는 타입을 명시하는 부분이든 값을 가져오는 부분이든 같은 문법으로 사용된다는 말이고 그 대신 타입을 가져오는 부분에서는 무조건 obj['field'] 이 형식을 사용해라는 것이다. 근데 테스트해 보니 무조건 후자만 되기도 한다. obj.field으로 해보려고 해도 바로 에러가 걸려서 안된다. 나만 글을 읽고 이렇게 오래 고민했나 싶기도 하다... 또 이 코드 예시는 이해를 위한 코드이지 실제로 사용할 때는 const first를 선언하는 부분에서는 이미 타입 추론이 되고 있을 거라서 굳이 타입을 한 번 더 선언하는 거는 비효율 적이다.
마지막으로 타입 공간과 값 공간을 혼동해서 잘못 작성했을 가능성이 큰 코드 예시를 보며 마무리 하자. 예를 들어 단일 객체 매개변수를 받도록 email 함수를 변경했다고 생각해 보자
function email(options: {person: Person, subject: string, body:string}) {
// ...
}
자바스크립트 구조 분해 할당 사용
function email({person, subject, body}) {
// ....
}
어떤가 깔끔하지 않은가?? 그럼 타입스크립트에도 적용을 해보자!!
(밑으로 넘어가기 전에 타입스크립트에서 어떻게 구조 분해 할당을 사용해 함수를 선언할지 한 번 생각을 해보고 넘어가길 바란다. )
하지만 타입스크립트에서는 깔끔하게 오류가 발생한다.
function email({
person: Person,
// ~~~~ 바인딩 요소 'Person'에 암시적으로 'any'형식이 있습니다.
subject: string,
// ~~~~~ 'string' 식별자가 중복되었습니다.
// 바인딩 요소 'string'에 암시적으로 'any' 형식이 있습니다.
body: string,
// ~~~~~ 'string' 식별자가 중복되었습니다.
// 바인딩 요소 'string'에 암시적으로 'any' 형식이 있습니다.
}) {/* ... */}
값의 관점에서 Person가 string이 타입이 아닌 값으로 해석되었기 때문이다. Person이라는 변수명과 string이라는 이름을 가지는 두 개의 변수를 생성하려 한 것이다. 문제를 해결하려면 타입과 값을 구분해야 한다.
functino email({person, subject, body}: {person:Person, subject: string, body: string}) {
// ...
}
이 코드는 장황하기 하지만, 매개변수에 명명된 타입을 사용하거나 문맥에서 추론되도록 잘 동작한다.
이번에 작은 문제로부터 시작해서 책과 블로그를 찾아보고 결론적으로 책의 내용을 정리하는 건데도 전부 이해하면서 요약하려 쓰려니 평소 블로그 글을 쓰는 것보다 더 어려웠던 거 같다. 그래도 이 파트는 정말 깊게 이해한 거 같아서 뿌듯하기도 하다. 타입스크립트를 사용하는 많은 분들에게 도움이 되기를!!
'개발 > JavaScript' 카테고리의 다른 글
[JavaScript] await? return await? return? (0) | 2023.03.25 |
---|---|
[JavaScript] 옵셔널 체이닝(Optional chaining) 사용법과 주의할점 (0) | 2023.02.13 |
[JavaScript] call by value, call by reference, call by sharing (0) | 2023.02.07 |
[JavaScript] 인자 개수가 자유로운 함수 선언(arguments) (0) | 2023.02.05 |
[JavaScript] parameter vs argument (0) | 2023.02.04 |
댓글