티스토리 뷰
Type-Only Imports and Exports
이 기능은 대부분의 경우 필요하지 않은 기능이지만, 만약 --isolatedModules 옵션이나 타입스크립트, babel의 transpileModule API를 사용해 컴파일 할 때 이슈가 발생한다면, 이 기능이 필요할 수 있습니다.
Typescript는 타입을 참조하기 위해 JavaScript의 import 문법을 재사용 사용합니다. 예를 들어, 아래의 예제에서 우리는 순수 TypeScript 타입인 Options과 함께 JavaScript 변수인 doThing을 import 합니다.
// ./foo.ts
interface Options {
// ...
}
export function doThing(options: Options) {
// ...
}
// ./bar.ts
import { doThing, Options } from "./foo.js";
function doThingBetter(options: Options) {
// do something twice as good
doThing(options);
doThing(options);
}
TypeScript는 import elision 기능을 제공하여 JavaScript로 컴파일 될 때 타입으로 사용되는 것들을 모두 지워버립니다. JavaScript에서는 type이 없기 때문이죠. 위의 예제의 경우 Options는 타입으로써 사용되는 것을 확인하고, 자동적으로 import를 지워버립니다. 따라서 위 예제의 컴파일 결과는 아래와 같습니다.
// ./foo.js
export function doThing(options: Options) {
// ...
}
// ./bar.js
import { doThing } from "./foo.js";
function doThingBetter(options: Options) {
// do something twice as good
doThing(options);
doThing(options);
}
이 기능은 대부분의 경우 훌륭하게 사용되지만 몇몇의 경우 문제가 됩니다.
가장 먼저, export 되는 것이 변수인지 타입인지 모호한 경우가 몇가지 있습니다. 아래 예시의 경우 MyThing이 변수인지 타입인지 알 수 없습니다.
import { MyThing } from "./some-module.js";
export { MyThing };
우리가 위 예제에서 export 된 MyThing이 변수로 사용되는지, 타입으로 사용되는지 알 수 있는 방법이 없습니다. Babel과 TypeScript의 transpileModule API 도 마찬가지로 MyThing이 타입일 경우 제대로 동작하지 않는 코드를 만들어 낼 것 입니다. 그리고 TypeScript의 isolatedModules 플래그는 이것이 문제가 될 것이라고 알려줄 것 입니다. 진짜 문제는 "아니야, 이건 타입으로만 사용될 거니까 지워져야해" 라고 알려줄 방법이 없다는 것 입니다. 따라서 import elision 기능만으로는 부족한 부분이 있습니다.
또 다른 이슈는 TypeScript의 import elision 기능은 타입을 import 하는 구문이라면 모두 제거해버립니다. 이는 부수적인 효과를 가지고 있는 모듈을 import 할 때 문제가 발생할 수 있습니다. 따라서 유저들은 원하는 동작을 위해 두 번째 import 선언을 추가해야 합니다.
// This statement will get erased because of import elision.
import { SomeTypeFoo, SomeOtherTypeBar } from "./module-with-side-effects";
// This statement always sticks around.
import "./module-with-side-effects";
구체적인 예시로는 Angular.js 같은 프레임워크에서 찾아볼 수 있습니다. Angular.js에서 services 는 전역적으로 등록되고, 사용하는 쪽에서 등록된 서비스를 주입받게 됩니다. 사용하는 쪽에서 이 서비스들은 타입으로 사용되기 위해 import 합니다. 결과적으로 ./service.js 는 절대로 실행되지 않고 런타임에 문제가 발생합니다.
// ./service.ts
export class Service {
// ...
}
register("globalServiceId", Service);
// ./consumer.ts
import { Service } from "./service.js";
inject("globalServiceId", function (service: Service) {
// do stuff with Service
});
이러한 이슈가 발생하는 것들을 해결하기 위해 어떤 것들이 import 되고 지워져야 하는지 유저들이 세밀한 조작을 할 수 있는 문법이 추가되었습니다.
TypeScript 3.8에서는 이에 대한 해결책으로 type-only imports and exports 문법이 추가되었습니다.
import type { SomeThing } from "./some-module.js";
export type { SomeThing };
import type 은 타입 type annoations과 declarations 을 위해 사용되는 정의만 import 하게 됩니다. 이는 항상 지워지고, 런타임에 남은 잔재물이 없습니다. export type 도 비슷하게 타입을 추론할 때에만 사용되는 export만 제공합니다. 그리고 이 또한 컴파일할 때 지워집니다.
이는 기억해야할 중요한 점입니다. 클래스는 런타임에는 변수를 가지고 design-time에는 타입을 가집니다. 그리고 이를 상황에 맞게 사용합니다. 만약 클래스를 import 할 때 import type 을 사용한다면, 상속과 같은 것들을 할 수 없습니다.
import type { Component } from "react";
interface ButtonProps {
// ...
}
class Button extends Component<ButtonProps> {
// ~~~~~~~~~
// error! 'Component' only refers to a type, but is being used as a value here.
// ...
}
만약 Flow를 사용해본 경험이 있다면, 이 문법은 상당히 비슷하게 느껴질 것입니다. 한 가지 다른 차이점은 모호하게 만들 수 있는 코드를 피하기 위해 몇가지 제한 사항을 추가했다는 것입니다.
// Is only 'Foo' a type? Or every declaration in the import?
// We just give an error because it's not clear.
import type Foo, { Bar, Baz } from "some-module";
// ~~~~~~~~~~~~~~~~~~~~~~
// error! A type-only import can specify a default import or named bindings, but not both.
런타임에 사용되지 않는 imports 를 제어하기 위한 새로운 컴파일러 importsNotUsedAsValues flag도 추가되었습니다. 이 플래그는 3개의 설정 값 중 하나를 사용할 수 있습니다.
- remove : 기존 컴파일러의 동작과 동일하게 런타임에 사용되지 않는 imports 들을 제거합니다.
- preserve : 타입 정보는 제거 되지만, 해당 모듈의 imports 는 유지합니다. 따라서 위의 imports/side-effects 문제상황을 해결할 수 있습니다.
- error : 이는 preserve 옵션과 동일하게 동작하지만 타입으로써만 import를 사용했을 때에 오류를 발생시킵니다. 만약 변수가 아닌 것들이 우연하게 import 되는 것을 보호하고 싶을 때 유용할 것 입니다.
해당 기능에 대한 더 많은 정보를 위해서 pull request 와 연관된 변경사항 등을 살펴볼 수 있습니다.
ECMAScript Private Fields
TypeScript 3.8은 ECMAScript의 private fields 를 지원합니다. (state-3 class fields proposal)
class Person {
#name: string
constructor(name: string) {
this.#name = name;
}
greet() {
console.log(`Hello, my name is ${this.#name}!`);
}
}
let jeremy = new Person("Jeremy Bearimy");
jeremy.#name
// ~~~~~
// Property '#name' is not accessible outside class 'Person'
// because it has a private identifier.
private fields 는 몇가지 규칙이 있습니다.
- Private field의 이름은 # 으로 시작해야 합니다.
- 모든 private field name은 해당 private field를 가지고 있는 class에 고유하게 범위가 지정됩니다.
- public, private 같은 TypeScript의 접근 제한자는 private fields에 사용할 수 없습니다.
- private fields 해당 클래스의 외부에서 접근되거나, 감지조차 될 수 없습니다. -- JS 유저들이라 하더라도! 이를 hard privacy 라고 부릅니다.
private field가 주는 이점은 hard privacy 외에도, 해당 private field 를 가지고 있는 class에 고유하게 범위가 지정된다는 점 입니다. 예시로 일반적인 프로퍼티 선언은 자식 클래스에서 덮어씌워지기 쉽습니다.
class C {
foo = 10;
cHelper() {
return this.foo;
}
}
class D extends C {
foo = 20;
dHelper() {
return this.foo;
}
}
let instance = new D();
// 'this.foo' refers to the same property on each instance.
console.log(instance.cHelper()); // prints '20'
console.log(instance.dHelper()); // prints '20'
하지만 private fields를 사용한다면 이에 대해 걱정할 필요가 없어집니다. 각 private field의 이름은 해당하는 클래스에서 고유하게 유지됩니다.
class C {
#foo = 10;
cHelper() {
return this.#foo;
}
}
class D extends C {
#foo = 20;
dHelper() {
return this.#foo;
}
}
let instance = new D();
// 'this.#foo' refers to a different field within each class.
console.log(instance.cHelper()); // prints '10'
console.log(instance.dHelper()); // prints '20'
주의 깊게 봐야할 또 다른 점은 다른 타입의 private field에 접근한다면 TypeError가 발생합니다. (private field 가 있는 타입끼리는 덕타이핑이 안된다)
class Square {
#sideLength: number;
constructor(sideLength: number) {
this.#sideLength = sideLength;
}
equals(other: any) {
return this.#sideLength === other.#sideLength;
}
}
const a = new Square(100);
const b = { sideLength: 100 };
// Boom!
// TypeError: attempted to get private field on non-instance
// This fails because 'b' is not an instance of 'Square'.
console.log(a.equals(b));
마지막으로, 순수 .js 파일에서 사용할 경우 private fields는 항상 할당 되기 전에 선언되어야 합니다.
class C {
// No declaration for '#foo'
// :(
constructor(foo: number) {
// SyntaxError!
// '#foo' needs to be declared before writing to it.
this.#foo = foo;
}
}
JavaScript는 항상 선언되지 않은 프로퍼티에 접근할 수 있도록 해주었고, TypeScript는 항상 속성을 정의해야 했습니다. 하지만 private fields를 사용한다면 JavaScript, TypeScript 상관 없이 항상 선언이 필요합니다.
class C {
/** @type {number} */
#foo;
constructor(foo: number) {
// This works.
this.#foo = foo;
}
}
더 많은 정보를 위해 pull request 를 살펴볼 수 있습니다.
그럼 private 키워드와 hash(#)중 무엇을 사용해야 하나?
상황에 따라 적절하게 골라서 사용해야 합니다.
TypeScript의 private 접근자는 항상 지워집니다. 따라서 런타임에는 일반적인 property 과 동일하게 행동하며, 이 프로퍼티가 private 접근자로 선언되었다고 알려줄 방법이 없습니다. private 키워드를 사용한다면 privacy는 compile-time/design-time에서만 적용됩니다. 컴파일 이후에는 JavaScript 사용자의 의도에 맡기게 됩니다.
class C {
private foo = 10;
}
// This is an error at compile time,
// but when TypeScript outputs .js files,
// it'll run fine and print '10'.
console.log(new C().foo); // prints '10'
// ~~~
// error! Property 'foo' is private and only accessible within class 'C'.
// TypeScript allows this at compile-time
// as a "work-around" to avoid the error.
console.log(new C()["foo"]); // prints '10'
이러한 "soft privacy" 는 일시적으로 특정 API에 접근 권한이 없는 유저들에게 기능을 제공하거나, 모든 런타임에서 동작할 수 있도록 할 때 유용합니다.
반면에, ECMAScript의 hash(#) 는 완벽하게 클래스 외부에서 해당 프로퍼티에 접근할 수 없도록 만들어줍니다.
class C {
#foo = 10;
}
console.log(new C().#foo); // SyntaxError
// ~~~~
// TypeScript reports an error *and*
// this won't work at runtime!
console.log(new C()["#foo"]); // prints undefined
// ~~~~~~~~~~~~~~~
// TypeScript reports an error under 'noImplicitAny',
// and this prints 'undefined'.
이러한 Hard privacy는 아무도 내부에 어떠한 것도 가져갈 수 없다는 것을 강력하게 보장할 때 유용합니다. 만약 당신이 라이브러리의 개발자라면 private field를 지우거나 이름을 바꾸는 것이 사용자들에게는 어떠한 충돌도 발생시키지 않는 다는 것 입니다.
위에서 언급했듯이, 또 다른 이점은 hash(#)를 사용함으로써 서브클래싱이 더 쉬워질 수 있습니다. hash(#)를 사용한 private fields들은 하위 클래스에서 이름이 충돌할 걱정을 하지 않아도 됩니다. TypeScript의 private 제한자로 선언되었다면, 하위 클래스를 선언할 때에 여전히 상위 클래스의 프로퍼티를 재정의하진 않았는지 주의해야 합니다.
한가지 더 생각해봐야 할 점은 어디에서 당신의 코드가 실행되는지 입니다. TypeScript는 ECMAScript 2015(ES6) 또는 그 이상을 타겟으로 하지 않으면 hash(#) private 기능을 지원할 수 없습니다. 이는 hash(#)을 사용할 때 ECMAScript 내부적으로 ES6이상 부터 추가된 WeakMap을 사용하여 private field를 보호하기 때문입니다. WeakMap은 하위 버전(ES6 이하)에서 메모리 릭을 발생시키지 않으면서 대체(polyfilled)할 방법이 없습니다. 반면에 TypeScript의 private 키워드 선언 프로퍼티는 모든 타겟에서 동작할 수 있습니다. ECMAScript 3일지라도!
마지막 고려사항은 속도에 관한 문제입니다: TypeScript의 private 제한자로 정의된 프로퍼티는 다른 일반적인 프로퍼티와 다르지 않습니다. 따라서 이에 접근할 때 다른 프로퍼티에 접근 할 때와 같이 빠릅니다. 대조적으로 hash(#) private field는 내부적으로 WeakMap을 사용하기 때문에 사용할 때 비교적 느립니다. 몇몇 런타임에서는 # private field의 구현을 최적화 시켰고, WeekMap 구현체보다는 빠를 수 있지만 모든 런타임에 해당하는 이야기는 아닙니다.
export * as ns 문법
특정 모듈의 모든 멤버를 하나의 멤버처럼 만들고 export 할 때 종종 아래와 같은 코드를 작성합니다.
import * as utilities from "./utilities.js";
export { utilities };
이러한 패턴을 지원하기 위해 최근 ECMAScript 2020 에 새로운 문법이 추가되었습니다.
export * as utilities from "./utilities.js";
TypeScript 3.8에서도 해당 문법이 추가되었습니다. 만약 당신의 모듈의 컴파일 target이 es2020보다 하위 버전이라면, 타입스크립트 컴파일 결과로는 첫 번째 예시와 같이 출력됩니다.
추가적인 정보는 pull request 에서 확인해볼 수 있습니다.
Top-Level await
대부분의 현대 JavaScript환경은 I/O와 같은 작업을 비동기방식으로 제공합니다. 이러한 API들은 이러한 비동기 작업들을 Promise로 반환합니다. 작업들을 non-blocking으로 만듬으로써 많은 장점을 얻을 수 있기 때문입니다.
fetch("...")
.then(response => response.text())
.then(greeting => { console.log(greeting) });
JavaScript 개발자들은 Promises 를 사용할 때 .then 체인을 기피하기 위해 async 함수 안에서 await 을 사용하도록 정의합니다. 그리고 그 함수를 즉시 호출합니다.
async function main() {
const response = await fetch("...");
const greeting = await response.text();
console.log(greeting);
}
main()
.catch(e => console.error(e))
하지만 TypeScript 3.8 부터는 async 함수로 감싸는 것도 필요하지 않습니다. ECMAScript에 새롭게 추가되는 편리한 기능인 "top-level await" 를 사용할 수 있기 때문입니다.
이전의 JavaScript에서는 (비슷한 기능을 가진 대부분의 다른 언어들도 마찬가지로), await은 오직 async 함수 내부에서만 사용될 수 있었습니다. 하지만 top-level await은 모듈의 top level 에서 await를 사용 할 수 있게 해 줍니다.
const response = await fetch("...");
const greeting = await response.text();
console.log(greeting);
// Make sure we're a module
export {};
top-level await 은 오직 모듈의 top level에서만 동작합니다. 그리고 파일들은 오직 import, export 를 통해 발견되었을 때에만 모듈로 간주됩니다.
Top level await 는 모든 환경에서 동작하지 않습니다. 현재로써는 compile target을 es2017 혹은 그 이상으로, module은 esnext 혹은 system으로 지정 해야 사용할 수 있습니다.
원본 pull request
JSDoc Property Modifiers
TypeScript 3.8은 allowJs flag를 사용함으로써 JavaScript 파일을 지원합니다. 또한 checkJs 옵션 이나 //@ts-check 주석을 통해 JavaScript 파일의 타입 체킹을 할 수 있습니다.
JavaScript 파일은 타입 체킹을 위한 전용 문법을 가지고 있지 않기 때문에 TypeScript는 JSDoc을 활용합니다. TypeScript 3.8은 프로퍼티를 위해 사용되는 몇가지 JSDoc의 태그를 이해합니다.
첫 번째는 접근 제한자 입니다: @public, @private, @protected 이 태그들은 각각 TypeScript에서 public, private, protected 접근 제한자와 동일하게 동작합니다.
// @ts-check
class Foo {
constructor() {
/** @private */
this.stuff = 100;
}
printStuff() {
console.log(this.stuff);
}
}
new Foo().stuff;
// ~~~~~
// error! Property 'stuff' is private and only accessible within class 'Foo'.
다음으로, @readonly 제한자도 추가되었습니다.
// @ts-check
class Foo {
constructor() {
/** @readonly */
this.stuff = 100;
}
writeToStuff() {
this.stuff = 200;
// ~~~~~
// Cannot assign to 'stuff' because it is a read-only property.
}
}
new Foo().stuff++;
// ~~~~~
// Cannot assign to 'stuff' because it is a read-only property.
이 외에 추가적인 변경사항은 아래에 원문을 참조하시면 됩니다.
원문