✔️

09. Advanced Types

타입 가드와 다른 타입들 (Type Guards and Differentiating Types)

// pet: Fish | Bird
let pet = getSmallPet();

if ("swim" in pet) {
  pet.swim();
}
if (pet.fly) {
	// ..
}
🚨
Property 'fly' does not exist on type 'Fish | Bird'. Property 'fly' does not exist on type 'Fish'.
let pet = getSmallPet();
let fishPet = pet as Fish;
let birdPet = pet as Bird;

if (fishPet.swim) {
  fishPet.swim();
} else if (birdPet.fly) {
  birdPet.fly();
}

하지만 이런 스타일의 코드는 원하는 것이 아닐 것 (= in으로 체크하세요)

사용자 정의 타입 가드

타입 가드 = 어떤 스코프에서 타입을 보장하는 런타임 체크를 실행하는 몇 가지 표현식

타입 예측 사용하기

타입 가드를 정의하기 위해서 간단하게 타입에 대한 예측을 반환하는 함수를 정의하면 됨

function isFish(pet: Fish | Bird): pet is Fish {
  return (pet as Fish).swim !== undefined;
}
let pet = getSmallPet();

if (isFish(pet)) { // 이 단락에서 pet은 Fish 타입으로 좁혀짐
  pet.swim();
} else {
  pet.fly();
}
const zoo: (Fish | Bird)[] = [getSmallPet(), getSmallPet(), getSmallPet()];
const underWater1: Fish[] = zoo.filter(isFish);

// 또 다른 방법
const underWater2: Fish[] = zoo.filter<Fish>(isFish);
const underWater3: Fish[] = zoo.filter<Fish>((pet) => isFish(pet));

in 연산자 사용하기

in도 타입을 좁히기 위한 표현식으로 사용될 수 있음

function move(pet: Fish | Bird) {
  if ("swim" in pet) {
    return pet.swim();
  }
  return pet.fly();
}

typeof 타입 가드

유니온 타입을 사용하는 padLeft 예제로 돌아가서 다음과 같이 타입 예측을 할 수 있음

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}'.`);
}

그렇지만 primitive 타입을 확인하기 위해서 함수를 만드는 것은 번거로움

다행히도 typeof x === "string" 같은 코드를 타입 가드용 함수로 만들지 않아도 됨

→ Typescript가 자체적으로 해당 코드를 타입 가드로 해석함 (아래처럼 인라인으로 체크 가능)

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}'.`);
}

여기에서 === 뒤에 오는 typename은 typeof 연산자가 반환하는 값만 가능하고,

다른 값을 넣는 경우에는 타입 가드로 인식하지 않음

(undefined, number, string, boolean, bigint, symbol, object, function)

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("  ");
}

let padder: Padder = getRandomPadder();
//       ^ = let padder: Padder

if (padder instanceof SpaceRepeatingPadder) {
  padder;
//     ^ = let padder: SpaceRepeatingPadder
}
if (padder instanceof StringPadder) {
  padder;
//     ^ = let padder: StringPadder
}
  1. 타입이 any가 아닌 경우 함수의 prototype 프로퍼티 타입
  1. 그 타입의 생성자 Signatures 의해 리턴되는 타입의 Union 타입

이 순서로 타입을 좁혀감

Nullable 타입

let exampleString = "foo";
exampleString = null;
🚨
Type 'null' is not assignable to type 'string'.
let stringOrNull: string | null = "bar";
stringOrNull = null;

stringOrNull = undefined;
🚨
Type 'undefined' is not assignable to type 'string | null'.

Optional 파라미터와 프로퍼티

--stringNullChecks 옵션을 사용하면 옵셔널 파라미터에는 자동적으로 | undefined 가 붙음

function f(x: number, y?: number) {
  return x + (y ?? 0);
}

f(1, 2);
f(1);
f(1, undefined);
f(1, null);
🚨
Argument of type 'null' is not assignable to parameter of type 'number | undefined'.
class C {
  a: number;
  b?: number;
}

let c = new C();

c.a = 12;
c.a = undefined;
🚨
Type 'undefined' is not assignable to type 'number'.
c.b = 13;
c.b = undefined;
c.b = null;
🚨
Type 'null' is not assignable to type 'number | undefined'.

타입 가드와 타입 어설션(assertion)

Nullable 타입은 유니온으로 구현되어 있기 때문에, 타입 가드로 null을 제거해야 함

다행히 자바스크립트에서 사용하는 코드 그대로 Null 체크가 가능함

function f(stringOrNull: string | null): string {
  if (stringOrNull === null) {
    return "default";
  } else {
    return stringOrNull;
  }
}

null 제거를 아래와 같이 좀 더 간단하게 표현할 수도 있음

function f(stringOrNull: string | null): string {
  return stringOrNull ?? "default";
}

컴파일러가 nullundefined를 자동으로 제거할 수 없는 경우에는 타입 어설션으로 수동 제거 가능

뒤에 !를 붙여서 identifier!로 쓰면 nullundefined를 타입에서 제거 가능

interface UserAccount {
  id: number;
  email?: string;
}

const user = getUser("admin");
user.id;
🚨
Object is possibly 'undefined'.
if (user) {
  user.email.length;
}
🚨
Object is possibly 'undefined'.
user!.email!.length;

해당 객체나 필드가 있다는 것을 보장할 수 있다면 ! 체인을 통해서 nullability를 쉽게 제거 가능

타입 별칭(Alias)

타입 별칭(type A {})은 타입에 새로운 이름을 만들어줌

인터페이스와 어떤 측면에서는 비슷한데,

primitive, union, tuples, 그리고 다른 모든 타입에 이름을 붙일 수 있다는 장점이 있음

type Second = number;

let timeInSecond: number = 10;
let time: Second = 10;

인터페이스처럼 타입 별칭도 Generic하게 만들 수 있음

type Container<T> = { value: T };

타입 별칭을 타입 내에서 스스로 참조할 때도 사용할 수 있음

type Tree<T> = {
  value: T;
  left?: Tree<T>;
  right?: Tree<T>;
};

intersection 타입과 함께 꽤 맛이 간(mind-bending) 타입을 만들 수 있음 (러시아 인형..)

type LinkedList<Type> = Type & { next: LinkedList<Type> };

interface Person {
  name: string;
}

let people = getDriversLicenseQueue();
people.name;
people.next.name;
people.next.next.name;
people.next.next.next.name;
//                  ^ = (property) next: LinkedList

인터페이스 vs 타입 별칭

위에서 말한 것처럼 인터페이스와 타입 별칭은 유사한 점이 많지만 또 미묘하게 다름

https://stackoverflow.com/questions/37233735/typescript-interfaces-vs-types

Enum 멤버 타입

다형성(Polymorphic) 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;
  }
  // ... other operations go here ...
}

let v = new BasicCalculator(2).multiply(5).add(1).currentValue();

클래스에서 this를 사용하기 때문에, 확장할 수 있고 새 클래스는 변경 없이 메서드를 사용할 수 있음

class ScientificCalculator extends BasicCalculator {
  public constructor(value = 0) {
    super(value);
  }
  public sin() {
    this.value = Math.sin(this.value);
    return this;
  }
  // ... other operations go here ...
}

let v = new ScientificCalculator(2).multiply(5).sin().add(1).currentValue();

this가 없으면 ScientificCalculatorBasicCalculator를 확장할 수 없음

+ fluent 인터페이스를 유지할 수 없음

(fluent 인터페이스 간단 요약 : 메소드 체이닝에 상당 부분 기반한 객체 지향 API 설계 메소드)

Index 타입 - 타입을 동적으로 사용할 때

컴파일러에서 동적 프로퍼티 이름을 사용하는 코드를 확인하도록 할 수 있음

아래 코드는 일반적인 자바스크립트 패턴에서 객체의 프로퍼티를 확인하는 방법

function pluck(o, propertyNames) {
  return propertyNames.map((n) => o[n]);
}

타입스크립트에서는..

index type queryindexed access를 사용할 수 있음 → keyof, T[K]

function pluck<T, K extends keyof T>(o: T, propertyNames: K[]): T[K][] {
  return propertyNames.map((n) => o[n]);
}

interface Car {
  manufacturer: string;
  model: string;
  year: number;
}

let taxi: Car = {
  manufacturer: "Toyota",
  model: "Camry",
  year: 2014,
};

// Manufacturer와 model은 둘 다 string 타입
// 따라서 둘 다 string 배열을 만들기 위해서 pluck(= 뽑다) 가능함
let makeAndModel: string[] = pluck(taxi, ["manufacturer", "model"]);

// model과 year 속성을 pluck 하면
// union type 배열을 얻게 됨 (string | number)[]
let modelYear = pluck(taxi, ["model", "year"]);

컴파일러는 manufacturermodelCar의 프로퍼티인지를 확인

keyof : 인덱스 타입 쿼리 연산자, Props의 타입에 존재하는 type들을 union 타입으로 할당

let carProps: keyof Car;
//         ^ = let carProps: "manufacturer" | "model" | "year"

공식문서랑 주석을 다르게 썼는데, 본문 읽어보니까 이상한듯해서 바꿈요..

pluck(taxi, ["year", "unknown"]);
🚨
Type '"unknown"' is not assignable to type '"manufacturer" | "model" | "year"'

T[K] : indexed access operator, 인덱싱된 접근 연산자

function getProperty<T, K extends keyof T>(o: T, propertyName: K): T[K] {
  return o[propertyName]; // o[propertyName] is of type T[K]
}

위 예제의 o: TpropertyName: K에서 o[propertyName]: T[K] 를 의미함 (왓더..)

let manufacturer: string = getProperty(taxi, "manufacturer");
let year: number = getProperty(taxi, "year");

let unknown = getProperty(taxi, "unknown");

getProperty의 반환 결과물은 어떤 파라미터를 넘기느냐에 따라 달라짐

Index 타입과 Index 시그니처

keyofT[K]는 인덱스 시그니처와 상호작용함

interface Dictionary<T> {
  [key: string]: T;
}
let keys: keyof Dictionary<number>;
//     ^ = let keys: string | number
let value: Dictionary<number>["foo"];
//      ^ = let value: number

인덱스 시그니처 타입이 number이면 string | number 아니고 그냥 number

interface Dictionary<T> {
  [key: number]: T;
}

let keys: keyof Dictionary<number>;
//     ^ = let keys: number
let numberValue: Dictionary<number>[42];
//     ^ = let numberValue: number
let value: Dictionary<number>["foo"];
🚨
Property 'foo' does not exist on type 'Dictionary<number>'.

매핑된(Mapped) 타입

기존에 정의되어 있는 타입을 새로운 타입으로 변환해 주는 문법

(마치 자바스크립트 map() API 함수를 타입에 적용한 것과 같은 효과)

일반적인 작업은 기존 타입을 가져와서 각 프로퍼티를 옵셔널하게 만드는 것

interface PersonSubset {
  name?: string;
  age?: number;
}

또는 readonly로 만드는 것

interface PersonReadonly {
  readonly name: string;
  readonly age: number;
}

바로 이렇게

type Partial<T> = {
  [P in keyof T]?: T[P];
};

type Readonly<T> = {
  readonly [P in keyof T]: T[P];
};

type PersonPartial = Partial<Person>;
//   ^ = type PersonPartial = {
//       name?: string | undefined;
//       age?: number | undefined;
//   }
type ReadonlyPerson = Readonly<Person>;
//   ^ = type ReadonlyPerson = {
//       readonly name: string;
//       readonly age: number;
//   }

주목할 부분은 멤버에 대해서가 아니라 타입에 대해서 묘사하고 있다는 것

→ 기존 타입을 기반으로 새로운 멤버를 추가하고 싶은 것이라면 intersection 타입을 사용해야 함

// 이렇게 쓰기 (거의 뭐 암호 수준인데;)
type PartialWithNewMember<T> = {
  [P in keyof T]?: T[P];
} & { newMember: boolean }

// 에러 납니다
type WrongPartialWithNewMember<T> = {
  [P in keyof T]?: T[P];
  newMember: boolean;
}
🚨
'boolean' only refers to a type, but is being used as a value here.

간단한 예제로 다시 살펴봅시다

type Keys = "option1" | "option2";
type Flags = { [K in Keys]: boolean };

내부에 for ... in 이 있는 인덱스 시그니처 구문과 유사함

  1. 타입 변수 K는 차례대로 각 프로퍼티에 바인딩됩니다.
  1. 반복 처리할 프로퍼티의 이름이 들어있는 문자열 리터럴 Union Keys
  1. 프로퍼티의 결과 타입 = boolean

이 결과는 다음과 같음

type Flags = {
  option1: boolean;
  option2: boolean;
};

현실 세계의 예제에서는 이렇게 하드코딩(Keys)된 케이스보다 이미 존재하는 타입을 쓰는 경우가 더 많음

위에서 정리했던 keyof 와 indexed access types를 활용할 때..

type NullablePerson = { [P in keyof Person]: Person[P] | null };
//   ^ = type NullablePerson = {
//       name: string | null;
//       age: number | null;
//   }
type PartialPerson = { [P in keyof Person]?: Person[P] };
//   ^ = type PartialPerson = {
//       name?: string | undefined;
//       age?: number | undefined;
//   }

제너릭한 버전

type Nullable<T> = { [P in keyof T]: T[P] | null };
type Partial<T> = { [P in keyof T]?: T[P] };

매핑된 타입으로부터의 인터페이스 - unwrapping

function unproxify<T>(t: Proxify<T>): T {
  let result = {} as T;
  for (const k in t) {
    result[k] = t[k].get();
  }
  return result;
}

let originalProps = unproxify(proxyProps);
//  ^ = let originalProps: {
//      rooms: number;
//  }