TypeScript의 구조적 타이핑, 타입 소거, 공변/반공변 이슈, 콜백 매개변수와 선택적 표시의 의미, void 반환의 대체 가능성, 비어 있는 타입, 타입 별칭과 브랜드, 런타임 타입 구분과 사용자 정의 타입 가드, 타입 단언, 매개변수 이름, 컨텍스트 타이핑과 인덱스 시그니처, 함수 오버로드 동작을 설명합니다.
TypeScript는 구조적 타이핑을 사용합니다. 이 시스템은 여러분이 사용해 봤을 수 있는 일부 인기 있는 언어(예: Java, C# 등)가 채택한 타입 시스템과 다릅니다.
구조적 타이핑의 핵심 아이디어는 두 타입의 멤버가 호환되면 두 타입 자체도 호환된다는 것입니다. 예를 들어 C#이나 Java에서는 다음과 같이 이름이 다른 두 클래스
MyPoint
와
YourPoint
가 있고, 둘 다 public
int
프로퍼티
x
와
y
를 가진다고 해도 서로 바꿔 쓸 수 없습니다. 비록 완전히 동일해 보여도 말이죠. 하지만 구조적 타입 시스템에서는 타입 이름이 다르다는 사실은 중요하지 않습니다. 동일한 멤버를 동일한 타입으로 갖고 있다면 그 둘은 동일합니다.
이 원리는 서브타입 관계에도 적용됩니다. 예를 들어 C++에서는
Dog
을
Animal
자리에 쓰려면
Animal
이 명시적으로
Dog
의 클래스 계보에 있어야 합니다. TypeScript에서는 그렇지 않습니다. 적절한 타입의 멤버를
Animal
만큼(혹은 그보다 더 많이) 가진
Dog
은 명시적 상속 여부와 상관없이
Animal
의 서브타입입니다.
이 점은 명명적 타입 언어에 익숙한 프로그래머에게는 놀랍게 느껴질 수 있습니다. 이 FAQ의 많은 질문은 구조적 타이핑과 그 함의에서 비롯됩니다. 하지만 기본 개념을 이해하고 나면 추론하기 매우 쉬워집니다.
TypeScript는 컴파일 시 타입 애너테이션, 인터페이스, 타입 별칭 및 기타 타입 시스템 구성요소를 제거합니다.
입력:
TypeScript
복사
출력:
TypeScript
복사
즉, 런타임에는 어떤 변수
x
가
SomeInterface
타입으로 선언되었다는 정보를 전혀 보존하지 않습니다.
런타임 타입 정보가 없다는 점은 리플렉션이나 기타 메타데이터 시스템을 광범위하게 사용하는 데 익숙한 프로그래머에게는 놀라울 수 있습니다. 이 FAQ의 많은 질문이 결국 “타입이 소거되기 때문”으로 귀결됩니다.
이런 코드를 썼는데 오류가 날 줄 알았습니다:
일반 텍스트
복사
이는 타입 시스템에 공변/반공변 애너테이션이 명시적으로 없기 때문에 생기는 비안전성(unsoundness)입니다. 이 제약으로 인해 TypeScript는
(x: Dog) => void
가
(x: Animal) => void
에 할당 가능한지 판단할 때 더 관대해질 수밖에 없습니다.
그 이유를 이해하려면 두 가지 질문을 생각해 봅시다.
두 번째 질문(Dog[]가 Animal[]의 서브타입이어야 하는가?)이 더 분석하기 쉽습니다. 만약 답이 “아니오”라면 어떻게 될까요?
TypeScript
복사
이는 매우 성가십니다. 배열을 수정하지 않는다는 전제에서
checkIfAnimalsAreAwake
가 배열을 바꾸지 않는다면 이 코드는 100% 올바릅니다. 단지
Dog[]
를
Animal[]
대신 사용할 수 없다는 이유로 이런 프로그램을 거부하는 것은 합당하지 않습니다. 여기서 분명히
Dog
들의 집합은
Animal
들의 집합이기도 하니까요.
이제 첫 번째 질문으로 돌아가 봅시다. 타입 시스템이
Dog[]
가
Animal[]
의 서브타입인지 결정할 때(컴파일러가 최적화를 전혀 하지 않는다고 가정하고) 다음과 같은 계산을 포함해 여러 가지를 수행합니다:
보시다시피 타입 시스템은 “타입
(x: Dog) => number
가
(x: Animal) => number
에 할당 가능한가?”를 물어야 합니다. 이는 우리가 처음 던졌던 질문과 같습니다. 만약 TypeScript가 매개변수에 대해 ‘반공변’만 강제했다면(즉,
Animal
이
Dog
에 할당 가능해야 한다면),
Dog[]
는
Animal[]
에 할당 가능하지 않았을 것입니다.
요약하면, TypeScript 타입 시스템에서는 “더 구체적인 타입의 인수를 받는 함수가, 덜 구체적인 타입의 인수를 받는 함수에 할당 가능해야 하는가?”라는 질문이, “그 더 구체적인 타입의 배열이 덜 구체적인 타입의 배열에 할당 가능해야 하는가?”라는 질문에 대한 전제 조건을 제공합니다. 후자의 답이 “아니오”인 타입 시스템은 대부분의 경우 용납되기 어렵기 때문에, 함수 인자 타입에 한해서는 정확성의 일부를 트레이드오프합니다.
이런 코드를 썼는데 오류가 날 줄 알았습니다:
TypeScript
복사
이는 예상되고 바람직한 동작입니다. 먼저 FAQ 맨 위의 “대체 가능성(substitutability)”을 참고하세요 —
handler
는 추가 매개변수를 안전하게 무시할 수 있으므로
callback
의 유효한 인수입니다.
다음 경우도 생각해 봅시다:
TypeScript
복사
이는 “오류가 나야 한다고 생각했던” 예제와 동형(isomorphic)입니다. 런타임에
forEach
는 콜백을 (value, index, array) 세 인자로 호출하지만, 대부분의 경우 콜백은 한두 개만 사용합니다. JavaScript에서 매우 흔한 패턴이며, 사용하지 않는 매개변수를 일일이 선언하게 하는 건 부담스럽습니다.
하지만 forEach가 매개변수를 선택적(optional)로 표시하면 되지 않나요? 예: forEach(callback: (element?: T, index?: number, array?: T[]))
이것은 선택적 콜백 매개변수가 의미하는 바가 아닙니다. 함수 시그니처는 항상 호출자 관점에서 읽어야 합니다. 만약
forEach
가 콜백 매개변수를 선택적으로 선언했다면, 그 의미는 “
forEach
가 콜백을 인자 없이(0개 인자) 호출할 수도 있다”입니다.
선택적 콜백 매개변수의 의미는 다음과 같습니다:
TypeScript
복사
forEach
는 항상 콜백에 세 인자를 모두 제공합니다.
index
매개변수가
undefined
인지 확인할 필요가 없습니다 — 항상 존재하며, 선택적이 아닙니다.
현재 TypeScript에는 콜백 매개변수가 반드시 존재해야 함을 표시하는 방법이 없습니다. 주의할 점은, 이런 강제가 직접적으로 버그를 고치지는 않는다는 것입니다. 가령 가상의 세계에서
forEach
콜백이 최소 한 개의 인수를 받아야 한다고 요구하더라도, 다음 코드는:
TypeScript
복사
“고쳐졌다”고 할 수는 있지만, 매개변수를 하나 추가한다고 해서 더 정확해지는 것은 아닙니다:
TypeScript
복사
이런 코드를 썼는데 오류가 날 줄 알았습니다:
TypeScript
복사
이 또한 예상되고 바람직한 동작입니다. 먼저 “대체 가능성”을 다시 떠올려 보세요 —
doSomething
이
callMeMaybe
보다 “더 많은” 정보를 반환한다고 해도 유효한 대체입니다.
또 다른 경우를 보겠습니다:
일반 텍스트
복사
이것은 “오류가 나야 한다고 생각했던” 예제와 동형입니다.
Array#push
는 숫자(배열의 새로운 길이)를 반환하지만,
void
를 반환하는 함수 자리에서 안전하게 대체할 수 있습니다.
다른 관점에서 보면,
void
를 반환하는 콜백 타입은 “반환값이 있더라도 나는 그것을 보지 않겠다”는 의미입니다.
이런 코드를 썼는데 오류가 날 줄 알았습니다:
TypeScript
복사
멤버가 하나도 없는 타입은 어떤 타입으로도 대체될 수 있습니다. 이 예제에서
window
,
42
,
'huh?'
는 모두
Thing
이 요구하는 멤버(없음)를 갖고 있습니다.
일반적으로 프로퍼티가 전혀 없는
interface
를 선언하는 일은 없어야 합니다.
다음 코드를 작성했을 때 오류가 나길 기대했습니다:
TypeScript
복사
타입 별칭은 말 그대로 별칭일 뿐이며, 참조하는 타입과 구분되지 않습니다.
“브랜드드 원시 타입(branded primitives)”을 만들기 위한 교차 타입(intersection) 기반의 우회 방법이 가능합니다:
TypeScript
복사
이 타입의 값을 만들 때마다 타입 단언을 추가해야 합니다. 그래도 여전히
string
으로 별칭을 만들면 타입 안전성이 사라질 수 있습니다.
다음 코드는 오류를 발생시키길 바랍니다:
TypeScript
복사
두 타입을 실제로 서로 호환되지 않게 만들고 싶다면 ‘브랜드’ 멤버를 추가하는 방법이 있습니다:
TypeScript
복사
이 경우 ‘브랜드드’ 객체를 만들 때마다 타입 단언이 필요하다는 점에 유의하세요:
TypeScript
복사
#202도 함께 참고하세요. 이 부분을 더 직관적으로 만드는 제안 이슈입니다.
이런 식의 코드를 작성하고 싶습니다:
TypeScript
복사
TypeScript의 타입은 컴파일 중 소거됩니다(https://en.wikipedia.org/wiki/Type_erasure). 즉, 런타임 타입 검사를 수행할 내장 메커니즘이 없습니다. 객체를 어떻게 구분할지는 여러분에게 달려 있습니다. 흔한 방법은 객체의 프로퍼티를 검사하는 것입니다. 사용자 정의 타입 가드를 이용해 이를 구현할 수 있습니다:
TypeScript
복사
이런 코드를 작성했습니다:
TypeScript
복사
또는 다음과 같은 코드
let a: any = 'hmm'; let b = a as HTMLElement; // b === null일 거라 기대함
TypeScript에는 타입 캐스트가 아니라 타입 단언(type assertion)이 있습니다.
<T>x
의 의도는 “TypeScript야,
x
를
T
로 취급해 줘”이지, 타입 안전한 런타임 변환을 수행한다는 뜻이 아닙니다. 타입이 소거되기 때문에 C#의
expr as type
이나
(type)expr
에 해당하는 직접적 동등물이 없습니다.
이런 코드를 썼는데 오류가 날 줄 알았습니다:
TypeScript
복사
함수 타입에서 매개변수 이름은 필수입니다. 작성하신 코드는 이름이
number
이고 타입이
any
인 하나의 매개변수를 받는 함수를 의미합니다. 다시 말해, 다음 선언은
TypeScript
복사
다음 선언과 동일합니다
TypeScript
복사
대신 아래처럼 작성해야 합니다:
TypeScript
복사
이 문제를 피하려면
noImplicitAny
플래그를 켜세요. 그러면 묵시적
any
매개변수 타입에 대해 경고가 발생합니다.
이 세 함수는 같은 일을 하는 것 같은데, 마지막 것만 오류입니다. 왜 그럴까요?
TypeScript
복사
이제는(TypeScript 1.8 이상) 더 이상 오류가 아닙니다. 이전 버전의 경우 설명은 다음과 같습니다.
컨텍스트 타입(contextual typing)은 어떤 표현식의 주변 문맥이 그 타입에 대한 힌트를 제공할 때 발생합니다. 예를 들어 다음 초기화에서:
TypeScript
복사
표현식
y
는
number
타입을 초기화하므로 컨텍스트 타입으로
number
를 얻습니다. 이 경우 특별한 일은 일어나지 않지만, 다른 경우에는 더 흥미로운 일이 벌어집니다.
가장 유용한 경우 중 하나는 함수입니다:
TypeScript
복사
컴파일러는 어떻게
s
가
string
임을 알았을까요? 그 함수 식을 단독으로 썼다면
s
는
any
가 되었고 오류도 없었을 겁니다. 하지만 함수가
x
의 타입으로부터 컨텍스트 타입을 받았기 때문에 매개변수
s
가
string
타입을 얻게 된 것입니다. 매우 유용하죠!
동시에, 인덱스 시그니처는 객체를
string
이나
number
로 인덱싱할 때의 타입을 지정합니다. 당연히 이 시그니처는 타입 검사에 포함됩니다:
TypeScript
복사
인덱스 시그니처의 부재 또한 중요합니다:
TypeScript
복사
객체에 인덱스 시그니처가 없다고 가정해 버리면, 인덱스 시그니처가 있는 객체를 초기화할 방법이 사라지는 문제가 생깁니다:
TypeScript
복사
해결책은, 객체 리터럴이 인덱스 시그니처를 가진 타입으로 컨텍스트 타이핑될 때 그 인덱스 시그니처가 매칭되면 객체 리터럴의 타입에 그 시그니처를 추가하는 것입니다. 예를 들어:
TypeScript
복사
원래 함수로 돌아가 봅시다:
TypeScript
복사
result
의 타입에는 인덱스 시그니처가 없기 때문에 컴파일러가 오류를 보고합니다.
왜 “Supplied parameters do not match any signature”(제공된 매개변수가 어떤 시그니처와도 일치하지 않습니다) 오류가 발생하나요?
함수(또는 메서드)의 구현 시그니처는 오버로드 목록의 일부가 아닙니다.
TypeScript
복사
하나 이상의 오버로드 시그니처 선언이 있으면, 오직 그 오버로드들만 보입니다. 마지막 시그니처 선언(구현 시그니처라고도 함)은 여러분의 시그니처 모양(shape)에 기여하지 않습니다. 원하는 동작을 얻으려면 오버로드를 하나 더 추가해야 합니다:
TypeScript
복사
그 이유는 JavaScript에는 함수 오버로딩이 없으므로, 함수 내부에서 매개변수 검사를 수행하게 되기 때문입니다. 따라서 구현은 사용자가 호출하길 원하는 형태보다 더 관대할 수 있습니다.
예를 들어 사용자에게 인수 쌍이 정확히 매칭되도록 요구하면서, 구현은 혼합된 인수 타입을 허용하지 않고도 이를 제대로 처리할 수 있습니다:
TypeScript
복사