안녕하세요, 단테입니다.
타입스크립트 5.0은 이전 버전과 비교해서 여러 가지 차이점이 있는데요, 예제 코드와 함께 새로운 기능에 대해 공부해보겠습니다
데코레이터 (Decorators): 이전 버전에서는 실험적인 기능으로 제공되었던 데코레이터가 ECMAScript (Stage 3) 스펙에 맞게 업데이트되었습니다.
데코레이터는 클래스, 메서드, 접근자, 프로퍼티, 파라미터에 함수를 붙여 기능을 확장할 수 있는 기능입니다.
자바의 스프링이나 NestJS에서 많이 볼 수 있는 문법입니다.
데코레이터는 아래와 같이 생겼습니다.
class Person {
name: string;
constructor(name: string) {
this.name = name;
}
@loggedMethod
greet() {
console.log(`Hello, my name is ${this.name}.`);
}
}
이 데코레이터는 로킹이나 퍼포먼스 프로파일링등에 사용하며 위에 예시로 들었던 @loggedMethod
는 사용하는 메소드의 이름을 로그로 출력합니다.
function loggedMethod(originalMethod: any, _context: any) {
function replacementMethod(this: any, ...args: any[]) {
console.log("LOG: Entering method.")
const result = originalMethod.call(this, ...args);
console.log("LOG: Exiting method.")
return result;
}
return replacementMethod;
}
const p = new Person("Ray");
p.greet();
// Output:
//
// LOG: Entering method.
// Hello, my name is Ray.
// LOG: Exiting method.
위 예제에서 p.greet 을 호출하면 데코레이터에 선언
했던 로그가 출력됨을 확인할 수 있습니다.
typescript 5.0 이전에도 tsc
사용시 --experimentalDecorators 옵션을 사용해 자바스크립트 코드로 트랜스파일 할 수 있었습니다.
Mobx나 NestJS를 사용하시는 분들은 데코레이터를 사용해야하기 때문에 프로젝트 세팅에서 tsconfig.json에 아래와 같이 experimentalDecorators
값을 설정하셨을텐데요
{
"compilerOptions": {
"experimentalDecorators": true,
"emitDecoratorMetadata": true
}
}
4.9.5 버전과 5.1.3 버전을 비교해보며 문법적으로는 어떻게 변경되었는지 살펴보겠습니다.
아래 사진에서 왼쪽이 5.1.3, 오른쪽이 4.9.5 입니다. 비교에 사용한 코드는 아래 상세하였습니다.
function loggedMethod(target: any, propertyKey: string, descriptor: PropertyDescriptor){
console.log("target: ", target);
console.log("propertyKey: ", propertyKey)
console.log("descriptor: ", descriptor)
const originalMethod = descriptor.value;
descriptor.value = function(...args: any[]) {
console.log(`LOG: Entering method '${propertyKey}'.`)
const result = originalMethod.call(this, ...args);
console.log(`LOG: Exiting method '${propertyKey}'.`)
}
return descriptor
}
class Person {
name: string;
constructor(name: string) {
this.name = name;
}
@loggedMethod
greet() {
console.log(`Hello, my name is ${this.name}`);
}
}
const dante = new Person("Dante")
dante.greet()
[LOG]: "target: ", Person: {}
[LOG]: "propertyKey: ", "greet"
[LOG]: "descriptor: ", {
"writable": true,
"enumerable": false,
"configurable": true
}
[LOG]: "LOG: Entering method 'greet'."
[LOG]: "Hello, my name is Dante"
[LOG]: "LOG: Exiting method 'greet'."
declare function loggedMethod(target: any, propertyKey: string, descriptor: PropertyDescriptor): PropertyDescriptor;
declare class Person {
name: string;
constructor(name: string);
greet(): void;
}
declare const dante: Person;
// typescript v5.1.3
function loggedMethod(originalMethod: any, context: ClassMethodDecoratorContext) {
console.log("originalMethod: ", originalMethod);
console.log("context: ", context)
const methodName = String(context.name);
function replacementMethod(this: any, ...args: any[]) {
console.log(`LOG: Entering method '${methodName}'.`)
const result = originalMethod.call(this, ...args);
console.log(`LOG: Exiting method '${methodName}'.`)
return result;
}
return replacementMethod;
}
class Person {
name: string;
constructor(name: string) {
this.name = name;
}
@loggedMethod
greet() {
console.log(`Hello, my name is ${this.name}`);
}
}
const dante = new Person("Dante")
dante.greet()
Output
"use strict";
var __runInitializers = (this && this.__runInitializers) || function (thisArg, initializers, value) {
var useValue = arguments.length > 2;
for (var i = 0; i < initializers.length; i++) {
value = useValue ? initializers[i].call(thisArg, value) : initializers[i].call(thisArg);
}
return useValue ? value : void 0;
};
var __esDecorate = (this && this.__esDecorate) || function (ctor, descriptorIn, decorators, contextIn, initializers, extraInitializers) {
function accept(f) { if (f !== void 0 && typeof f !== "function") throw new TypeError("Function expected"); return f; }
var kind = contextIn.kind, key = kind === "getter" ? "get" : kind === "setter" ? "set" : "value";
var target = !descriptorIn && ctor ? contextIn["static"] ? ctor : ctor.prototype : null;
var descriptor = descriptorIn || (target ? Object.getOwnPropertyDescriptor(target, contextIn.name) : {});
var _, done = false;
for (var i = decorators.length - 1; i >= 0; i--) {
var context = {};
for (var p in contextIn) context[p] = p === "access" ? {} : contextIn[p];
for (var p in contextIn.access) context.access[p] = contextIn.access[p];
context.addInitializer = function (f) { if (done) throw new TypeError("Cannot add initializers after decoration has completed"); extraInitializers.push(accept(f || null)); };
var result = (0, decorators[i])(kind === "accessor" ? { get: descriptor.get, set: descriptor.set } : descriptor[key], context);
if (kind === "accessor") {
if (result === void 0) continue;
if (result === null || typeof result !== "object") throw new TypeError("Object expected");
if (_ = accept(result.get)) descriptor.get = _;
if (_ = accept(result.set)) descriptor.set = _;
if (_ = accept(result.init)) initializers.unshift(_);
}
else if (_ = accept(result)) {
if (kind === "field") initializers.unshift(_);
else descriptor[key] = _;
}
}
if (target) Object.defineProperty(target, contextIn.name, descriptor);
done = true;
};
// typescript v5.1.3
function loggedMethod(originalMethod, context) {
console.log("originalMethod: ", originalMethod);
console.log("context: ", context);
const methodName = String(context.name);
function replacementMethod(...args) {
console.log(`LOG: Entering method '${methodName}'.`);
const result = originalMethod.call(this, ...args);
console.log(`LOG: Exiting method '${methodName}'.`);
return result;
}
return replacementMethod;
}
let Person = (() => {
var _a;
let _instanceExtraInitializers = [];
let _greet_decorators;
return _a = class Person {
constructor(name) {
this.name = (__runInitializers(this, _instanceExtraInitializers), void 0);
this.name = name;
}
greet() {
console.log(`Hello, my name is ${this.name}`);
}
},
(() => {
_greet_decorators = [loggedMethod];
__esDecorate(_a, null, _greet_decorators, { kind: "method", name: "greet", static: false, private: false, access: { has: obj => "greet" in obj, get: obj => obj.greet } }, null, _instanceExtraInitializers);
})(),
_a;
})();
const dante = new Person("Dante");
dante.greet();
Compiler Options
{
"compilerOptions": {
"strict": true,
"noImplicitAny": true,
"strictNullChecks": true,
"strictFunctionTypes": true,
"strictPropertyInitialization": true,
"strictBindCallApply": true,
"noImplicitThis": true,
"noImplicitReturns": true,
"alwaysStrict": true,
"esModuleInterop": true,
"declaration": true,
"target": "ES2017",
"jsx": "react",
"module": "ESNext",
"moduleResolution": "node"
}
}
Logs
Playground Link: Provided
위 예시 코드는 클래스에서 메소드 데코레이터를 가지고 비교한 것입니다. 앞서서 캡쳐했던 두 버전의 비교 코드에서 빨간색으로 박스친 함수 시그니처를 좀 더 자세히 살펴보면
5.0 미만 버전에서 decriptor 인자에서 value
키를 참조하여 원본 함수를 참조하는 부분이
5.0 이상에서는 첫번째 인자로 들어옴을 확인할 수 있습니다.
또한 이 데코레이터 함수는 팩토리 패턴으로 다른 함수를 반환함에 있어 descriptor.value에 함수를 정의하고(4.9), 아니면 replacementMethod 자체를 반환하는(5.0)등의 차이점이 두드러지게 나타납니다.
이에 따라 기존에 데코레이터를 사용하는 NestJS나 Mobx와 같은 라이브러리를 사용하는 프로젝트는 5.0으로 마이그레이션할 때 이 점을 꼭 유의해야 합니다.
NestJS를 사용하시던 분들은 5.0 마이그레이션에 있어 큰 진통이 있을 것으로 예상합니다.
TS는 효과적으로 상수를 타입으로 정의하게 도와주는 const assertion이라고 하는 기능을 제공합니다.
아래 예시 코드의 HasNames
타입은 getNamesExcalty
를 통해 반환될 때 string[] 타입이 됩니다. 선언할 때 그렇게 했으니 당연한 것인데요,
type HasNames = { names: readonly string[] };
function getNamesExactly<T extends HasNames>(arg: T): T["names"] {
return arg.names;
}
// Inferred type: string[]
const names = getNamesExactly({ names: ["Alice", "Bob", "Eve"]})
이 때 반환 값을 함수 인풋과 동일하게 하기 위해 const assertion
을 사용할 수 있습니다.
이번에 등장한 const modifier
를 사용하게 되면 함수 호출 시 const assertion
을 인자와 함께 사용하지 않아도 의도한대로 ["Alice", "Bob", "Eve"]
로 반환값을 추론합니다.
type HasNames = { names: readonly string[] };
function getNamesExactly<const T extends HasNames>(arg: T): T["names"] {
// ^^^^^
return arg.names;
}
// Inferred type: readonly ["Alice", "Bob", "Eve"]
// Note: Didn't need to write 'as const' here
const names = getNamesExactly({ names: ["Alice", "Bob", "Eve"] });
const modifier
사용시 한가지 유의해야할 점은
함수 호출 시 인자를 object, array, primitive expression으로 직접 넘기지 않고 변수로 넣게되면 아래 코드처럼 의도와 다르게 string[]
으로 반환값이 추론된다는 점입니다.
또한 readonly string[]이 아닌 string[]
으로 추론된다는 점도 유의해야 합니다.
type HasNames = { names: readonly string[] };
function getNamesExactly<const T extends HasNames>(arg: T): T["names"] {
return arg.names;
}
// Inferred type: string[]
const arg = ["Alice", "Bob", "Eve"];
// ^^^
const names = getNamesExactly({ names: arg });
tsconfig.json의 extends 필드를 통해 기존에 정의된 compilerOptions를 재활용할 수 있습니다.
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"outDir": "../lib",
// ...
}
}
이번 버전부터 extends 필드에 배열 형식으로 여러 설정 파일을 설정할 수 있게 되며 설정 파일끼리 충돌되는 키값이 있으면 나중에 설정한 파일의 값으로 덮어씌여집니다.
아래와 같이 tsconfig.json의 extends된 파일이 tsconfig1.json, tsconfig2.json일때 적용되는 설정 값은
"strictNullChecks": true, "noImplicitAny": true
입니다.
// tsconfig1.json
{
"compilerOptions": {
"strictNullChecks": true
}
}
// tsconfig2.json
{
"compilerOptions": {
"noImplicitAny": true
}
}
// tsconfig.json
{
"extends": ["./tsconfig1.json", "./tsconfig2.json"],
"files": ["./index.ts"]
}
enum을 사용하면 함수 파라메터로 사전에 정의하지 않은 literal 타입이 전달되는 것을 방지해 코드의 안정성을 높이는데 도움이 됩니다.
typescript 2.0에서는 Color.Red
, Color.Green
, Color.Blue
값이 각각이 literal type을 가지고 있어 아래와 같이 isPrimaryColor
에서 ||
가 아닌 &&
을 표기하는 실수를 줄일 수 있게 도와줍니다.
enum E {
Foo = 10,
Bar = 20,
}
function takeValue(e: E) {}
takeValue(E.Foo); // works
takeValue(123); // error!
// Color is like a union of Red | Orange | Yellow | Green | Blue | Violet
enum Color {
Red, Orange, Yellow, Green, Blue, /* Indigo, */ Violet
}
// Each enum member has its own type that we can refer to!
type PrimaryColor = Color.Red | Color.Green | Color.Blue;
function isPrimaryColor(c: Color): c is PrimaryColor {
// Narrowing literal types can catch bugs.
// TypeScript will error here because
// we'll end up comparing 'Color.Red' to 'Color.Green'.
// We meant to use ||, but accidentally wrote &&.
return c === Color.Red && c === Color.Green && c === Color.Blue;
}
enum E {
Blah = Math.random()
}
enum literal type으로 인해 의도치 않은 값을 할당하거나 참조하는 실수를 줄일 수 있게 되었지만 Math.random()
과 같이 계산 값을 설정하는 경우 2.0 버전 이전으로 회귀해버립니다.
function bar(n: number) {
return n * n;
}
enum E {
A = 10 * 10, // Numeric literal enum member
B = 'foo', // String literal enum member
C = bar(42), // Opaque computed enum member
}
먼저 string 값과 number 타입을 동일한 enum의 값으로 설정할 수 없습니다.
// ts 4.9.5
이제 computed value를 가진 enum literal type도 함께 선언할 수 있습니다.
두번째로는 number 타입만 enum E에 선언하다고 하더라도 union literal type이 깨지고 numeric literal type으로만 판단하기 때문에 의도한 시그니처 정적 체킹이 불가능 해집니다.
// ts 4.9.5
function bar(n: number) {
return n * n;
}
enum E {
A = 10 * 10, // Numeric literal enum member
C = bar(42), // Opaque computed enum member
}
function log(e: E) {
return e
}
log(20)
log에 20값이 전달 가능하게 된다.
이전에 typescript enum 사용시 유의해야 할점에 대해 다뤘었습니다.
enum 에 오염된 값이 들어갈 수 있는 위험성이 있었습니다.
SomeEventDigit에 numeric literal 값으로 1이 없는 상태이지만 변수 m에는 SomeEventDigit
타입으로 값 1이 바인딩 될 수 있었습니다.
의도치 못한 바인딩을 5.0 부터는 방지할 수 있게 되었습니다.
switch 문을 작성할 때 TypeScript는 이제 확인 중인 값에 리터럴 유형이 있는지 감지하여 IDE에서 자동 완성을 사용할 수 있습니다.
비교연산자 >, <, <=, >=
에 대해서도 에러를 표기합니다.
function func(ns: number | string) {
return +ns > 4; // OK
}
오늘은 타입스크립트 5.0에서 변경된 주요 포인트들에 대해 알아보았습니다.
감사합니다.
https://www.youtube.com/watch?v=ndd0nTfIVFU