타입스크립트 in 50 레슨

스테판 바움가트너가 스매싱 매거진에서 출판한 서적으로 타입스크립트 주요 주제들에 대해 잘 짚어주고 있다.

Lesson 43

Promise 와 가변variadic 튜플 타입

새로운 기법을 활용해 callback 스타일의 코드를 promisify 하게 만들어 아래와 같은 동작을 기대하는 유틸리티 함수를 만들어보자.

declare function loadFile(fileName: string, cb: (result: string) => void)

const loadFilePromise = promisify(loadFile)

loadFilePromise('./chapter7.md').then(result => result.toUpperCase())

우리가 만들 함수는 여러 인자가 있지만 마지막 인자로 콜백 함수를 가지고 있어야 하고 promisified 된 함수를 반환한다. 함수 원형은 아래와 같다.

declare function promisify<Fun extends FunctionWithCallback >(fun: Fun): PromisifiedFunction<Fun>

type FunctionWithCallback = ((arg1: any, cb: (result: any) => any) => any)
                          | ((arg1: any, arg2: any, cb: (result: any) => any) => any)
                          | ((arg1: any, arg2: any, arg3: any, cb: (result: any) => any) => any)

이렇게 Fun 을 extends 하여 사용하는 패턴에 익숙해져야 한다. 여기에 사용된 FunctionWithCallback 의 타입도 확인해보자. 콜백을 포함한 인자의 개수에 따라 union 연산을 하고 있다. 가변 튜플 타입을 사용하여 대응할 수 있다. 튜플은 다음과 같이 표현된다.

type PersonProps = [string, number]

const [name, age]: PersonProps = ['Stefan', 37]

이 방식을 적용하면 함수 선언은 이렇게 표현할 수 있게 된다.

declare function hello(name: string, msg: string): void

declare function hello(...args: [string, string]): void

가변 튜플 타입의 튜플은 가변이란 말처럼 아직 정의되지 않은 타입을 가지고 있다. 이런 특성을 활용하면 콜백 스타일의 함수 원형을 완벽하게 표현할 수 있다.

type FunctionWithCallback<T extends any[]> = (...t: [...T, (...args: any) => any]) => any

`t` 는 튜플이고 그 안에 가변 인자로 T[] 와 마지막 콜백 함수를 가지고 있다. 이 콜백 함수도 가변 인자 타입이 선언된 와일드카드 함수 타입이다. 여기에서 명확히 `any` 를 사용하고 있다. 의도된 any 타입이다. 이 함수는 헬퍼 함수이기 때문에 any 선언이 괜찮다. 이제 Promise 를 반환하는 타입을 선언하면 가변 튜플 타입 선언과 함께 아래와 같이 정리할 수 있다.

type PromisifiedFunction<T> = (...args: InferArguments<T>) => Promise<InferResults<T>>

Promisify 를 위해 마지막 콜백 함수를 제외한 인자를 따로 할 필요가 있다. 콜백을 제외한 가변 인자 타입을 InferArguments<T> 로 선언하면 아래와 같다.

type InferArguments<T> = T extends (...t: [...infer A, (...args: any) => any]) => any ? A : never

반환 타입은 Promise 안에서 사용되는 콜백 함수이기 때문에 같은 방식으로 InferResults<T> 를 선언할 수 있다.

type InferResults<T> = T extends (...t: [...infer A, (res: infer R) => any]) => any ? R : never

promisify 에 대한 실제 구현을 살펴 보자.

function promisify<Fun extends (...args: any[]) => any>(f: Fun): (...args: InferArguments<Fun>) => Promise<InferResults<Fun>> {
    return function (...args: InferArguments<Fun>) {
        return new Promise((resolve) => {
            function callback(result: InferResults<Fun>) {
                resolve(result)
            }
            args.push(callback)
            f.call(null, ...args)
        })
    }
}

콜백을 포함한 promisify 할 함수 f 를 인자로 promisify 함수를 호출하면 infer arguement 를 통해 args 를 인자로 삼고 infer result 를 반환하는 함수를 반환한다. promisify 된 함수는 콜백 함수 이전의 args 를 인자로 받고 Promise 로 감싸진 callback 을 기존 콜백 함수 대신 마지막 인자로 전달하여 resolve 한다.

Lesson 44

JSONify 클래스 디자인

infer 는 extends 와 함께 삼항 연산의 결과를 나중에 참조하기 위해 사용한다. T 는 일반 함수이고 함수의 반환 타입을 R 로 추론 가능하면 R 로 정의하고 그렇지 않으면 any 를 반환한다.

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

T extends (…args: any) => infer R ? R : any JSON 은 parse, stringify 로 이루어진 함수가 없고, undefined 가 없는 자바스크립트 객체 표현이다. 타입스크립트를 만든 Anders Hejlsberg 의 쇼케이스로 사용된 JSON 타입 제너릭 코드로 Serializer 를 구현하고 있다. JSONify 타입은 기본 타입과 중첩된 배열이나 객체도 대응한다.

class Serializer<T> {
    serialize(inp: T): string {
        return JSON.stringify(inp)
    }
    deserialize(inp: string): JSONified<T> {
        return JSON.parse(inp)
    }
}

이에 추가로 toJSON 함수 타입을 가지고 있는데 전달되는 객체가 toJSON 함수를 가지고 있는 경우 JSON.stringify 의 결과를 사용하도록 대응할 것이다.

type Widget = {
  toJSON(): {
    kind: "Widget", date: Date
  }
}

JSONified 타입을 구성해 보자. toJSON 함수가 있는 경우를 구분하여 infer 를 통해 반환 타입을 구해 사용한다.

type JSONified<T> = JSONifiedValue<T extends { toJSON(): infer U } ? U : T>;

이제 각 값에 대한 타입 정의를 하고 있는 JSONifiedValue 타입을 살펴 보자.

type JSONifiedValue<T> =
  T extends string | number | boolean | null ? T :
  T extends Function ? never :
  T extends Array<infer U> ? JSONifiedArray<U> :
  T extends object ? JSONifiedObject<T> : never;

원시 타입인 경우 해당 타입을 반환 한다. 함수인 경우 버린다. 배열 인 경우 배열 안에 있는 타입을 참조하는 JSONifiedArray 타입을 반환한다. 객체인 경우는 JSONifiedObject 타입을 반환한다. 객체 타입인 재귀 타입이 된다.

type JSONifiedObject<T> = {[P in keyof T]: JSONified<T[P]>}

배열 타입인 경우, 배열의 요소가 undefined 값이 있는 경우 null 타입을 반환하도록 한다. 배열의 요소로 객체가 있을 수 있으니 여기에도 재귀 구문이 추가된다.

type UndefinedAsNull<T> = T extends undefined ? null : T;

type JSONifiedArray<T> = Array<UndefinedAsNull<JSONified<T>>>

Serializer 클래스에 대한 타입 제약이 완성되었다.

type SomeItem = {
  text: string; count: number; choice: "yes" | "no" | null;
  func: () => void;
  nested: { isSaved: boolean; data: [1, undefined, 2]; what: [undefined, undefined] }
  widget: Widget; children?: SomeItem[];
}

const serializer = new Serializer<SomeItem>();

serializer.serialize(it)
let o = serializer.deserialize('')

Lesson 45

복합 서비스 정의 타입 패턴

앤더스 헤일스버그의 다른 예제로 동적 타입 정의에 대한 내용을 살펴 보자. 아래와 같이 사용되는 객체가 있다.

const serviceDefinition = {
  open: { filename: String },
  insert: { pos: Number, text: String },
  delete: { pos: Number, len: Number },
  close: {},
}

이 정의를 사용하는 createService 함수를 구현하기 위해 서비스를 정의한 내용과 이 요청을 처리하는 핸들러를 전달할 것이다. 반환되는 서비스는 open, insert 등 정의된 스펙에 따라 사용할 수 있다. createService 함수 원형을 보자.

declare function createService<S extends ServiceDefinition>(
  serviceDefinition: S,
  handler: RequestHandler<S>,
): ServiceObject<S>

서비스 정의 타입은 간단히 아래와 같이 정의할 수 있다. 문자열을 키로 사용하는 객체 타입을 구성하고 키가 정의되는 과정에 타입이 narrow 된다.

type ServiceDefinition = {
  [x: string]: MethodDefinition;
}
type MethodDefinition = {
  [x: string]: StringConstructor | NumberConstructor;
}

핸들러 타입을 정의해 보자. request 를 받은 핸들러의 실행을 완료하고 boolean 을 반환한다.

type RequestHandler<T extends ServiceDefinition> = (req: RequestObject<T>) => boolean;

RequestObject 타입은 서비스 정의에 따른다. RequestObject 는 아래처럼 정의된다.

type RequestObject<T extends ServiceDefinition> = {
  [P in keyof T]: { message: P; payload: RequestPayload<T[P]>; }
}[keyof T];

이렇게 정의된 request 객체는 다음과 같은 타입에 대한 상황을 만족한다.

{
  req: { message: "open"; payload: { filename: string; } }
     | { message: "insert"; payload: { pos: number; text: string; } }
     | { message: "delete"; payload: { pos: number; len: number; } }
     | { message: "close"; payload: undefined; }
}

createService 함수가 반환하는 객체의 반환 타입도 정리해 보자. 문자열 키에 대한 ServiceMethod 제너릭 타입이다.

type ServiceObject<T extends ServiceDefinition> = {
  [P in keyof T]: ServiceMethod<T[P]>
};

각 서비스 메소드는 페이로드를 받아 실행 결과를 반환한다.

type ServiceMethod<T extends MethodDefinition> = {} extends T ? () => boolean : (payload: RequestPayload<T>) => boolean;

페이로드는 RequestPayload 로 정의된 제너릭 타입이다. 서비스 정의에 사용된 자바스크립트 타입을 통해 생성자 타입을 구성할 수 있다.

type RequestPayload<T extends MethodDefinition> = {} extends T ? undefined : { [P in keyof T]: TypeFromConstructor<T[P]> };

type TypeFromConstructor<T> = T extends StringConstructor ? string : T extends NumberConstructor ? number : any;

실제 서비스 생성 함수를 구현하면 아래와 같다.

function createService<S extends ServiceDefinition>(serviceDefinitions: S, handler: RequestHandler<S>,): ServiceObject<S> {
  const service: Record<string, Function> = {};

  for (const name in serviceDefinitions) {
    service[name] = (payload: any) => handler({ message: name, payload });
  }

  return service as ServiceObject<S>;
}

const service = createService(serviceDefinition, req => {
  switch (req.message) {
    case 'open':
      // do something
      break;
    case 'insert':
      // do something
      break;
    default:
      // do something or reach never
      break;
  }
  return true;
});

service.close();
service.open({ filename: 'text.txt' });

이렇게 타입 정보를 추가하여 흔히 사용하는 서비스 정의 패턴의 타입 안정성을 확보할 수 있다.

Lesson 46

DOM JSX 타입 엔진 만들기 1

JSX 는 템플릿 언어도 아니고 HTML 도 아니고 XML 도 아니다. JSX 의 실체는 함수 호출이다. `(element, properties, …children)` 로 보면 된다.

<Button onClick={() => alert('YES')}>Click me</Button>

위 구문은 사실 아래로 변환 된다.

React.createElement(Button, { onClick: () => alert('YES') }, 'Click me');

태그 문법이라 중첩 사용이 가능하다. 이는 재귀로 표현할 수 있다.

<Button onClick={() => alert('YES')}><span>Click me</span></Button>
React.createElement(Button, { onClick: () => alert('YES') }, React.createElement('span', {}, 'Click me'));

대문자로 시작되는 요소는 컴포넌트로, 소문자로 시작되는 항목은 문자열로 변환되고 있다. 타입스크립트를 사용해 JSX 컴파일러를 만들어보자. 함수 원형은 아래와 같다.

function factory(element, properties, ...children) {
  //...
}

컴파일러 옵션을 추가한다.

{
  "compilerOptions": {
    ...
    "jsx": "react",
    "jsxFactory": "DOMcreateElement",
    "noImplicitAny": false
  }
}

팩토리 함수의 스펙을 살펴보자. element 가 함수이면 함수형 컴포넌트로 사용하고 properties 와 children 을 인자로 호출하여 결과를 얻어 낸다. element 가 문자열이면 일반 노드로 사용한다.

function DOMcreateElement(element, properties, ...children) {
  if(typeof element === 'function') {
    return element({ ...nonNull(properties, {}), children });
  }

  return DOMparseNode(element, properties, children);
}

function nonNull(val, fallback) {
  return Boolean(val) ? val : fallback
}

일반 노드를 파싱하는 구문을 보자. children 을 처리하는 함수는 재귀 패턴을 사용하자.

function DOMparseNode(element, properties, children) {
  const el = Object.assign(document.createElement(element), properties);

  DOMparseChildren(children).forEach(child => { el.appendChild(child); });

  return el;
}

function DOMparseChildren(children) {
  return children.map(child => {
    if(typeof child === 'string') {
      return document.createTextNode(child);
    }
    return child;
  })
}

앞으로 이어서 사용할 JSX 템플릿은 아래와 같다.

const Button = ({ msg }: { msg: string }) => {
  return (
    <button onclick={() => alert(msg)}>
      <strong>Click me</strong>
    </button>
  );
};

const el = (
  <div>
    <h1 className="what">Hello world</h1>
    <p>...</p>
    <Button msg="Yay" />
    <Button msg="Nay" />
  </div>
);

document.body.appendChild(el);

Lesson 47

DOM JSX 엔진 만들기 2

이제 타입 정보를 추가해보자. 이전의 nonNull 함수에 타입 정보를 추가하면 이렇게 된다.

function nonNull<T, K>(val: T, fallback: K) {
  return Boolean(val) ? val : fallback;
}

다음으로 DOMParseChildren 함수를 위해 타입을 추가하자. HTMLElement 는 가장 기본 클래스이다. 이 경우 string 과 Text 타입을 추가로 받을 수 있다.

type PossibleElements = HTMLElement | Text | string;

function DOMparseChildren(children: PossibleElements[]) {
    //
}

type Fun = (...args: any[]) => any;

함수 타입 원형을 위해 Fun 타입도 정의해 두었다. HTML 의 모든 엘리먼트에 대한 태그 맵이 있다. HTMLElementTagNameMap 인터페이스가 브라우저 안에 정의되어 있다.

type AllElementsKeys = keyof HTMLElementTagNameMap;

type CreatedElement<T> = T extends AllElementsKeys ? HTMLElementTagNameMap[T] : HTMLElement;

프로퍼티를 위한 타입 정의는 이렇게 가능하다.

type Props<T> = T extends Fun ? Parameters<T>[0] : T extends string ? Partial<CreatedElement<T>> : never;

이제 DOMParseNode 함수 원형은 아래와 같이 구성할 수 있다.

function DOMparseNode<T extends string>(element: T, properties: Props<T>, children: PossibleElements[]) {
    //
}

DOMCreateElement 함수 원형도 다음과 같다.

function DOMcreateElement<T extends string>(element: T, properties: Props<T>, ...children: PossibleElements[]): HTMLElement;
function DOMcreateElement<F extends Fun>(element: F, properties: Props<F>, ...children: PossibleElements[]): HTMLElement;
function DOMcreateElement(element: any, properties: any, ...children: PossibleElements[]): HTMLElement {
    //
}

이렇게 타입 정보가 제공되면 HTML 엘리먼트 코드 작업 때 자동 완성 등의 기능과 함께 타입 안전한 코드를 작성할 수 있다.

Lesson 48

객체 타입 확장하기 1

타입스크립트의 컨트롤 플로우 분석은 타입 좁히기에 도움을 준다. 자주 사용되는 구문은 아래와 같을 것이다.

function print(msg: any) {
  if(typeof msg === 'string') {
    console.log(msg.toUpperCase()) // We know msg is a string
  } else if (typeof msg === 'number') {
    console.log(msg.toFixed(2)) // I know msg is a number
  }
}

객체에 대해서는 조금 더 복잡하게 타입을 확인하게 되는데 타입스크립트는 타입을 좁히지 못하는 상황이 온다.

if(typeof obj === 'object' && 'prop' in obj) {
  // It's safe to access obj.prop
  console.assert(typeof obj.prop !== 'undefined')
  // But TS doesn't know :-(
}

if(typeof obj === 'object' && obj.hasOwnProperty('prop')) {
  // It's safe to access obj.prop
  console.assert(typeof obj.prop !== 'undefined')
  // But TS doesn't know :-(
}

hasOwnProperty 에 대해 타입 정보를 제공하여 이 문제를 해결해 보자.

function hasOwnProperty<X extends {}, Y extends PropertyKey>(obj: X, prop: Y): obj is X & Record<Y, unknown> {
  return obj.hasOwnProperty(prop)
}

X 는 객체에 대한 확장을 보장한다. 객체의 키로 위해 string, number, symbol 을 사용할 수 있는데 이를 위해 `PropertyKey` 타입이 제공된다. obj: X 와 prop: Y 에 대한 타입을 확보하게 된다. 이 헬퍼 함수를 아래와 같이 사용하자.

// person is an object
// person = { } & Record<'name', unknown> = { } & { name: 'unknown'}
if(typeof person === 'object' && hasOwnProperty(person, 'name') && typeof person.name === 'string') {
    // Yes! name now exists in person
    // Do something with person.name, which is a string
}

또 다른 흔한 예제를 보자. 자바스크립트에서 자주 볼 수 있는 코드이다.

const obj = { name: 'Stefan', age: 38 }

Object.keys(obj).map(key => {
  console.log(obj[key])
})

타입스크립트는 key 에 대한 타입 정보를 얻을 수 없어 경고를 안내한다. Object.keys 는 여러 타입을 받을 수 있다. number 늘 받는 경우는 빈 배열을 반환한다. 문자열이나 배열을 담으면 숫자 인덱스를 가지는 배열을 반환한다. 객체를 전달 받는다면 이 객체에 사용된 키를 반환한다. 스펙을 알았으니 타입을 강제해 보자.

type ReturnKeys<O> = O extends number ? [] : O extends Array<any> | string ? string[] : O extends object ? Array<keyof O> : never
// Extending the interface
interface ObjectConstructor {
  keys<O>(obj: O) : ReturnKeys<O>
}

ReturnKey<T> 타입과 인터페이스 확장으로 Object.keys 메소드의 타입 제약을 강화할 수 있다.

Lesson 49

객체 타입 확장하기 2

자바스크립트는 동적으로 객체를 생성할 수 있다. 특히 Object.defineProperty 를 통해 런타임에 확장이 가능하고 객체의 writable 속성을 조정하여 객체의 변경을 보호할 수 있다. 타입스크립트는 assets 키워드를 통해 타입 단정을 할 수 있다. 타입 단정을 통해 타입 좁히기 역할을 할 수 있다. 다음 예제를 보자.

function assertIsNum(val: any) {
  if (typeof val !== "number") {
    throw new AssertionError("Not a number!");
  }
}

function multiply(x, y) {
  assertIsNum(x);
  assertIsNum(y);
  return x * y; // x, y 의 타입 정보가 없다.
}

assetIsNum 함수에 타입 단정문을 추가해보자.

function assertIsNum(val: any): asserts val is number {
    //
}

function multiply(x, y) {
  assertIsNum(x);
  assertIsNum(y);
  return x * y; // Now also TypeScript knows that both x and y are numbers
}

이 개념을 Object.defineProperty 에도 적용해 볼 수 있다. defineProperty 헬퍼 함수 원형은 아래와 같다.

function defineProperty<Obj extends object, Key extends PropertyKey, PDesc extends PropertyDescriptor>
  (obj: Obj, prop: Key, val: PDesc) {
  Object.defineProperty(obj, prop, val)
}

PropertyKey 와 PropertyDescriptor 는 빌트인 타입이다. (obj, prop, val) 에 타입 단정을 추가해 보면 아래와 같은 모양이 될 것이다.

function defineProperty<Obj extends object, Key extends PropertyKey, PDesc extends PropertyDescriptor>
  (obj: Obj, prop: Key, val: PDesc): asserts obj is Obj & DefineProperty<Key, PDesc> {
  Object.defineProperty(obj, prop, val)
}

DefineProperty 타입과 이 타입이 사용하는 InferValue 제너릭 타입은 아래와 같이 정의할 수 있다.

type DefineProperty<Prop extends PropertyKey, Desc extends PropertyDescriptor> =
  Desc extends { writable: any, set(val: any): any } ? never :
  Desc extends { writable: any, get(): any } ? never :
  Desc extends { writable: false } ? Readonly<InferValue<Prop, Desc>> :
  Desc extends { writable: true } ? InferValue<Prop, Desc> :
  Readonly<InferValue<Prop, Desc>>

type InferValue<Prop extends PropertyKey, Desc> =
  Desc extends { get(): any, value: any } ? never :
  Desc extends { value: infer T } ? Record<Prop, T> :
  Desc extends { get(): infer T } ? Record<Prop, T> : never;

이 작업을 객체 생성자에 적용해 보자.

type ObjectKeys<T> =
  T extends object ? (keyof T)[] :
  T extends number ? [] :
  T extends Array<any> | string ? string[] :
  never;

interface ObjectConstructor {
  keys<T>(o: T): ObjectKeys<T>
}

Object.defineProperty 대신 defineProperty 를 사용해 객체를 확장해 보자.

const storage = { currentValue: 0 }

defineProperty(storage, 'maxValue', { writable: false, value: 9001 })

storage.maxValue // it's a number
storage.maxValue = 2 // Error! It's read-only

const storageName = 'My Storage'

defineProperty(storage, 'name', {
  get() {
    return storageName
  }
})

storage.name // it's a string!

// it's not possible to assing a value and a getter
defineProperty(storage, 'broken', {
  get() {
    return storageName
  },
  value: 4000
})

storage // storage is never because we have a malicious property descriptor

Lesson 50

에필로그

마지막 레슨. 타입스크립트 팀에서 제공하는 정보에 귀를 기울이기. 깃헙에서 볼 수 있다. 로드맵, 다음 버전의 기능들 등. TC39 소식도 보자. 타입스크립트 핸드북을 계속 보자. Deno 와 Pika CDN 의 정보도 주시하자. 타입스크립트 위클리는 필수. 필자의 블로그도 계속 볼 것: https://fettblog.eu/