Prog. Langs & Tools/TypeScript

TypeScript 5.0 버전 주요 변경사항 정리

DevOwen 2023. 6. 4. 03:23

이 블로그 포스팅은 마이크로소프트 기술블로그에 나온 타입스크립트 5.0 버전 변경사항 글을 참고하여 작성했습니다.

데코레이터(Decorators)

데코레이터는 클래스와 클래스 요소(메서드, 프로퍼티 등)들을 재사용 가능한 방법으로 사용자 지정할 수 있도록 지원하는 곧 출시될 ECMA스크립트 기능이다.

class Person {
    name: string;
    constructor(name: string) {
        this.name = name;
    }
    greet() {
        console.log(`Hello, my name is ${this.name}.`);
    }
}
const p = new Person("Ray");
p.greet();

greet 메서드는 간단하지만, 더 복잡해질 수 있다고 가정한다. 아마 비동기 로직이 있을 수도 있고, 재귀 로직이나 사이드 이펙트 등이 있을 수도 있다. 무엇으로 이루어 져 있든 간에, greet를 디버깅 하기 위해 콘솔 로그를 찍어본다.

class Person {
    name: string;
    constructor(name: string) {
        this.name = name;
    }
    greet() {
        console.log("LOG: Entering method.");
        console.log(`Hello, my name is ${this.name}.`);
        console.log("LOG: Exiting method.")
    }
}

이 패턴은 꽤 일반적이다. 우리는 이런 식으로 모든 메서드에 할 수도 있다. 여기에서 데코레이터가 사용된다. 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;
}

일단 여기에 any가 많은 것들은 신경쓰지 말고, 함수의 로직에 집중하자. loggedMethod는 원본 메서드인 originalMethod를 받아서 함수를 리턴하는데 이 함수는 다음 로직을 수행한다.

  1. Entering... 메시지 출력
  2. this와 모든 인자들(...args)을 원본 메서드(originalMethod)로 넘김
  3. Exiting... 메시지 출력
  4. 원본 메서드 출력

이제 greet 메서드를 decorate 하기 위해 loggedMethod를 사용할 수 있다.

class Person {
    name: string;
    constructor(name: string) {
        this.name = name;
    }
    @loggedMethod
    greet() {
        console.log(`Hello, my name is ${this.name}.`);
    }
}
const p = new Person("Ray");
p.greet();
// Output:
//
//   LOG: Entering method.
//   Hello, my name is Ray.
//   LOG: Exiting method.

greet 메서드 위에 데코레이터 @loggedMethod를 사용했다. 이를 사용했을 때, target 메서드와 컨텍스트 객체와 함께 데코레이터가 호출된다. loggedMethod가 새로운 함수로 출력되었기 때문에, 이 함수는 원래 정의된 greet를 대체한다.

loggedMethod의 두 번째 인자는 컨텍스트 객체(context object)이다. 여기에는 데코레이터 메서드가 선언 되었을 때의 정보가 담겨져 있다. 예를 들면 private 멤버인지, 아니면 static 인지, 메서드의 이름이 어떤 것인지 등이 담겨져 있다. 이러한 내용들이 담기도록 loggedMethod를 다시 써보면 다음과 같다.

function loggedMethod(originalMethod: any, context: ClassMethodDecoratorContext) {
    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;
}

타입을 더 엄격하게 가져간다. 타입스크립트는 ClassMethodDecoratorContext라는 타입을 제공하는데, 메서드 데코레이터가 가지는 컨텍스트 객체를 모델화했다.

또 다른 데코레이터인 bound를 살펴보자.

function bound(originalMethod: any, context: ClassMethodDecoratorContext) {
    const methodName = context.name;
    if (context.private) {
        throw new Error(`'bound' cannot decorate private properties like ${methodName as string}.`);
    }
    context.addInitializer(function () {
        this[methodName] = this[methodName].bind(this);
    });
}

addInitializer 메서드는 생성자의 시작에 후킹하는 방법이다. 다른 말로 static으로 동작하는 클래스 초기화를 의미한다. bound 데코레이터에 addInitializer를 사용해서 생성자 안에서 bind를 호출한다.

bound 는 아무것도 반환하지 않는다. bound가 메서드를 감쌀 때 원본 그 자체를 남긴다. 대신, 다른 필드들이 초기화되기 전에 로직을 추가할 것이다.

class Person {
    name: string;
    constructor(name: string) {
        this.name = name;
    }
    @bound
    @loggedMethod
    greet() {
        console.log(`Hello, my name is ${this.name}.`);
    }
}
const p = new Person("Ray");
const greet = p.greet;
// Works!
greet();

두 개의 데코레이터를 쌓은 점을 주목하자. @bound와 @loggedMethod. 이런 데코레이터들은 반대 순서로 실행한다. 이 말은 @loggedMethod가 원본 메서드 greet를 데코레이트한다. 그 다음 @bound가 @loggedMethod의 결과를 데코레이트한다.

const 타입 파라미터

객체의 타입을 추론할 때, 타입스크립트는 일반적으로 타입을 제너럴하게 선택한다. 예를 들어 아래의 경우 names의 추론 타입은 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"]});

일반적으로 이 의도는 라인 아래로 진행할수록 변화가 가능하게 하기 위해서이다. 그러나, 더 구체적인 타입을 필요로 할 때가 있다. 지금까지는 as const를 추가해서 원하는 방식으로 추론이 가능했다.

// The type we wanted:
//    readonly ["Alice", "Bob", "Eve"]
// The type we got:
//    string[]
const names1 = getNamesExactly({ names: ["Alice", "Bob", "Eve"]});
// Correctly gets what we wanted:
//    readonly ["Alice", "Bob", "Eve"]
const names2 = getNamesExactly({ names: ["Alice", "Bob", "Eve"]} as const);

이는 복잡하고 다루기가 힘들다. 타입스크립트 5.0에서는 const 수식어를 타입 파라미터 선언에 추가해서 const 스러운 타입 추론을 할 수 있게 되었다.

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 수식어는 변경 가능한 값들을 거부하지 않는다. 그리고 불변성 제약을 요구하지도 않는다. 변경 가능한 타입 제한은 놀라운 결과를 나타낼 것이다. 아래 코드를 보자.

declare function fnBad<const T extends string[]>(args: T): void;
// 'T' is still 'string[]' since 'readonly ["a", "b", "c"]' is not assignable to 'string[]'
fnBad(["a", "b" ,"c"]);

여기서, 추론된 후보 T는 readonly ["a", "b", "c"] 이다. 그리고 readonly 배열은 변경 가능한 것에서 사용될 수 없다. 이 경우, 추론은 제약으로 돌아오고 배열은 string[]으로 여겨진다. 그러면 호출은 성공적으로 나아간다.

함수에 대한 더 나은 정의는 readonly string[] 를 사용해야 한다.

declare function fnGood<const T extends readonly string[]>(args: T): void;
// T is readonly ["a", "b", "c"]
fnGood(["a", "b" ,"c"]);

extends에서 여러 개의 설정 파일을 지원

여러 개의 프로젝트를 관리할 때 여러 tsconfig.json 파일이 상속받을 수 있는 base 설정 파일이 있으면 도움이 된다. 이는 타입스크립트가 extends 필드를 compilerOptions로부터 필드를 복사해서 쓸 수 있게 지원하는 이유이기도 하다.

// packages/front-end/src/tsconfig.json
{
    "extends": "../../../tsconfig.base.json",
    "compilerOptions": {
        "outDir": "../lib",
        // ...
    }
}

그러나, 여러 개의 설정 파일을 extends하고 싶은 상황도 생길 수 있다. 예를 들어 npm으로 배포된 타입스크립트 base 설정 파일을 사용한다고 상상해보자. 만약 당신의 모든 프로젝트가 @tsconfig/strictest 패키지를 npm에서 사용한다고 했을 때, 여기에는 간단한 해결 방법이 있다. tsconfig.base.json 에서 tsconfig/strictest 를 상속받는 것이다.

// tsconfig.base.json
{
    "extends": "@tsconfig/strictest/tsconfig.json",
    "compilerOptions": {
        // ...
    }
}

이 방식은 어느 정도까진 동작한다. 만약 @tsconfig/strictest 를 사용하고 싶지 않는 프로젝트가 있다면, 수동으로 그 옵션을 끄거나, @tsconfig/strictest를 상속받지 않는 다른 버전의 tsconfig.base.json을 만들어 주어야 한다.

여기서 더 유연성을 가져가기 위해서, 타입스크립트 5.0은 extends 필드에 여러 개의 입력 값을 허용한다.

{
    "extends": ["a", "b", "c"],
    "compilerOptions": {
        // ...
    }
}

이와 같이 쓰는 것은 마치, c를 직접 상속받고, c가 b를 상속 받고, b가 a를 상속받는 것을 의미한다. 만약 여기에서 충돌이 발생한다면, 뒤에 있는 입력값이 유효하다.

아래의 예시에서, strictNullChecks와 noImplicitAny는 둘 다 마지막 tsconfig.json에서 가능하다.

// tsconfig1.json
{
    "compilerOptions": {
        "strictNullChecks": true
    }
}
// tsconfig2.json
{
    "compilerOptions": {
        "noImplicitAny": true
    }
}
// tsconfig.json
{
    "extends": ["./tsconfig1.json", "./tsconfig2.json"],
    "files": ["./index.ts"]
}

다른 예시로, 위에서 들었던 원래 예시를 이렇게 바꿔서 쓸 수도 있다.

// packages/front-end/src/tsconfig.json
{
    "extends": ["@tsconfig/strictest/tsconfig.json", "../../../tsconfig.base.json"],
    "compilerOptions": {
        "outDir": "../lib",
        // ...
    }
}

모든 enum은 유니온 enum이다.

타입스크립트 2.0에서 enum 리터럴 타입이 생겼고, enum 구성 요소들을 고유의 타입을 부여했다. 이는 enum 그 자체를 각각 구성 요소타입의 유니온 타입으로 전환시켰다.

// 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 요소에 고유 타입을 줌으로써 이 타입들이 부분적으로 실제 값들과 연관이 되어 있다는 점이다. 몇몇 경우에 이러한 값을 연산하는 것은 불가능하다. 예를 들면, 다음과 같이 enum 요소가 함수 호출에 의해 초기값을 가질 수도 있다.

enum E {
    Blah = Math.random()
}

타입스크립트가 이러한 이슈들을 마주했을 때, 이전 enum 정책으로 조용히 돌아갔다. 이는 유니온과 리터럴 타입으로 인한 모든 이점을 버린다는 것을 의미했다.

타입스크립트 5.0은 모든 enum을 유니온 enum으로 만들었는데, 각각의 계산된 멤버들에 고유의 타입을 생성했다. 이는 모든 enum들이 이제 좁아지고 그들의 멤버를 타입으로 참조할 수 있게 되었다. (TypeScript 5.0 manages to make all enums into union enums by creating a unique type for each computed member. That means that all enums can now be narrowed and have their members referenced as types as well.)

--moduleResolution 번들러

타입스크립트 4.7에서 node.js의 ECMAScript 모듈 검색을 간결하게 하기 위한 모델을 --module, --moduleResolution 모드를 통해 제시했다. 하지만, 이 모드는 제한사항이 많이 있었다. 예를 들어 Node.js의 ECMAScript 모듈에서 어떠한 상대적인 import라도 파일 익스텐션을 필요로 한다.

// entry.mjs
import * as utils from "./utils";     // ❌ wrong - we need to include the file extension.
import * as utils from "./utils.mjs"; // ✅ works

이에는 Node.js와 브라우저에서의 특정 이유가 있다. 이는 파일 검색을 빠르게 해 주고, 단순한 파일 서버에서 더 나은 동작을 만들어 주기 때문이다. 하지만 번들러를 쓰는 많은 개발자들에게 node16/nodenext 같은 설정은 제한 사항이 많아서 성가셨다.

대부분의 현대 번들러들은 ECMAScript 모듈과 CommonJS 검색 규칙을 혼합해서 사용한다. 예를 들어, 확장된 부분이 없는 import는 CommonJS 안에서도 잘 동작하지만, 패키지의 export 조건을 살펴보는 경우 import 조건을 선호한다.

번들러가 어떻게 동작하는지 모델링하기 위해, 타입스크립트에서는 --moduleResolution 번들러를 소개한다.

{
    "compilerOptions": {
        "target": "esnext",
        "moduleResolution": "bundler"
    }
}

만약 여러분이 Vite, esbuild, swc, webpack, parcel 같은 하이브리드 검색 전략을 사용하는 현대적인 번들러를 사용한다면, 새로운 번들러 옵션은 도움이 될 것이다.

반면, npm에서 발행할 라이브러리를 작성한다면, 번들러 옵션은 번들러 옵션을 쓰지 않는 사용자들과 호환성 이슈를 감출 수 있다. 따라서 이 경우, node16이나 nodenext 를 사용하는 옵션이 더 나은 길이다.

--verbatimModuleSyntax

기본적으로, 타입스크립트는 import를 생략하는 경우가 있다.

import { Car } from "./car";
export function drive(car: Car) {
    // ...
}

타입스크립트는 이 코드에서 import를 타입만 한다고 감지하고, import를 완전히 드랍한다. 이 코드의 자바스크립트 출력은 다음과 같을 것이다.

export function drive(car) {
    // ...
}

이는 대부분 문제가 없다. 하지만 엣지 케이스를 살펴보자. 예를 들어 import "./car" 와 같이 구문이 있다면 이는 완전히 드롭된다. 이는 사이드 이펙트가 있거나 없는 차이를 만든다.(That actually makes a difference for modules that have side-effects or not.)

타입스크립트 생략 방식은 다른 복잡성 계층을 더 가지고 있다. import 생략은 단지 import가 어떻게 사용되는지에만 영향을 받지 않는다. 때로는 값도 마찬가디로 참고할 수 있다. 따라서 다음과 같이 코드가 작성되어 있다면

export { Car } from "./car";

이건 보존되거나, 드랍된다. 클래스 같은 값으로 Car가 선언되었으면 자바스크립트에 보존되지만, 타입이나 인터페이스로 선언되었으면 드랍된다. 타입 수식어를 적어서 이러한 애매한 상황을 조금이나마 도울 수 있다.

// This statement can be dropped entirely in JS output
import type * as car from "./car";
// The named import/export 'Car' can be dropped in JS output
import { type Car } from "./car";
export { type Car } from "./car";

타입 수식어와 관련된 여러 디테일한 flag가 있었지만 사용하기가 어려웠다. 그래서 타입스크립트 5.0에서는 --verbatimModuleSyntax라는 옵션을 도입했다. 이 규칙은 훨씬 단순하다. type 수식어가 없는 import/export만 남는다. type 수식어가 있으면 드랍된다.

// Erased away entirely.
import type { A } from "a";
// Rewritten to 'import { b } from "bcd";'
import { b, type c, type d } from "bcd";
// Rewritten to 'import {} from "xyz";'
import { type xyz } from "xyz";

이 옵션 아래에서, 여러분이 보는 것이 여러분이 얻는 것이다.(what you see is what you get)

이 옵션을 통해 몇몇 이슈들을 더 명백하게 할 수 있다. 예를 들어 --module node16 아래에서 package.json의 타입 필드를 까먹는 것은 흔하다. 그 결과, 개발자들이 ES Module 대신 CommonJS 모듈을 사용하기 시작했다. 이 새로운 flag 는 사용하는 파일 타입에 의도적인 걸 보장할 것이다.(This new flag ensures that you’re intentional about the file type you’re using because the syntax is intentionally different.)

export type * 지원

타입스크립트 3.8에서 type-only import를 소개했는데, 새로운 구문은 export * from "module" 이나 export * as ns from "module" 같은 재 export를 허용하지 않았다. 타입스크립트 5.0에서는 두 가지 형태를 지원한다.

// models/vehicles.ts
export class Spaceship {
  // ...
}
// models/index.ts
export type * as vehicles from "./vehicles";
// main.ts
import { vehicles } from "./models";
function takeASpaceship(s: vehicles.Spaceship) {
  // ✅ ok - `vehicles` only used in a type position
}
function makeASpaceship() {
  return new vehicles.Spaceship();
  //         ^^^^^^^^
  // 'vehicles' cannot be used as a value because it was exported using 'export type'.
}

JSDoc에서 @satisfies 지원

satisfies 연산자는 타입스크립트 4.9에서 등장했다. 이 옵션은 타입 자체에 영향을 주지 않으면서 표현이 양립할 수 있다.

interface CompilerOptions {
    strict?: boolean;
    outDir?: string;
    // ...
}
interface ConfigSettings {
    compilerOptions?: CompilerOptions;
    extends?: string | string[];
    // ...
}
let myConfigSettings = {
    compilerOptions: {
        strict: true,
        outDir: "../lib",
        // ...
    },
    extends: [
        "@tsconfig/strictest/tsconfig.json",
        "../../../tsconfig.base.json"
    ],
} satisfies ConfigSettings;

타입스크립트의 myCompilerOption.extends는 배열로 선언되어 있다는 것을 안다. 왜냐하면 satisfies가 오브젝트 타입을 검증하는 동안, 강제로 CompilerOptions 로 바꾸어서 정보를 유실시키도록 하지는 않기 때문이다. extends를 map 하면 괜찮다.

declare function resolveConfig(configPath: string): CompilerOptions;
let inheritedConfigs = myConfigSettings.extends.map(resolveConfig);

많은 사람들은 타입스크립트를 JSDoc을 통해 자바스크립트 코드의 타입 체크 용도로 사용한다. 이는 타입스크립트 5.0에서 새로운 JSDoc을 지원하는 이유이다.

// @ts-check
/**
 * @typedef CompilerOptions
 * @prop {boolean} [strict]
 * @prop {string} [outDir]
 */
/**
 * @satisfies {CompilerOptions}
 */
let myCompilerOptions = {
    outdir: "../lib",
//  ~~~~~~ oops! we meant outDir
};

이는 우리 표현의 원래 타입을 보존하지만, 우리가 코드를 이후에 더 정확하게 쓸 수 있게 허락해준다.

// @ts-check
/**
 * @typedef CompilerOptions
 * @prop {boolean} [strict]
 * @prop {string} [outDir]
 */
/**
 * @typedef ConfigSettings
 * @prop {CompilerOptions} [compilerOptions]
 * @prop {string | string[]} [extends]
 */
/**
 * @satisfies {ConfigSettings}
 */
let myConfigSettings = {
    compilerOptions: {
        strict: true,
        outDir: "../lib",
    },
    extends: [
        "@tsconfig/strictest/tsconfig.json",
        "../../../tsconfig.base.json"
    ],
};
let inheritedConfigs = myConfigSettings.extends.map(resolveConfig);

JSDoc에서 @overload 지원

타입스크립트에서 함수의 오버로드를 명시할 수 있다.

// Our overloads:
function printValue(str: string): void;
function printValue(num: number, maxFractionDigits?: number): void;
// Our implementation:
function printValue(value: string | number, maximumFractionDigits?: number) {
    if (typeof value === "number") {
        const formatter = Intl.NumberFormat("en-US", {
            maximumFractionDigits,
        });
        value = formatter.format(value);
    }
    console.log(value);
}

 

타입스크립트 5.0에서는 JSDoc이 @overload 태그로 오버로드를 선언할 수 있게 해준다.

// @ts-check
/**
 * @overload
 * @param {string} value
 * @return {void}
 */
/**
 * @overload
 * @param {number} value
 * @param {number} [maximumFractionDigits]
 * @return {void}
 */
/**
 * @param {string | number} value
 * @param {number} [maximumFractionDigits]
 */
function printValue(value, maximumFractionDigits) {
    if (typeof value === "number") {
        const formatter = Intl.NumberFormat("en-US", {
            maximumFractionDigits,
        });
        value = formatter.format(value);
    }
    console.log(value);
}
// all allowed
printValue("hello!");
printValue(123.45);
printValue(123.45, 2);
printValue("hello!", 123); // error!

속도, 메모리, 패키지 사이즈 최적화

Breaking Changes & Deprecation

런타임 요구사항

타입스크립트는 ECMAScript 2018을 타겟한다. 이는 Node.js 유저의 경우 최소 버전을 10 이상으로 사용해야 함을 의미한다.

상대 연산자에 대한 암시적 강제 금지

function func(ns: number | string) {
  return ns * 4; // Error, possible implicit coercion
}

타입스크립트 5.0에서는 상대 연산자인 <, >, <=, >= 에도 적용된다.

function func(ns: number | string) {
  return ns > 4; // Now also an error
}
function func(ns: number | string) {
  return +ns > 4; // OK
}

Enum 오버홀

타입스크립트 enum은 예전부터 문제가 많았다. 5.0에서는 enum을 선언할 수 있는 복잡한 방법들을 간소화 하고, 이러한 문제들을 일부 해결했다.

첫 번째 에러

enum SomeEvenDigit {
    Zero = 0,
    Two = 2,
    Four = 4
}
// Now correctly an error
let m: SomeEvenDigit = 1;

두 번째 에러

enum Letters {
    A = "a"
}
enum Numbers {
    one = 1,
    two = Letters.A
}
// Now correctly an error
const t: number = Numbers.two;

--experimentalDecorators 아래의 생성자 안에서 파라미터 데코레이터를 위한 보다 정확한 타입 체킹

타입스크립트 5.0에서는 --experimentalDecorators 아래의 데코레이터에게 더 정확한 타입 체크를 해줄 수 있다. 이가 명확해 지는 부분은 데코레이터를 생성자 파라미터로 사용하는 지점이다.

export declare const inject:
  (entity: any) =>
    (target: object, key: string | symbol, index?: number) => void;
export class Foo {}
export class C {
    constructor(@inject(Foo) private x: any) {
    }
}

이 호출은 실패할 것이다. 왜냐하면 key는 string | symbol 을 기대하기 때문이다. 하지만 생성자 파라미터는 key를 undefiend로 받는다. 정확한 수정은 key의 타입을 inject 안으로 넣는 것이다. A reasonable workaround if you’re using a library that can’t be upgraded is is to wrap inject in a more type-safe decorator function, and use a type-assertion on key.