TypeScript 50 Lessons Part 7
타입스크립트 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/