
🙌안녕하세요🙌
요즘 타입스크립트 스터디를 진행하고있습니다.
일단 공식 도큐멘트를 한번 훑기로 했는데요, 제가 고급타입 부분을 맡게되어서
발표 준비 겸, 기록으로 남기기위해 포스팅합니다!
그럼 시작합니다!🚗💨
첫번째는 교차타입입니다!
다양한 타입을 하나로 결합해서 모든 기능을 갖춘 단일 타입을 얻는 방식입니다.
예를 들어, Person & Serializable & Loggable은 Person,Serializable,Loggable의 모든 멤버를 가집니다.
믹스인(다른 포스팅에서 자세히 다룰 예정입니다)에 사용되는 교차타입, 그리고 우리가 알고 있던 객체지향의 방식과 다른 경우를 볼 수 있을 것입니다.
아래는 믹스인을 만드는 간단한 예제입니다.(짧게 설명하자면, 믹스인은 서로 다른 두 객체를 섞어서 두 객체의 기능을 모두 갖춘 하나의 객체를 만드는 것입니다 - 교차타입의 개념과 비슷하죠?)
function extend<T, U>(first: T, second: U): T & U {
let result = <T & U>{}; // 교차 타입
for (let id in first) {
(<any>result)[id] = (<any>first)[id];
}
for (let id in second) {
if (!result.hasOwnProperty(id)) {
(<any>result)[id] = (<any>second)[id];
}
}
return result;
}
class Person {
constructor(public name: string) { }
}
interface Loggable {
log(): void;
}
class ConsoleLogger implements Loggable {
log() {
// ...
}
}
var jim = extend(new Person("Jim"), new ConsoleLogger());
var n = jim.name;
jim.log();
유니온 타입은 이름으로 유추해 보았을 때 오히려 교차타입의 동작을 따라야 할 것 같습니다. 하지만 유니온 타입의 동작은 교차타입의 그것과는 좀 다릅니다.
간단하게 표현할 수 있습니다. 이 글을 보시는 여러분들은 |(pipeline)으로 대치되는 or이란 키워드를 알고계시죠??
한글로 말하자면 '혹은'이라고하지요, 유니온 타입은 이 단어를 적용한 경우와 같습니다.
string | number라고하면 string 혹은 number겠죠? 이것이 하나의 타입이 되는 것입니다.
유니온타입은 모든 타입의 공통적인 멤버에만 접근할 수 있습니다.
interface Bird {
fly();
layEggs();
}
interface Fish {
swim();
layEggs();
}
function getSmallPet(): Fish | Bird {
// ...
}
let pet = getSmallPet();
pet.layEggs(); // 좋아요
pet.swim(); // 오류
유니온타입은 Fish 혹은 Bird 중에 하나라는 뜻으로 사용하니까 두 경우 전부 호환되기 위해선 공통된 멤버만 사용되어야 하겠죠? 만약 swim이라는 메소드를 사용했는데 타입이 Bird라면 메소드가 존재하지 않아 문제가 생길테니까요!
유니온 타입을 활용해서 두 타입 중 어느 타입이 들어오든 공통 된 메소드를 실행시켜야 할 경우를 커버할 수 있습니다. 그렇다면 어느 쪽 타입이 가지고 있지 않은 메소드를 실행해야할 떄는 어떻게 하면 좋을까요??
일반적으로 JavaScript에서는 메소드의 유무를 확인해서 실행할 수 있을 것입니다.
바로 이렇게요!
let pet = getSmallPet();
// 이러한 각 프로퍼티 접근은 오류를 발생시킵니다.
if (pet.swim) {
pet.swim();
}
else if (pet.fly) {
pet.fly();
}
그러나, 타입스크립트에서는 컴파일 타임에 오류가 발생하게됩니다.
pet의 타입은 Fish | Bird이니 .swim이나 .fly메소드를 사용할 수 없기때문이죠.
그럼 어떻게하면 좋을까요? 우리는 타입 단언을 사용해서 이 문제를 해결할 수 있습니다!
let pet = getSmallPet();
if ((<Fish>pet).swim) {
(<Fish>pet).swim();
}
else {
(<Bird>pet).fly();
}
바로 위에서 우리는 타입 단언을 사용해서 어느 한 쪽 타입에만 존재하는 메소드를 사용할 수 있도록 코드를 작성하였습니다. 그런데 작동은하지만...타입 단언을 너무 많이 사용해서 코드가 이쁘지않아요😢😢
타입스크립트에서는 다행히도 타입 가드(type guard)라는 것을 지원합니다!
설명을 가져와서 읽어보자면,
일부 스코프에서 타입을 보장하는 런타임 검사를 수행하는 표현식.
타입 가드를 정의하려면 반환 타입이
타입 명제(type predicate) 인 함수를 정의하기만 하면 됩니다.
이라고 하네요. 한국말인데도 외계어처럼 느껴집니다...😂😂
그냥 저는 이렇게 이해했습니다. 타입을 보장해주는 쓸모있는 친구!
예제를 보면서 감을 잡아볼까요?
function isFish(pet: Fish | Bird): pet is Fish {
return (<Fish>pet).swim !== undefined;
}
반환타입이 되게 낯설죠? 주석도 아닌데 왜 갑자기 영어 문장이....
저 낯선 친구가 위에 나온 타입 명제라는 친구입니다. xx는 xx다의 형식으로 사용해요.
타입 명제는 argName is Type의 형태로 작성하고, argName은 꼭 현재 함수에서 사용한 매개변수의 이름을 사용해야 합니다.
이제 이 타입가드를 이용하면 코드를 조금 더 깔끔하게 작성할 수 있습니다.
// 'swim'과 'fly' 호출은 이제 모두 괜찮습니다.
if (isFish(pet)) {
pet.swim();
}
else {
pet.fly();
}
헷갈릴때는 타입가드의 return 값이 true이면 명제가 옳다는 것으로 인식한다라는 점을 기억하세요.
즉 isFish(pet)이 true이면 pet은 Fish다! 이해에 도움이 되셨나요❓
이 조건을 타입스크립트가 이해하기때문에 위의 코드에서 pet.swim과 pet.fly부분에
서 컴파일 오류가 사라지게 되는겁니다.
typeof 타입 가드(typeof type guards)typeof를 사용한 타입가드 또한 작성이 가능합니다!
위에서 작성한 코드를 기준으로 만들어봅시다!
function isNumber(x: any): x is number {
return typeof x === "number";
}
function isString(x: any): x is string {
return typeof x === "string";
}
function padLeft(value: string, padding: string | number) {
if (isNumber(padding)) {
return Array(padding + 1).join(" ") + value;
}
if (isString(padding)) {
return padding + value;
}
throw new Error(`Expected string or number, got '${padding}'.`);
}
타입가드는 작동하겠지만... 뭔가 타입마다 저렇게 만들어주어야하는게 굉장히 귀찮은 작업일 것 같네요💦💦
이런 단순한 반복작업을 피하기위해, 타입스크립트에서는 인라인 검사를 통해서 타입가드를 지원합니다!
인라인이라 함은, 함수의 내용만을 그대로 옮겨놓은 듯이 작성하는 것을 말합니다.
function padLeft(value: string, padding: string | number) {
if (typeof padding === "number") {
return Array(padding + 1).join(" ") + value;
}
if (typeof padding === "string") {
return padding + value;
}
throw new Error(`Expected string or number, got '${padding}'.`);
}
작성해야할 코드가 많이 줄었는데 여전히 타입가드는 정상적으로 작동할 것입니다.
typeof키워드를 활용한 타입가드는
typeof v === "typename" 혹은 typeof v !== "typename"의 두가지 형태를 지원합니다.
typename은 [number, string, boolean, symbol]중의 하나이어야합니다.
물론 다른 문자열도 가능합니다만, 타입가드로 인식하지는 않습니다.
instanceof 타입 가드(instanceof type guards)class같은 경우는 typeof를 사용해도 항상 "object"라는 값 만을 얻을 수 있을 것입니다.
이때, 우리는 좌절하지않고 instanceof키워드를 사용하면 됩니다!
interface Padder {
getPaddingString(): string
}
class SpaceRepeatingPadder implements Padder {
constructor(private numSpaces: number) { }
getPaddingString() {
return Array(this.numSpaces + 1).join(" ");
}
}
class StringPadder implements Padder {
constructor(private value: string) { }
getPaddingString() {
return this.value;
}
}
function getRandomPadder() {
return Math.random() < 0.5 ?
new SpaceRepeatingPadder(4) :
new StringPadder(" ");
}
// 여기까지는 단순히 랜덤한 인스턴스를 만들기 위한 밑작업입니다.
// 'SpaceRepeatingPadder | StringPadder' 타입입니다
let padder: Padder = getRandomPadder();
if (padder instanceof SpaceRepeatingPadder) {
padder; // 타입이 'SpaceRepeatingPadder'로 좁혀졌습니다
if (padder instanceof StringPadder) {
padder; // 타입이 'StringPadder'로 좁혀졌습니다.
}
코드가 길지만 핵심은 instanceof를 사용하는 부분입니다.
작성방식은variableName instanceof constructorName.
1. 타입이 any가 아닌 경우 함수의 prototype 프로퍼티 타입
2. 해당 타입의 생성자 시그니처에 의해 반환된 타입의 결합
위의 두 방식을 저는 이렇게 이해했습니다.
class Name{}
let name: any = new Name();
name.constructor.name === Name.name // true
//혹은
name.constructor.name | new Name().constructor.name // Name | Name이 됩니다.
// 그리고 Name 하나로 합쳐지게됩니다.
이 방식은 제가 이해한 방식일 뿐입니다. 매우 주관적이니 참고만 하시기 바랍니다🤷🤷
기본적으로 타입체커는 null과 undefined를 모든 항목에 할당 가능한 것으로 판단합니다.
타입스크립트에서는 --strictNullChecks라는 컴파일 옵션이 있는데, --noImplicitAny와 함께 필수로 써야할 옵션으로 꼽힙니다.
--strictNullChecks 옵션을 설정한 채로 null값이 들어갈 수 있는 타입을 지정하려면 유니온 타입을 설정해야합니다
let a: string | null; 이런식으로요!
--strictNullChecks를 선택적 매개 변수와 함께 쓰면 자동으로| undefined를 추가합니다:
function f(x: number, y?: number) {
return x + (y || 0);
}
f(1, 2);
f(1);
f(1, undefined);
f(1, null); // 오류, 'null'은 'number | undefined'에 할당할 수 없습니다
선택적 프로퍼티도 동일합니다.
class C {
a: number;
b?: number;
}
let c = new C();
c.a = 12;
c.a = undefined; // 오류, 'undefined'를 'number'에 할당 할 수 없습니다
c.b = 13;
c.b = undefined; // ok
c.b = null; // 오류, 'null'은 'number | undefined'에 할당할 수 없습니다
Nullable 타입은 유니온으로 구현되기 때문에 타입 가드를 사용하여 null을 제거해야합니다.
function f(sn: string | null): string {
// case 1
if (sn == null) {
return "default";
}
else {
return sn;
}
// case 2
return sn || "default";
}
컴파일러가 null 또는 undefined를 제거할 수 없는 경우에는 타입 단언 연산자를 사용하여 수동으로 제거해야합니다.
연산자는 후위 !입니다. v!은 v의 null과 undefined를 제거합니다.
function broken(name: string | null): string {
function postfix(epithet: string) {
// 그러나 name이 null일 수 있다는 오류가 나옵니다.
return name.charAt(0) + '. the ' + epithet; // 오류, 'name'이 null일 수 있습니다.
}
// name이 null이어도 여기서 "Bob"으로 바뀝니다.
name = name || "Bob";
return postfix("great");
}
// 아래와 같이 느낌표를 넣어주면 됩니다.
function fixed(name: string | null): string {
function postfix(epithet: string) {
return name!.charAt(0) + '. the ' + epithet; // 좋아요
}
name = name || "Bob";
return postfix("great");
}
컴파일러가 name이 null일 수 있다고 판단하는 이유는, 외부 함수에서 호출한 경우 중첩된 함수에 대한 모든 호출을 추적하는 것이 불가능하기 때문입니다(즉시실행함수-IIFE의 경우 가능).
IIFE를 적용해서 이렇게 코드를 작성할 수도 있습니다.
function broken(name: string | null): string {
name = name || "Bob";
return (function postfix(epithet: string) {
// 즉시실행함수이기때문에 name이 null이 아니라는 것을 알고있음
return name.charAt(0) + '. the ' + epithet;
})("great")
}
타입 별칭은 타입의 새로운 이름을 생성합니다.
그냥 타입에 다른 이름을 달아준다고 생각하면 될 거 같아요!
type Name = string;
type NameResolver = () => string;
type NameOrResolver = Name | NameResolver;
function getName(n: NameOrResolver): Name {
if (typeof n === "string") {
return n;
}
else {
return n();
}
}
실제로 새로운 이름을 생성하는 것이아니라 새로운 이름만 만들어 주는 것입니다.
인터페이스와 마찬가지로, 타입 별칭도 제네릭이 될 수 있습니다.
type Container<T> = { value: T };
타입 별칭을 스스로 참조할 수도 있습니다.
type Tree<T> = {
value: T;
left: Tree<T>;
right: Tree<T>;
}
교차타입과 함께 꽤 쓸모있는 타입을 만들 수도 있습니다.
type LinkedList<T> = T & { next: LinkedList<T> };
interface Person {
name: string;
}
var people: LinkedList<Person>;
var s = people.name;
var s = people.next.name;
var s = people.next.next.name;
var s = people.next.next.next.name;
그러나 타입 별칭이 표현식에 다시 들어가는 것은 불가능합니다❌
type Yikes = Array<Yikes>; // Error
인터페이스를 이미 공부하신 분들이라면, 이거 인터페이스와 비슷한데? 라는 생각을 하셨을지도 모르겠습니다!
이 비슷한 두 친구는 거의 비슷하지만 몇가지 다른 점을 가졌습니다.
일단, 아래코드를 에디터에 옮겨주세요.
type Alias = { num: number }
interface Interface {
num: number;
}
declare function aliased(arg: Alias): Alias;
declare function interfaced(arg: Interface): Interface;
이렇게 같은 방식으로 사용할 수 있지만, 마우스 커서를 Alias와 Interface에 올려보시면 다른 점이 보이실 겁니다. 하나는 type Alias = { num: number }가 나오고,
하나는 interface Interface가 나올 겁니다.
인터페이스는 어디서나 사용되는 새로운 이름을 만들기 때문인데요, 이런 이유로 만약에 타입별칭에서 오류가 생기면 오류메시지에는 타입별칭이 사용되지 않을 것입니다.
또한, 가장 중요한 차이점으로 꼽을 수 있는 확장성 부분에서 차이가 큰데요,
객체지향에서 자주 사용되는 확장(extends), 구현(implements)등이 안된다는 겁니다.
스스로 확장,구현을 할 수도 없고 다른타입에서도 확장, 구현이 불가능합니다
type B = { num: number };
interface C { num: number};
interface A extends B {}; // Error
interface A extends C {}; // OK
type B extends C {}; // Error
type B implements C = {}; //Error
이런 점 때문에, 무조건 interface를 쓰는 것이 낫다는 의견도 있습니다.
하지만, 튜플 혹은 유니온 타입의 경우에는 aliases가 유용할 때도 있습니다.
상황에 맞게 사용해주시면 되겠습니다!
type tuple = [number, string];
type yesOrNo = 'yes' | 'no';
문자열 리터럴 타입은, "문자열을 이용한 타입" 정도로 설명할 수 있습니다.
type Easing = "ease-in" | "ease-out" | "ease-in-out";
let a: Easing = "uneasy" // Error
바로 위에서 나온 yesOrNo의 예제랑 똑같이 생겼죠?
메소드 오버로드를 구현하기 위해 사용할 수도 있습니다.
function createElement(tagName: "img"): HTMLImageElement;
function createElement(tagName: "input"): HTMLInputElement;
// ... 더 많은 오버로드 ...
function createElement(tagName: string): Element {
// ... 코드는 여기에 있습니다 ...
}
문자열 리터럴과 같습니다. 더 설명할 부분이 없네요🤷
function rollDie(): 1 | 2 | 3 | 4 | 5 | 6 {
// ...
}
열거형 멤버는 열거형이 선언되는 순간 하나의 타입이 됩니다.

싱글톤 타입, 유니온 타입, 타입 가드 및 타입 별칭을 결합하여 식별 유니온이라는 패턴을 만들 수 있습니다.
구성 요소는 세가지입니다.
1. 공통적이고, 싱글톤 타입 프로퍼티를 가지는 타입 - discriminant
2. 그 타입들의 유니온 타입 - the union
3. 공통적인 프로퍼티를 사용한 타입가드
interface Square {
kind: "square";
size: number;
}
interface Rectangle {
kind: "rectangle";
width: number;
height: number;
}
interface Circle {
kind: "circle";
radius: number;
}
첫번째 구성요소는 위의 kind 프로퍼티 처럼 공통적이고 각각 식별자로 이용할 수 있는 타입을 말합니다.
type Shape = Square | Rectangle | Circle;
두번째 구성요소는 그 타입들을 유니온해서 만든 하나의 타입입니다.
function area(s: Shape) {
switch (s.kind) {
case "square": return s.size * s.size;
case "rectangle": return s.height * s.width;
case "circle": return Math.PI * s.radius ** 2;
}
}
세번째 구성요소는 위의 두가지 요소를 이용하는 타입가드 입니다.
식별유니온은 하나의 타입이라기 보다는, 타입가드를 활용하는 하나의 디자인 패턴이라고 할 수 있겠습니다
위에서 타입가드를 사용해서 검사한 코드를 다시 한번 볼까요?
function area(s: Shape) {
switch (s.kind) {
case "square": return s.size * s.size;
case "rectangle": return s.height * s.width;
case "circle": return Math.PI * s.radius ** 2;
}
}
자 여기서 새로운 타입이 추가되었다고 가정해보겠습니다🤔🤔
type Shape = Square | Rectangle | Circle | Triangle;
그렇다면 우리는 area 함수도 바꿔주어야 할 것입니다.
두가지 방법이 있습니다.
1. --strictNullChecks 컴파일 옵션을 설정하고, area함수에 반환타입을 추가하는 것.
2. 컴파일러가 철저하게 검사하게 하기위해 never 타입을 사용하는 것.
일단 첫 번째 방법부터 볼까요?
function area(s: Shape): number { // error: returns number | undefined
switch (s.kind) {
case "square": return s.size * s.size;
case "rectangle": return s.height * s.width;
case "circle": return Math.PI * s.radius ** 2;
}
}
이렇게 작성하면 number 부분에서 오류가 발생할 것입니다. 함수가 number | undefined라는 문구와 함께 말이죠
이 방법은 유니온 타입가드가 더 이상 모든 케이스를 커버할 수 없다는 것을 TypeScript가 알기 때문에 검사가 가능해지는 것입니다. 그러나 strictNullChecks가 오래된 코드에서 항상 작동하는 것은 아니라는 점을 주의해야합니다!.
두 번째 방법은 never타입을 이용한 방법입니다.
function assertNever(x: never): never {
throw new Error("Unexpected object: " + x);
}
function area(s: Shape) {
switch (s.kind) {
case "square": return s.size * s.size;
case "rectangle": return s.height * s.width;
case "circle": return Math.PI * s.radius ** 2;
default: return assertNever(s); // 누락된 경우 여기에 오류가 발생합니다
}
}
이렇게 작성하면 case가 모든 경우를 커버하지 못하면, 오류가 발생하기 때문에 철저하게 검사를 할 수 있습니다.
this 타입(Polymorphic this types)풀어서 얘기하자면, 다양한 모습을 가지는 this 타입 정도가 되겠네요!
이미 JavaScript에서 this 키워드에 대해서 알고 오셨을거라 생각합니다.
JavaScript에서 보통 현재 컨텍스트(문맥 - 코드 흐름에서의 위치) 정도로 알고있을텐데요,
class 에서 this는 여타 객체지향언어들의 this와 같은 개념을 동작합니다.
생성자를 통해 만들어진 현재 인스턴스를 가리킵니다.
class BasicCalculator {
public constructor(protected value: number = 0) { }
public currentValue(): number {
return this.value;
}
public add(operand: number): this {
this.value += operand;
return this;
}
public multiply(operand: number): this {
this.value *= operand;
return this;
}
// ... 다른 연산은 여기에 있습니다 ...
}
class ScientificCalculator extends BasicCalculator {
public constructor(value = 0) {
super(value);
}
public sin() {
this.value = Math.sin(this.value);
return this;
}
// ... 다른 연산은 여기에 있습니다 ...
}
let v1 = new BasicCalculator(2)
.multiply(5)
.add(1)
.currentValue();
let v2 = new ScientificCalculator(2)
.multiply(5)
.sin()
.add(1)
.currentValue();
에디터에서 v1의.multiply(5) 부분에 마우스를 올려보면 다음과 같습니다.

.multiply() 메소드는 this를 리턴하는데 리턴 타입이 BasicCaculator이네요?
v2도 한번 볼까요?

리턴 타입이 ScientificCaclulator입니다. 이렇게 this가 달라지는 것을 볼 수 있습니다.
인덱스 타입을 사용하면 동적 프로퍼티 이름을 사용하는 코드를 컴파일러가 검사하도록 할 수 있습니다.
function pluck<T, K extends keyof T>(o: T, names: K[]): T[K][] {
return names.map(n => o[n]);
}
interface Person {
name: string;
age: number;
}
let person: Person = {
name: 'Jarid',
age: 35
};
let strings: string[] = pluck(person, ['name']);
컴파일러는 name이 실제로 Person의 프로퍼티인지 확인합니다. 이 코드에는 두 가지 새로운 연산자가 등장합니다.
1. 인덱스 타입 쿼리 연산자 - keyof T
2. 인덱스 접근 연산자 - T[K]
첫 번째, 인덱스 타입 쿼리 연산자는 타입 T에 대해 알려진 프로퍼티 이름들의 유니온 타입입니다.
예제를 보겠습니다.
interface Person {
name: string;
age: number;
sex: string;
}
let props: keyof Person;
일 때, 에디터에서 props에 마우스를 올려보면 뭐가 나올지 예상이 되십니까?
위의 설명대로라면 아마 "name" | "age" | "sex"가 될 것입니다.

예상한대로 동작하는 것을 볼 수 있습니다.
두 번째는 인덱스 접근 연산자 T[K]입니다.
위에 나온 예제를 보시면 pluck의 리턴 타입은 T[K]의 형태로 되어있는 걸 알 수 있습니다.
그렇다면 pluck<T, K extends keyof T>(o: T, name: K[]): T[K][]인 제네릭에서 pluck(person, ['name'])로 함수를 호출하면 어떤 결과가 될까요?
T는 person의 타입인 Person, K[]는 "name"[]을 가지게 됩니다.
더 나아가면 T[K]는 Person["name"]이 되겠네요, 위 예제에서 Person["name"]은 string타입이죠? 그러면 결과적으로 pluck의 리턴타입은 string[]이 되는 것입니다.
이렇게 키 값에 따라 string[]

혹은 number[]의 리턴타입을 가지게 됩니다.

잠깐✋
이렇게 사용할 때에는 타입 변수 K extends keyof T를 꼭 선언해주어야 합니다.
이 점에 유의해주세요❗️
문자열 인덱스 시그니처를 기억하고 계시나요
인덱서(인덱스를 나누는 키?)를 문자열로 고정하는 지정된 형태입니다.
interface Map<T> {
[key: string]: T; // [key: string]: type 이 문자열 인덱스 시그니처입니다.
}
위 코드는 키 값은 string타입이고 값의 타입은 T라는 의미인데요,
그럼 keyof Map<type>은 무조건 string타입을 가지게 되겠죠? 키의 타입은 무조건 string이니까요
let keys: keyof Map<number>; // string
let values: Map<number>['asdf']; // number
타입을 만들다보면 프로퍼티들이 선택적 프로퍼티이거나 혹은 readonly일 때가 있습니다.
모든 프로퍼티의 타입을 선택적으로 하거나 readonly로 만들 수 있습니다.
한가지로 전부 매핑하는 것입니다.
잠깐✋ 이 예제의 Readonly와 Partial은 이미 TypeScript에 내장되어있기때문에 그대로 사용하시면 name conflict가 일어납니다
Readonly1등으로 바꾸어 사용해주세요❗
type Readonly<T> = {
readonly [P in keyof T]: T[P];
}
type Partial<T> = {
[P in keyof T]?: T[P];
}
type PersonPartial = Partial<Person>;
type ReadonlyPerson = Readonly<Person>;
let person2: PersonPartial = { name: 'John', sex: "male" } // 모든 프로퍼티가 선택적, age 프로퍼티가 없지만 통과
let person3: ReadonlyPerson = { name: 'Henry', age: 32, sex: 'male' }
person3.age = 33;
person은 위에서 썼던 예제인 Person을 그대로 가져다 쓴 것입니다.
간단한 mapped type으로 동작 원리를 한번 볼까요❓
type Keys = 'option1' | 'option2';
type Flags = { [K in Keys]: boolean };
K는 Keys의 타입을 차례로 읽어옵니다. ex) option1, option2
for .. in구문의 동작과 유사하다고 생각하시면 될 것 같습니다.
매핑이 되면 아래 코드와 같게 됩니다.
type Flags = {
option1: boolean;
option2: boolean;
}
다음은 mapped types의 가장 일반적인 템플릿이며 대부분의 경우에 적합합니다
type Nullable<T> = { [P in keyof T]: T[P] | null }
type Partial<T> = { [P in keyof T]?: T[P] }
그리고 이 mapped type은 mapped 되기 전 원본의 프로퍼티 지정자(readonly, optional)등을 모두 복사합니다.
interface Person {
name: string;
readonly age: number;
sex: "male" | "female";
}
type Partial<T> = { [P in keyof T]?: T[P] }
let partialPerson: Partial<Person> = { name: "John", age: 33, sex: "male" }; // 모든 프로퍼티는 partial(선택적)이고 age는 선택적이며 readonly이다
위에서 프로퍼티를 매핑하는 법을 알았으니, 이제 언래핑도 해봐야겠죠❓
function unwrapReadonly<T>(t: Readonly<T>): T {
let result = {} as T;
for (const k in t) {
result[k] = t[k]
}
console.log(result)
return result;
}
let person2: PersonPartial = { name: 'John', age: 33 }
let person3: ReadonlyPerson = { name: 'Henry', age: 32, sex: 'male' }
let unwrappedPartial = unwrapReadonly(person2);
let unwrappedReadonly = unwrapReadonly(person3);
이렇게 작성했을 때, 우리는 리턴 타입이 Person이길 기대할겁니다.
그리고 인자의 타입이 Readonly<T>이니, 우리는 Partial<T>를 매개변수로 호출할 때 에러가 나길 기대하겠지만 에러없이 실행이 됩니다.
이 부분은 후에 문제가 될 소지가 있겠죠? 예측과 다르게 동작할 수 있다는 얘기입니다.
그럼 어떻게하면 좋을까요? 정답은 리턴타입을 변수에 명시해주는 것입니다.
let unwrappedPartial: Person = unwrapReadonly(person2); // Error
let unwrappedReadonly: Person = unwrapReadonly(person3); // OK

매개변수의 타입이 달랐기 때문에 리턴 타입 또한 보장받지 못하여 Person타입이 아니게 됩니다. 그래서 타입이 다르다는 오류를 출력하게 되죠.
타입을 명시하지않아도 타입스크립트는 동작할 수 있지만, 독이 될 수 있으므로 항상 타입을 명시해주는 습관을 들이는게 타입스크립트를 잘 사용하는 길입니다👍
조건부 타입은 현재(2020.01.08) 핸드북 번역본에 들어가 있지 않은 단락입니다.
원문에서 발견하여 추가로 작성합니다.
TypeScript 2.8 버전에서 소개되었습니다.
번역본은 아니고, 원문의 내용과 검색으로 알게 된 내용을 믹스해서 작성하였습니다.
조건부 타입은 조건부 표현식에 따라 정해지는 타입니다.
여러분은 아래와 같은 표현식을 보신 적이 있으시겠죠?
a is true ? 'a' : 'b';
조건부 타입은 위 표현식을 활용한 타입입니다.
조건부 타입은 하나의 타입이라기보다, 조건을 만족하는 타입을 구하는 표현식이라고 보는게 옳을 것 같습니다
T extends U ? X : Y
위와 같은 형식으로 조건에 따른 타입을 지정할 수 있습니다.
위는 T가 U에 할당 가능하면 X, 아니면 Y 타입을 리턴한다는 의미입니다.
Type aliases와 제네릭을 이용한 간단한 예제입니다.
type TypeName<T> =
T extends string ? "string" :
T extends number ? "number" :
T extends boolean ? "boolean" :
T extends undefined ? "undefined" :
T extends Function ? "function" :
"object";
type T0 = TypeName<string>; // "string"
type T1 = TypeName<"a">; // "string"
type T2 = TypeName<true>; // "boolean"
type T3 = TypeName<() => void>; // "function"
type T4 = TypeName<string[]>; // "object"
조건부 타입 T extends U ? X : Y는 X | Y타입으로 해석되거나 혹은 해석이 유예됩니다(타입에 의존적이기 때문입니다.)
T 혹은 U가 타입변수를 포함하면, X | Y로의 타입해석 혹은 타입 해석 유예는 타입시스템이 "T가 U에 할당가능한가"에 대한 충분한 정보를 가졌는지 아닌지에 따라 정해집니다.
예시를 보면서 비교해보시죠.
declare function f<T extends boolean>(x: T): T extends true ? string : number;
// x는 'string | number' 타입을 가집니다
let x = f(Math.random() < 0.5)
위 경우는 이미 리턴 타입이 string | boolean으로 해석되는 경우입니다.
다음은 타입 해석이 연기되는 경우입니다.
interface Foo {
propA: boolean;
propB: boolean;
}
declare function f<T>(x: T): T extends Foo ? string : number;
function foo<U>(x: U) {
// a는 'U extends Foo ? string : number' 타입을 가집니다
// U의 타입을 알 수 없기 때문에 타입 해석이 연기됩니다.
let a = f(x);
// 그래도 b에 a를 할당하는 것은 가능합니다.
// 결과적으로 'string | number'가 될 것은 확실하기 때문입니다.
let b: string | number = a;
}
검사가 이루어진 이후의 날 것의 타입인 조건부 타입을 분배 조건부 타입이라고 합니다.
분배 조건부 타입은 인스턴스화하는 동안 유니온 타입으로 분배됩니다.
예를 들어, T extends U ? X : Y이고 T의 타입 매개변수가 A | B | C라면,
이렇게 해석됩니다.
(A extends U ? X : Y) | (B extends U ? X : Y) | (C extends U ? X : Y)
다음은 분배 조건부 타입이 해석되는 과정을 담은 예제입니다.
type EmailAddress = string | string[] | null | undefined;
type NonNullable1<T> = T extends null | undefined ? never : T;
type NonNullableAddress = NonNullable1<EmailAddress>
// 아래와 같이 분배됩니다.
type NonNullableEmailAddress = NonNullable<
| string
| string[]
| null
| undefined
>;
// next
type NonNullableEmailAddress =
| NonNullable<string>
| NonNullable<string[]>
| NonNullable<null>
| NonNullable<undefined>;
// next
type NonNullableEmailAddress =
| (string extends null | undefined ? never : string)
| (string[] extends null | undefined ? never : string[])
| (null extends null | undefined ? never : null)
| (undefined extends null | undefined ? never : undefined);
// next
type NonNullableEmailAddress =
| string
| string[]
| never
| never;
// next
type NonNullableEmailAddress = string | string[];
조건부 타입 T extends U ? X : Y의 인스턴스화 과정에서, 조건부 타입 내부의 T에 대한 참조는 유니온 타입의 각각의 요소로 해석됩니다. 그리고 X절(조건이 참인 경우) 에서 T는 U에 할당가능한 것으로 계산됩니다.
type BoxedValue<T> = { value: T };
type BoxedArray<T> = { array: T[] };
type Boxed<T> = T extends any[] ? BoxedArray<T[number]> : BoxedValue<T>;
// 조건이 참일 때, BoxedArray<T[number]>이 가능한 이유
// 이미 T extends any[] ? 라는 조건을 통과한 상태이기 때문에 T는 무조건 배열.
type T20 = Boxed<string>; // BoxedValue<string>;
type T21 = Boxed<number[]>; // BoxedArray<number>;
type T22 = Boxed<string | number[]>; // BoxedValue<string> | BoxedArray<number>;
그러던중 재밌는 것을 발견했는데 type [][number] === type never라는 사실입니다,
interface stringIndex {
[index: number]: any
}
let ccc: stringIndex[numbe] // any
let ddd: [][number] // never
let eee: ['1'][number] // '1'
[]을 number로 인덱싱하면 아무값도 얻을 수 없기 때문에 never인 것으로 이해했습니다.
결과로 보아 타입으로 해석할때 []['number']은 빈배열([])을 [number]로 인덱싱 하였을 때 얻을 수 있는 타입이 되는 것이라고 유추하였고,
['1', '2']을 number로 인덱싱한 결과가 리턴타입이 '1' | '2'이 되어서 검증되었다고 결론지었습니다.
위에서 배운 매핑타입을 조건부 타입과 함께 사용해보겠습니다.
NonNullable이라는 타입을 분배 조건부 타입에서 예제를 통해 보셨었죠?
모든 프로퍼티를 매핑해주는 매핑타입을 조건부 타입과 함께 이용하면, 조건을 만족하는 프로퍼티들로 추려낼 수 있습니다.👍
이 또한 어떠한 과정을 통해 적용되는지 함께 보실까요❓
type NonNullablePropertyKeys<T> = {
[P in keyof T]: null extends T[P] ? never : P
}[keyof T];
type User = {
name: string;
email: string | null;
};
// next
type NonNullableUserPropertyKeys = NonNullablePropertyKeys<User>;
// next
type NonNullableUserPropertyKeys = {
[P in keyof User]: null extends User[P] ? never : P
}[keyof User];
// next
type NonNullableUserPropertyKeys = {
[P in "name" | "email"]: null extends User[P] ? never : P
}[keyof User];
// next
type NonNullableUserPropertyKeys = {
name: null extends User["name"] ? never : "name";
email: null extends User["email"] ? never : "email";
}[keyof User];
// next
type NonNullableUserPropertyKeys = {
name: null extends string ? never : "name";
email: null extends string | null ? never : "email";
}[keyof User];
// next
type NonNullableUserPropertyKeys = {
name: "name";
email: never;
}[keyof User];
// next
type NonNullableUserPropertyKeys = {
name: "name";
email: never;
}["name" | "email"];
// next
type NonNullableUserPropertyKeys =
| { name: "name"; email: never }["name"]
| { name: "name"; email: never }["email"];
// next
type NonNullableUserPropertyKeys =
| "name"
| never;
// next
type NonNullableUserPropertyKeys = "name";
또한, 유니온 타입이나 교차 타입과 같이 재귀적으로 본인을 참조하는 것이 허용되지 않습니다.
type ElementType<T> = T extends any[] ? ElementType<T[number]> : T; // Error
조건부 타입에서는 extends절에서 infer키워드를 사용 가능합니다.
infer키워드는 타입변수를 참조하기 위해서 사용됩니다.
같은 타입변수에 다수의 infer 키워드를 사용할 수도 있습니다.
직관적인 구조이기 때문에 이해가 어렵지 않습니다.
기본적인 형태입니다.
type ReturnType<T> = T extends (...args: any[]) => infer R ? R : any;
type First<T> =
T extends [infer U, ...unknown[]]
? U
: never;
type SomeTupleType = [string, number, boolean];
type FirstElementType = First<SomeTupleType>; // string
// ReturnType은 이미 존재하는 이름입니다 수정해서 사용해주세요
type ReturnType<T> =
T extends (...args: any[]) => infer R
? R
: any;
type A = ReturnType<() => string>; // string
type B = ReturnType<() => () => any[]>; // () => any[]
type C = ReturnType<typeof Math.random>; // number
type D = ReturnType<typeof Array.isArray>; // boolean
다음은 infer 키워드 여러개를 이용하는 예제입니다.
type Unpacked<T> =
T extends (infer U)[] ? U :
T extends (...args: any[]) => infer U ? U :
T extends Promise<infer U> ? U :
T;
type T0 = Unpacked<string>; // string
type T1 = Unpacked<string[]>; // string
type T2 = Unpacked<() => string>; // string
type T3 = Unpacked<Promise<string>>; // string
type T4 = Unpacked<Promise<string>[]>; // Promise<string>
type T5 = Unpacked<Unpacked<Promise<string>[]>>; // string
다음 예제는 공변적 포지션에 있는 같은 타입 변수의 여러 후보(infer로 참조 된)이 어떻게 유니온 타입이 되는지 보여줍니다.
type Foo<T> = T extends { a: infer U, b: infer U } ? U : never;
type T10 = Foo<{ a: string, b: string }>; // string
type T11 = Foo<{ a: string, b: number }>; // string | number
다음은, 반변적 포지션에 있는 같은 타입 변수의 여러 후보(infer로 참조 된)이 교차 타입이 되는 과정을 보여줍니다.
type Bar<T> = T extends { a: (x: infer U) => void, b: (x: infer U) => void } ? U : never;
type T20 = Bar<{ a: (x: string) => void, b: (x: string) => void }>; // string
type T21 = Bar<{ a: (x: string) => void, b: (x: number) => void }>; // string & number
공변성 & 반변성의 관한 포스팅은 이곳을 참조하였습니다.
공변적이라 함은
A <: B(A가 B의 SubType)일 때, 함수식(T -> A) <: (T -> B)같이
좌항과 우항의 위치가 유지되는 것을 의미한다.
반변적이라 함은
A <: B(A가 B의 SubType)일 때, 함수식(B -> T) <: (A -> T)같이
좌항과 우항의 위치가 바뀌는 것을 의미한다.
함수타입에서 반환타입은 공변적(co-variant)이고, 인자타입은 반변적(contra-variant)이다.
여러 여러 콜 시그니처(예를 들면 오버로드 된 함수의 타입)를 infer와 함께 사용할 때, 마지막으로 선언된 콜 시그니처로 부터 추론됩니다. 인자 타입 베이스의 오버로드 해석은 불가능합니다.
declare function foo(x: string): number;
declare function foo(x: number): string;
declare function foo(x: string | number): string | number;
type T30 = ReturnType<typeof foo>; // string | number
infer 키워드를 제약조건 절(extends ...)에서 사용하는 것은 불가능합니다.
type ReturnType<T extends (...args: any[]) => infer R> = R; // Error, not supported
그러나, 제약조건 절(extedns ...)에서 타입변수를 제거하고, 특정 조건부 타입으로 대체하는 것으로 거의 같은 효과를 낼 수 있습니다.
type AnyFunction = (...args: any[]) => any;
type ReturnType<T extends AnyFunction> = T extends (...args: any[]) => infer R ? R : any;
T로부터U에 할당 가능한 타입들을 제외한다.Extract<T, U>
T로부터U에 할당 가능한 타입들만 추출한다.NonNullable
T로부터null | undefined인 타입들을 제거한다.ReturunType
T라는 함수타입의 리턴 타입을 얻는다.InstanceType
T라는 생성자 함수의 인스턴스 타입을 얻는다.(== 어느 클래스인지)
이렇게 타입스크립트의 고급 타입 파트에 대해서 알아보았습니다!
포스팅이 이렇게 길어질 줄은 몰랐네요😢😢
다음 포스팅에서는 타입스크립트에서의 데코레이터에 대해서 알아보겠습니다
그럼, 다음포스팅에서 만나요 여러분👋👋
좋은 글 잘 읽었습니다.
현시점에서는 type도 extends, implements모두 사용 가능하도록 변경되었습니다.