Code Review (TypeScript)

https://slides.com/woongjae/lgcns-ts-4

Lead Software Engineer @ProtoPie

Microsoft MVP

TypeScript Korea User Group Organizer

Marktube (Youtube)

Mark Lee

이 웅재

0. Setup

1. Type Annotations

2. interface & class

3. Type Guards

4. Decorators

5. Generic & Conditional Types

0. Setup

git 을 이용해 Repository 를 clone 합니다.

git clone https://github.com/xid-mark/lgcns.git

해당 프로젝트 폴더로 이동합니다.

cd lgcns

Node.js 버전은 14.15.1 (LTS) 입니다.

nvm 을 사용하는 경우 아래와 같이 입력해주세요.

nvm use

TypeScript Compiler 설치를 위해 아래의 명령어를 실행합니다.

npm ci

프로젝트에서 사용하는 TypeScript 버전은 4.1.2 입니다.

아래 명령어로 버전 확인이 가능합니다.

npx tsc --version

TypeScript 컴파일러를 동작 시킵니다.

npm run build:watch

1. Type Annotations

Q.1 - 1

/*
아래 변수는 "Hello" 라는 문자열, 2020 이라는 숫자, true 라는 boolean 값을 가질 수 있습니다.
이 3 가지를 만족하는 가장 제한된 타입 A 를 만들어서 변수에 설정하세요.
*/

let a = "Hello";

a = 2020;

a = true;

A.1 - 1

type A = "Hello" | 2020 | true;

let a: A = "Hello";

a = 2020;

a = true;

Q.1 - 2

/*
아래 변수 b 를 모두 만족하는 타입 B 를 만들어서 변수에 설정하세요.
b 는 name 을 필수로 가집니다. name 은 문자열입니다.
b 는 name 이외의 모든 프로퍼티를 사용 가능합니다. 해당 프로퍼티는 숫자거나 문자열 입니다.
*/

let b = {
  name: "Mark",
};

b = {
  name: "Mark",
  age: 38,
};

b = {
  name: "Mark",
  country: "Korea",
};

string OR number

interface StringArray {
    [index: number]: string;
}

const sa: StringArray = {}; // 옵셔널하다
sa[100] = '백';

interface StringDictionary {
    [index: string]: string;
}

const sd: StringDictionary = {}; // 옵셔널하다
sd.hundred = '백';

interface StringArrayDictionary {
    [index: number]: string;
    [index: string]: string;
}

const sad: StringArrayDictionary = {};
// 당연히 옵셔널하다.
sad[100] = '백';
sad.hundred = '백';

string index = optional property

interface StringDictionary {
    [index: string]: string;
    name: string;
}

const sd: StringDictionary = {
    name: '이름' // 필수
};

sd.any = 'any'; // 어떤 프로퍼티도 가능

////////////////////////////////////////////////

interface StringDictionaryNo {
    [index: string]: string;
    // name: number; // (X) 인덱서블 타입이 string 값을 가지기 때문에 number 를 필수로 끌어오면 에러
}

A.1 - 2

interface B {
  name: string;
  [index: string]: number | string;
}

let b: B = {
  name: "Mark",
};

b = {
  name: "Mark",
  age: 38,
};

b = {
  name: "Mark",
  country: "Korea",
};

Q.1 - 3

/*
아래 함수 c 는 2가지 형태로 사용이 가능합니다.
첫번째는 문자열을 받아 숫자를 반환하는 것이고,
두번째는 숫자와 숫자 배열을 받아 숫자를 반환하는 것입니다.
c1 과 c2 로 구현했습니다.
c 라는 함수가 두가지 함수를 사용 가능하도록 구현하세요. (힌트 : 오버로딩)
*/

function c1(str: string): number {
  return str.length;
}

function c2(num: number, numArr: number[]): number {
  return num + numArr.reduce((acc, cur) => acc + cur, 0);
}

오버로딩이 불가능한 자바스크립트에 타입을 붙이는 경우

function shuffle(value: string | any[]): string | any[] {
  if (typeof value === 'string')
    return value
      .split('')
      .sort(() => Math.random() - 0.5)
      .join('');
  return value.sort(() => Math.random() - 0.5);
}

console.log(shuffle('Hello, Mark!')); // string | any[]
console.log(shuffle(['Hello', 'Mark', 'long', 'time', 'no', 'see'])); // string | any[]
console.log(shuffle([1, 2, 3, 4, 5])); // string | any[]

제네릭을 떠올리자! => conditional type 활용

function shuffle2<T extends string | any[]>(
  value: T,
): T extends string ? string : T;
function shuffle2(value: any) {
  if (typeof value === 'string')
    return value
      .split('')
      .sort(() => Math.random() - 0.5)
      .join('');
  return value.sort(() => Math.random() - 0.5);
}

// function shuffle2<"Hello, Mark!">(value: "Hello, Mark!"): string
shuffle2('Hello, Mark!');

// function shuffle2<string[]>(value: string[]): string[]
shuffle2(['Hello', 'Mark', 'long', 'time', 'no', 'see']);

// function shuffle2<number[]>(value: number[]): number[]
shuffle2([1, 2, 3, 4, 5]);

// error! Argument of type 'number' is not assignable to parameter of type 'string | any[]'.
shuffle2(1);

오버로딩을 활용하면?

function shuffle3(value: string): string;
function shuffle3<T>(value: T[]): T[];
function shuffle3(value: string | any[]): string | any[] {
  if (typeof value === 'string')
    return value
      .split('')
      .sort(() => Math.random() - 0.5)
      .join('');
  return value.sort(() => Math.random() - 0.5);
}

shuffle3('Hello, Mark!');
shuffle3(['Hello', 'Mark', 'long', 'time', 'no', 'see']);
shuffle3([1, 2, 3, 4, 5]);

c1 과 c2 함수의 타입을 합치기

function c1(str: string): number {
  return str.length;
}

function c2(num: number, numArr: number[]): number {
  return num + numArr.reduce((acc, cur) => acc + cur, 0);
}

function c(str: string): number;
function c(num: number, numArr: number[]): number;
function c(strOrNum: string | number, numArr?: number[]): number {...}

c 의 내부 구현

function c(strOrNum: string | number, numArr?: number[]): number {
  if (typeof strOrNum === "string") {
    return c1(strOrNum);
  } else {
    return c2(strOrNum, numArr!);
  }
}

A.1 - 3

function c(str: string): number;
function c(num: number, numArr: number[]): number;
function c(strOrNum: string | number, numArr?: number[]): number {
  if (typeof strOrNum === "string") {
    return strOrNum.length;
  } else {
    return strOrNum + numArr!.reduce((acc, cur) => acc + cur, 0);
  }
}

2. Interface & Class

Q.2

/*
아래 조건을 만족하는 ICar 인터페이스와 Volvo 클래스를 작성하세요.
- 해당 조건을 제외하고는 사용할 수 없도록 컴파일 에러를 발생시키는 것에 주의해야 합니다.
- 아래 예시 중 주석 부분은 로그로 출력되는 내용입니다. 일치하도록 로직을 작성하세요.
- 아래 예시 중 [컴파일 에러!!] 가 나는 경우는 해당 동작을 할 수 없도록 해야합니다.
- 필요한 경우, 추가로 타입을 만들어도 좋습니다.
*/

interface ICar {}

class Volvo implements ICar {}

const myCar: ICar = new Volvo("suv");

console.log(myCar.type, myCar.isStarted); // 'suv' false
myCar.open("lf"); // '좌측 앞쪽 문이 열린다.'
console.log(myCar.doorOpenState); // { lf: true, rf: false, lb: false, rb: false }
myCar.doorOpenState.lf = false; // [컴파일 에러!!]
myCar.closeAllDoors();
console.log(myCar.doorOpenState); // { lf: false, rf: false, lb: false, rb: false }
myCar.start(); // '차가 출발한다.'
console.log(myCar.isStarted); // true

A.2

interface ICar {
  type: string;
  readonly isStarted: boolean;
  start(): void;
  open(where: "lf" | "rf" | "lb" | "rb"): void;
  readonly doorOpenState: DoorsOpenState;
  closeAllDoors(): void;
}

A.2

interface DoorsOpenState {
  readonly lf: boolean;
  readonly rf: boolean;
  readonly lb: boolean;
  readonly rb: boolean;
}

interface ICar {
  type: string;
  readonly isStarted: boolean;
  start(): void;
  open(where: keyof DoorsOpenState): void;
  readonly doorOpenState: DoorsOpenState;
  closeAllDoors(): void;
}

A.2

class Volvo implements ICar {
  private _isStarted = false;
  private _doorsOpenState = {
    lf: false,
    rf: false,
    lb: false,
    rb: false,
  };
  constructor(public type: string) {}
  public start(): void {
    this._isStarted = true;
    console.log("차가 출발한다.");
  }
  public open(where: keyof DoorsOpenState): void {
    console.log("좌측 앞쪽 문이 열린다.");
  }
  get doorOpenState() {
    return this._doorsOpenState;
  }
  get isStarted() {
    return this._isStarted;
  }
  public closeAllDoors(): void {
    this._doorsOpenState = {
      lf: false,
      rf: false,
      lb: false,
      rb: false,
    };
  }
}

3. Type Guards

/*
Toast 라는 타입은 모두 type 이라는 프로퍼티를 가지고 type 은 다음과 같은 것으로 분류할 수 있다.
"AFTER_SAVED" | "AFTER_PUBLISHED" | "AFTER_RESTORE"
*/

enum ToastType {
  AFTER_SAVED,
  AFTER_PUBLISHED,
  AFTER_RESTORE,
}

interface Toast {
  type: ToastType;
  createdAt: string;
}

const toasts: Toast[] = [];

/*
toasts 안에 여러개의 toast 가 있습니다.
toasts 를 인자로 받아서 각각의 toast 를 문자열로 변환하는 함수를 작성합니다.

각각의 toast 를 문자열로 바꿔 문자열 배열을 리턴하는 것으로 가정하고, 아래의 로직을 작성하세요.

- toasts 를 돌며 toast 를 꺼내서 toast 의 타입이 AFTER_SAVED 인 경우,
"저장 된 직후에 뜬다!!" 라고 문자열을 만들어냅니다.
- toasts 를 돌며 toast 를 꺼내서 toast 의 타입이 AFTER_PUBLISHED 인 경우,
"퍼블리시가 된 직후에 뜬다!!" 라고 문자열을 만들어냅니다.
- toasts 를 돌며 toast 를 꺼내서 toast 의 타입이 AFTER_RESTORE 인 경우,
"복원 된 직후에 뜬다!!" 라고 문자열을 만들어냅니다.

해당 함수를 안전하게 완성해주세요 (ToastType 이 추가된 경우에도 안전하도록 작성해보세요.)
*/

function convert(toasts) {}

Q.3

Toast 는 모두 type 을 가지고 type 은 enum 중 하나를 가진다.

enum ToastType {
    AFTER_SAVED,
    AFTER_PUBLISHED,
    AFTER_RESTORE,
}

interface Toast {
    type: ToastType,
    createdAt: string,
}

const toasts: Toast[] = [...];

if 와 else if 로 이루어진 잘못된 추론

// toastNodes1 -> (JSX.Element | undefined)[]
const toastNodes1 = toasts.map((toast) => {
  if (toast.type === ToastType.AFTER_SAVED)
    return (
      <div key={toast.createdAt}>
        <AfterSavedToast />
      </div>
    );
  else if (toast.type === ToastType.AFTER_PUBLISHED)
    return (
      <div key={toast.createdAt}>
        <AfterPublishedToast />
      </div>
    );
  else if (toast.type === ToastType.AFTER_RESTORE)
    return (
      <div key={toast.createdAt}>
        <AfterRestoredToast />
      </div>
    );
});

if, else if, else 로 작동하는 추론

// toastNodes2 -> JSX.Element[]
const toastNodes2 = toasts.map((toast) => {
  if (toast.type === ToastType.AFTER_SAVED)
    return (
      <div key={toast.createdAt}>
        <AfterSavedToast />
      </div>
    );
  else if (toast.type === ToastType.AFTER_PUBLISHED)
    return (
      <div key={toast.createdAt}>
        <AfterPublishedToast />
      </div>
    );
  else
    return (
      <div key={toast.createdAt}>
        <AfterRestoredToast />
      </div>
    );
});

마지막 else 에 never 를 검사함

// toastNodes3 -> JSX.Element[]
const toastNodes3 = toasts.map((toast) => {
  if (toast.type === ToastType.AFTER_SAVED)
    return (
      <div key={toast.createdAt}>
        <AfterSavedToast />
      </div>
    );
  else if (toast.type === ToastType.AFTER_PUBLISHED)
    return (
      <div key={toast.createdAt}>
        <AfterPublishedToast />
      </div>
    );
  else if (toast.type === ToastType.AFTER_RESTORE)
    return (
      <div key={toast.createdAt}>
        <AfterRestoredToast />
      </div>
    );
  else return neverExpected(toast.typs);
});

function neverExpected(value: never): never {
  throw new Error(`Unexpected value : ${value}`);
}

if + return 과 마지막에 never 를 검사함

// toastNodes4 -> JSX.Element[]
const toastNodes4 = toasts.map((toast) => {
  if (toast.type === ToastType.AFTER_SAVED)
    return (
      <div key={toast.createdAt}>
        <AfterSavedToast />
      </div>
    );
  if (toast.type === ToastType.AFTER_PUBLISHED)
    return (
      <div key={toast.createdAt}>
        <AfterPublishedToast />
      </div>
    );
  if (toast.type === ToastType.AFTER_RESTORE)
    return (
      <div key={toast.createdAt}>
        <AfterRestoredToast />
      </div>
    );

  return neverExpected(toast.typs);
});

switch 와 default never 를 통한 처리

const toastNodes5 = toasts.map((toast) => {
  switch (toast.type) {
    case ToastType.AFTER_SAVED:
      return (
        <div key={toast.createdAt}>
          <AfterSavedToast />
        </div>
      );
    case ToastType.AFTER_PUBLISHED:
      return (
        <div key={toast.createdAt}>
          <AfterPublishedToast />
        </div>
      );
    case ToastType.AFTER_RESTORE:
      return (
        <div key={toast.createdAt}>
          <AfterRestoredToast />
        </div>
      );
    default:
      return neverExpected(toast.type);
  }
});

Q.3

function convert(toasts: Toast[]) {
  return toasts.map((toast) => {
    switch (toast.type) {
      case ToastType.AFTER_SAVED:
        return "저장 된 직후에 뜬다!!";
      case ToastType.AFTER_PUBLISHED:
        return "퍼블리시가 된 직후에 뜬다!!";
      case ToastType.AFTER_RESTORE:
        return "복원 된 직후에 뜬다!!";
      default:
        return neverExpected(toast.type);
    }
  });
}

function neverExpected(value: never): never {
  throw new Error(`Unexpected value : ${value}`);
}

4. Decorators

Q.4

/*
class 의 method 에 decorator 를 사용해서 해당 class 의 this 를 자동으로 바인딩할 수 있습니다.

아래의 Timer 는 new Timer().start(); 를 이용해서 실행하면, NaN 이 출력됩니다.
_loop 메서드의 위에 @autobind 를 달아 정상적으로 출력되도록 autobind 함수를 작성하세요.
*/

function autobind() {}

class Timer {
  private _count = 0;
  private _timer: number | null = null;

  public start() {
    this._timer = setInterval(this._loop, 1000);
  }

  public stop() {
    if (this._timer !== null) {
      clearInterval(this._timer);
      this._count = 0;
    }
  }

  // @autobind
  private _loop() {
    console.log(this._count++);
  }
}

new Timer().start();

Method Decorator - 기본 구조

function methodDecorator(
	target: any,
	propertyKey: string,
	descriptor: PropertyDescriptor
) {...}

function methodDecoratorFactory(...) {
  return function(
    target: any,
    propertyKey: string,
    descriptor: PropertyDescriptor
  ) {...};
}

target, propertyKey, descriptor

function methodDecorator(
  target: any,
  propertyKey: string,
  descriptor: PropertyDescriptor
) {
  console.log('target', target);
  console.log('propertyKey', propertyKey);
  console.log('descriptor', descriptor);
}

class Test3 {
  @methodDecorator
  public print() {}
}

// target Test3 { print: [Function] }
// propertyKey print
// descriptor { value: [Function], writable: true, enumerable: true, configurable: true }

PropertyDescriptor 조작

configurable

  • 이 속성기술자는 해당 객체로부터 그 속성을 제거할 수 있는지를 기술한다.

  • true 라면 삭제할 수 있다. 기본값은 false.

enumerable

  • 해당 객체의 키가 열거 가능한지를 기술한다.

  • true 라면 열거가능하다. 기본값은 false.

value

  • 속성에 해당되는 값으로 오직 적합한 자바스크립트 값 (number, object, function, etc) 만 올 수 있다.
    기본값은 undefined.

writable

  • writable이 true로 설정되면 할당연산자 assignment operator 를 통해 값을 바꿀 수 있다.
    기본값은 false.

Method Decorator Example

function log(show: boolean = true) {
  return function(
    target: any,
    propertyKey: string,
    descriptor: PropertyDescriptor
  ) {
    const original = descriptor.value;
    descriptor.value = function(...args: any[]) {
      show && console.log('start');
      original(...args);
      show && console.log('end');
    };
  };
}

class Test5 {
  @log()
  print1(first: string, second: number) {
    console.log('print1', first, second);
  }

  @log(false)
  print2(first: string, second: number) {
    console.log('print2', first, second);
  }
}

const test5 = new Test5();
test5.print1('mark', 36);
test5.print2('mark', 36);

A.4

class Timer {
  private _count = 0;
  private _timer: number | null = null;

  public start() {
    this._timer = setInterval(this._loop, 1000);
  }

  public stop() {
    if (this._timer !== null) {
      clearInterval(this._timer);
      this._count = 0;
    }
  }

  @autobind
  private _loop() {
    console.log(this._count++);
  }
}

A.4

function autobind(
  target: any,
  propertyKey: string,
  descriptor: PropertyDescriptor
) {
  const fn = descriptor.value;
  return {
    get() {
      const boundFn = fn.bind(this);
      return boundFn;
    },
  };
}

5. Generic & Conditional Types

Q.5

/*
state 는 읽을 수만 있고, 변경을 하려면 새로 데이터를 만들어야 하는 전형적인 immutable data 입니다.
아래 상태에서는 내부 데이터가 변경이 가능합니다.
예를 들어, state1.books[0].title = "새 책 이름"; 과 같이 변경이 가능합니다.

컴파일 타임에 변경이 불가능하도록 DeepReadonly<T> 라는 타입을 만들어보세요.
(어떠한 State 에도 대응이 가능하도록 범용적인 타입을 만들어보세요.)
*/

const state = {
  books: [{ title: "책 이름", author: "저자" }],
};

Item<T>

T 가 string 이면 StringContainer, 아니면 NumberContainer

type Item2<T> = {
  id: T;
  container: T extends string ? StringContainer : NumberContainer;
};

const item2: Item2<string> = {
  id: 'aaaaaa',
  container: null, // Type 'null' is not assignable to type 'StringContainer'.
};

Item<T>

T 가 string 이면 StringContainer

T 가 number 면 NumberContainer

아니면 사용 불가

type Item3<T> = {
  id: T extends string | number ? T : never;
  container: T extends string
    ? StringContainer
    : T extends number
    ? NumberContainer
    : never;
};

const item3: Item3<boolean> = {
  id: true, // Type 'boolean' is not assignable to type 'never'.
  container: null, // Type 'null' is not assignable to type 'never'.
};

infer

type UnpackPromise<T> = T extends Promise<infer K>[] ? K : any;
const promises = [Promise.resolve('Mark'), Promise.resolve(38)];

type Expected = UnpackPromise<typeof promises>; // string | number

함수의 리턴 타입 알아내기 - MyReturnType

function plus1(seed: number): number {
  return seed + 1;
}

type MyReturnType<T extends (...args: any) => any> = T extends (
  ...args: any
) => infer R
  ? R
  : any;

type Id = MyReturnType<typeof plus1>;

lookupEntity(plus1(10));

function lookupEntity(id: Id) {
  // query DB for entity by ID
}

내장 conditional types (1)

// type Exclude<T, U> = T extends U ? never : T;
type Excluded = Exclude<string | number, string>; // number - diff

// type Extract<T, U> = T extends U ? T : never;
type Extracted = Extract<string | number, string>; // string - filter

// Pick<T, Exclude<keyof T, K>>; (Mapped Type)
type Picked = Pick<{name: string, age: number}, 'name'>;

// type Omit<T, K extends keyof any> = Pick<T, Exclude<keyof T, K>>;
type Omited = Omit<{name: string, age: number}, 'name'>;

// type NonNullable<T> = T extends null | undefined ? never : T;
type NonNullabled = NonNullable<string | number | null | undefined>;

내장 conditional types (2)

/*
type ReturnType<T extends (...args: any) => any> = T extends (
  ...args: any
) => infer R
  ? R
  : any;
*/

/*
type Parameters<T extends (...args: any) => any> = T extends (
  ...args: infer P
) => any
  ? P
  : never;
*/
type MyParameters = Parameters<(name: string, age: number) => void>; // [name: string, age: number]

내장 conditional types (3)

interface Constructor {
  new (name: string, age: number): string;
}

/*
type ConstructorParameters<
  T extends new (...args: any) => any
> = T extends new (...args: infer P) => any ? P : never;
*/

type MyConstructorParameters = ConstructorParameters<Constructor>; // [name: string, age: number]

/*
type InstanceType<T extends new (...args: any) => any> = T extends new (
  ...args: any
) => infer R
  ? R
  : any;
*/

type MyInstanceType = InstanceType<Constructor>; // string

Function 인 프로퍼티 찾기

type FunctionPropertyNames<T> = {
  [K in keyof T]: T[K] extends Function ? K : never;
}[keyof T];
type FunctionProperties<T> = Pick<T, FunctionPropertyNames<T>>;

type NonFunctionPropertyNames<T> = {
  [K in keyof T]: T[K] extends Function ? never : K;
}[keyof T];
type NonFunctionProperties<T> = Pick<T, NonFunctionPropertyNames<T>>;

interface Person {
  id: number;
  name: string;
  hello(message: string): void;
}

type T1 = FunctionPropertyNames<Person>;
type T2 = NonFunctionPropertyNames<Person>;
type T3 = FunctionProperties<Person>;
type T4 = NonFunctionProperties<Person>;

Mapped Types

interface IPerson {
  name: string;
  age: number;
}

type ReadonlyPerson = Readonly<IPerson>;

const person: ReadonlyPerson = Object.freeze<IPerson>({
  name: "Mark",
  age: 38,
});

person.name = "Hanna"; // error!
person.age = 27; // error!

Mapped Types

interface IPerson {
  name: string;
  age: number;
}

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

type Stringify<T> = {
  [P in keyof T]: string;
};

type PartialNullablePerson = Partial<Nullable<Stringify<IPerson>>>;
/*
type PartialNullablePerson = {
    name?: string | null | undefined;
    age?: string | null | undefined;
    speak?: string | null | undefined;
}
*/

let pnp: PartialNullablePerson;
pnp = { name: 'Mark', age: '38' };
pnp = { name: 'Mark' };
pnp = { name: undefined, age: null };

Mapped Types

// Make all properties in T optional
type Partial<T> = {
    [P in keyof T]?: T[P];
};

// Make all properties in T required
type Required<T> = {
    [P in keyof T]-?: T[P];
};

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

// From T, pick a set of properties whose keys are in the union K
type Pick<T, K extends keyof T> = {
    [P in K]: T[P];
};

// Construct a type with a set of properties K of type T
type Record<K extends keyof any, T> = {
    [P in K]: T;
};

Readonly<T>

interface Book {
  title: string;
  author: string;
}

interface IRootState {
  book: {
    books: Book[];
    loading: boolean;
    error: Error | null;
  };
}

type IReadonlyRootState = Readonly<IRootState>;
let state1: IReadonlyRootState = {} as IReadonlyRootState;
const book1 = state1.book.books[0];
book1.title = 'new';

A.5

type DeepReadonly<T> = T extends (infer E)[]
  ? ReadonlyArray<DeepReadonlyObject<E>>
  : T extends object
  ? DeepReadonlyObject<T>
  : T;

type DeepReadonlyObject<T> = { readonly [K in keyof T]: DeepReadonly<T[K]> };

const state: DeepReadonly<{ books: { title: string; author: string }[] }> = {
  books: [{ title: "책 이름", author: "저자" }],
};

state.books[0].author = "마크";

Code Review

By Woongjae Lee

Code Review

LG CNS 타입스크립트 특강 4주차

  • 973