이펙티브 타입스크립트를 공부하면서 주관적으로 중요한 점들을 정리해봤다.
readonly Type[][][]
const a: readonly number[][][] = [];
// ERROR
a[0] = [[0]];
// OK
a[0][0] = [0];
// OK
a[0][0][0] = 0;
a[0][0][0]
이 에러가 발생해야 할 것 같은데, a[0]
에서 오류가 난다. 개인적으로 이 코드는 좀 덜 직관적이라고 생각한다.
사실, readonly number[][][]
가 타입스크립트에서 어떻게 인식되는지를 알면 헷갈리지는 않는다.
let a: readonly number[][][] = [];
let b: ReadonlyArray<number[][]> = [];
// OK
b = a;
그렇다. number[][]
에 대한 readonly
배열인 것이다. 따라서, 2차원 배열을 원소로 갖는 readonly
배열이므로, a[0]
에 값을 할당할 수 없다.
각 모든 차원의 값을 readonly
로 만들고 싶다면, 다음과 같이 하면 된다.
const a: readonly (readonly (readonly number[])[])[] = [];
// ERROR
a[0] = [[0]];
// ERROR
a[0][0] = [0];
// ERROR
a[0][0][0] = 0;
요점은 N
차원 배열의 경우, N-1
차원 배열을 원소로 갖는 배열을 표현하기 위해 Type[][][]
와 같이 표현하며, 이는 (Type[][])[]
임을 염두해두고 있으면 된다.
그러나 개인적으로 readonly Type[][][]
은 (readonly Type[])[][]
으로 적용되는게 더 직관적이라는 생각이 든다. 결국 우리가 최종적으로 사용하려는 것은 Type
원소이고, 이 원소가 readonly
가 되기를 기대하는게 일반적이지 않은가?
이 이슈가 받아들여지면 좋을 것 같은데...
물론 readonly Type[][][]
같은 헷갈리는 코드를 작성하느니, ReadonlyArray<Type[][]>
같이 쓰면 해결될 일이다.
any
, unknown
, never
자바스크립트는 동적타입언어이므로, 어떤 변수에 아무런 값이나 할당할 수 있다. 이는 개발의 유연함을 가져다 줘 빠른 개발이 가능하도록 하지만, 프로그램이 성장할수록 문제가 된다. 이를 해결하기 위해 타입스크립트가 등장한 것이다.
타입스크립트는 결국 자바스크립트로 변환되므로, 타입스크립트의 타입 체계에는 당연하게도 자바스크립트의 동적타입체계가 포함되어 있다.
any
타입스크립트에서 가장 조심하게 사용해야 하는 타입이다. 그 이름이 말해주듯, 자바스크립트의 동적타입을 나타낸다.
any
는 어떠한 타입에든 할당 가능하고, any
에 어떠한 타입이든 할당 가능하다. 즉, 타입에 대한 제약조건 자체가 사라진다.
어떠한 타입에든 할당 가능하다는 점이 가장 조심해야할 점인데, 이렇게 자유로운 타입의 값이 할당된 값을 코드 전반에서 사용하게 된다면 타입시스템이 무너질 위험이 있기 때문이다. 이는 타입스크립트를 쓰는 중요한 이유중 하나를 없애는 것과 같다.
따라서 any
를 사용함에 있어서(물론 최대한 사용하지 않는게 가장 좋지만) 가장 하지 말아야할 점을 하나만 꼽자면:
함수의 리턴 타입을
any
로 선언하지 말자
이 점을 가장 조심하면 될 것 같다.
unknown
any
대신 사용할 수 있는 타입이 바로 unknown
이다. unknown
은 any
의 가장 큰 단점을 해결한 타입으로, 바로 unknown
은 어떠한 타입에도 할당이 불가능하다는 점이다. 이 말인 즉슨, unknown
타입의 변수를 사용하고자 한다면 결국 타입 단언(as
)을 해야한다는 뜻이다. 물론 any
에 할당한다면 예외다.
const a: unknown = 10;
// ERROR
const b: number = a;
따라서, 함수의 리턴 타입을 선언할 때 정말 부득이한 경우에는 unknown
으로 선언해서 사용하는 것이 any
로 선언하는 것보다 훨씬 좋다.
never
타입의 이름에서도 알 수 있듯, 절대로 일어날 수 없는 경우다. 함수의 리턴타입으로 흔하게 사용되는 경우는 무조건 예외를 던지는 경우일 것이고, 그 외에 무한 루프인 경우일 것이다.
이 경우, 함수는 절대로 리턴되지 않을 것이므로 이 때 never
타입을 사용한다.
never
타입의 경우 unknown
과는 반대의 성질을 보이는데, never
에는 어떠한 타입의 값도 할당이 불가능하고, never
는 어떠한 타입에도 할당이 가능하다.
사실상 never
타입의 값은 존재하지 않을 것이므로, 이 값을 사용할 일은 없다.
T
를 어떤 타입이라고 했을 때, any
, unknown
, never
의 할당관계를 표로 정리해보면 다음과 같을 것이다.
Type | Type을 T에 할당 | T를 Type에 할당 |
---|---|---|
any | O | O |
unknown | X | O |
never | O | X |
{}
, object
자바스크립트에서 객체는 정말 중요하다. primitive type들조차도 래퍼 객체로 표현되는 경우도 있다. 거의 대부분의 경우, 값들은 객체로 다뤄질 수 있다.
자바스크립트의 자유분방한 객체 타입에 해당하는 두 타입이라고 한다면 {}
와 object
가 있다. 사실 요즘은 왠만하면 {}
는 잘 사용하지 않는다고 한다. 둘 다 기본적인 객체의 속성을 나타내지만 가장 큰 차이점은 primitive type의 포함 여부이다.
let a: {};
a = undefined; // Error
a = null; // Error
a = 0;
a = true;
a = '';
a = Symbol();
a = [];
a = {};
a = () => {};
let b: object;
b = undefined; // Error
b = null; // Error
b = 0; // Error
b = true; // Error
b = ''; // Error
b = Symbol(); // Error
b = [];
b = {};
b = () => {};
{}
가 object
보다 좀 더 많은 타입들을 수용한다. 정말로 일반적인 객체만을 사용해야 한다면 object
가 좀 더 나은 선택이 될 수 있다.
타입스크립트는 좀 더 엄격하게 자바스크립트를 사용할 수 있게 해주지만, 그럼에도 일반적인 정적타입언어보다는 훨씬 유연한 편이다.
일반적으로 정적타입언어들의 경우, Nominal type system을 기본으로 한다. 이는 타입을 구분하는 근간이 이름에 기반하는 것인데, 보통 일반적으로 생각하는 그 방식이 맞다.
Golang의 경우를 살펴보자.
// A와 B 타입을 int에 기반하는 타입으로 선언
type A int
type B int
// A 타입을 매개변수로 받는다.
func f(a A) {
//
}
// int 타입을 매개변수로 받는다.
func g(a int) {
//
}
func main() {
var a A = 1
var b B = 2
f(a)
f(b) // ERROR
g(a) // ERROR
g(b) // ERROR
}
사실상 기반 타입이 int
로 모두 같지만, Golang의 타입 시스템에서는 타입의 이름으로 타입이 구분되므로 허용되지 않는다.
Rust도 살펴보자.
struct A {
a: i32,
}
struct AA {
a: i32,
}
fn f(a: A) {
//
}
fn main() {
let a = A { a: 1 };
let aa = AA { a: 1 };
f(a);
f(aa); // error[E0308]: mismatched types
}
A
와 AA
의 구조는 완전히 동일하지만, 역시 함수 f
는 A
만을 허용한다. 하지만 타입스크립트는 결국 자바스크립트이고, 자바스크립트의 중요한 특징중 하나인 Duck typing을 위해 Structual type system을 사용한다.
type TA = {
a: string;
}
type TAA = {
a: string;
}
interface IA {
a: string;
}
interface IAA {
a: string;
}
function f(a: TA) {
//
}
const a: TA = { a: 'string' };
const aa: TAA = { a: 'string' };
const ia: IA = { a: 'string' };
const iaa: IAA = { a: 'string' };
// TA 타입을 매개변수로 받지만, 모두 OK
f(a);
f(aa);
f(ia);
f(iaa);
즉, 타입스크립트에서 실제로 중요한 것은 타입의 구조(혹은 모양)이고, 타입스크립트에서 타입의 이름은 그저 이에 대한 alias
일 뿐이다. 실제로 타입스크립트에서 type X = Y
문법은 type alias이다. 즉, 구조에 대해 타입 별칭을 지정할 뿐이다. 또한 interface I {}
역시 그저 객체의 구조(모양)을 서술할 뿐이다.
따라서 타입스크립트를 사용함에 있어, 가장 염두해야 할 점은 타입을 온전히 믿으면 안된다는 점이다. 우리가 타입스크립트의 타입시스템에서 기대하는 바는 최소한 이런 모양의 구조를 갖고 있을 가능성이 있다 정도이다.
가능성이 있다라는 표현도 중요한데, 타입스크립트에서는 type assertion
을 통해 얼마든지 다른 타입으로 인식하게 할 수 있기 때문에 실제로 그 타입이 아닐 수도 있기 때문이다(ex: x as any as T
).
타입스크립트는 구조적 타입 시스템을 가지고 있다. 따라서, 구조만 만족한다면 타입의 이름이 다를지라도 할당이 허용된다. 이는 위에서 알아봤다.
하지만 예외의 경우가 있는데, 바로 리터럴 표현식을 사용하는 경우이다.
interface Person {
name: string;
}
const person: Person = {
name: 'Nio',
age: 10, // Error: Property 'age' does not exist on type 'Person'
};
구조적 타입 시스템의 관점에서 보자면, person
변수에 할당되는 객체의 값은 어쨌든 name
프로퍼티는 포함하고 있으니 할당될 수 있어야 한다. 사실, 리터럴 표현식이 아닌 경우 실제로 할당이 가능하다.
interface Person {
name: string;
}
const person = {
name: 'Nio',
age: 10,
};
// OK
const p: Person = person;
이는 객체 리터럴 표현식의 경우, 예외적으로 excess property checking
이 동작하기 때문인데, 리터럴 표현식의 경우 개발자의 의도가 명확하기 때문에 타입의 모양이 정확히 일치하도록 강제하여 버그를 줄일 수 있게 한다.
따라서, 객체 리터럴을 사용하여 특정 타입의 값을 생성하고자 한다면 excess property checking
이 동작할 수 있도록 타입을 명시적으로 선언해주는 것이 좋다.
intersection type
, union type
동적타입언어의 경우, 변수에 값을 할당할 때 타입개념이 존재하지 않으므로 어떠한 값도 마음대로 할당할 수 있다. 타입스크립트 역시 이런게 가능하며, 이를 대표하는 타입이 바로 any
일 것이다. 하지만 any
는 타입안정성을 무너뜨리기 쉬우므로, 이를 보완하기 위해 타입의 가능성을 나타내는 타입시스템인 intersection type
과 union type
이 존재한다.
개인적으로 타입스크립트의 타입은
가능성
의 개념으로 보고 있다. 타입스크립트 핸드북에서는intersection type
과union type
은 기존 타입에서 새로운 타입을 구축하는 방식이라고 설명하고, 이펙티브 타입스크립트에서는 집합의 개념으로 설명하기도 한다. 각자 편한 방식의 멘탈모델로 언어를 이해하다 나중에 더 알맞는 모델로 이동하면 될 일이다.
intersection type
개인적으로 이름 때문에 헷갈렸는데, 보통 일반적으로 우리가 교집합을 생각하면 벤다이어그램을 그리고, 두 집합의 공통된 원소 부분을 생각하기 마련이다. 그렇게 생각하며 아래와 같은 코드를 보면 never
타입이라고 생각하기 쉽다.
type A = { a: string; };
type B = { b: string; };
// A와 B가 공유하는 구조가 없으니, `AB`는 존재할 수 없는 타입이겠네!(❌)
type AB = A & B;
하지만 실제로 AB
타입은 A
와 B
의 구조를 모두 포함하는 타입이 된다. 즉, 무의식적으로 타입의 프로퍼티를 집합의 원소라고 생각하기 때문인 것 같다. 하지만 조금 더 생각해보면, 우리는 타입에 대한 교집합을 구하는 것이지, 타입이 포함하는 프로퍼티에 대한 교집합을 구하는 것이 아니다.
따라서, A
와 B
의 교집합이 되는 타입은 두 구조적 모양을 동시에 가진 타입이 되어야만 한다.
// A타입과 B타입 둘 다 포함되는 타입이 곧 A & B 이다.
type AB = { a: string; b: string; }
union type
이는 나열한 타입들 중 하나의 타입이 될 수 있는 가능성을 나타내는 타입이다.
type A = B | C | D;
이 경우, A
는 B
혹은 C
혹은 D
중 하나가 될 수 있다는 뜻이다. 따라서 intersection type
보다 union type
의 범위가 더 넓은데, B & C & D
타입은 B | C | D
에 포함될 수 있지만, 그 역은 성립하지 않는다.
keyof (A|B)
, keyof (A&B)
keyof T
는 T
타입의 구조에서 key
값들을 union
타입으로 추출하는 타입 연산자이다. 이를 union
, intersection
타입과 같이 쓸 때 A|B
나 A&B
에 집중하면 그 결과가 헷갈릴 수가 있다.
예를 들어 아래의 코드를 보자.
type A = { a: string; aa: string; };
type B = { b: string; bb: string; };
type ABC = { a: string; b: string; c: string; };
type X = keyof (A|B); // never
type Y = keyof (A|ABC); // 'a'
A|B
타입의 경우, A
또는 B
둘 중 하나가 될 수 있는 구조의 모양을 가져야 한다. 그러면 왠지 'a' | 'aa' | 'b' | 'bb'
가 나와야 할 것 같다. 그런데 never
다. 왜일까? 다음의 경우를 살펴보자.
A|ABC
타입의 경우 그 결과가 'a'
이다. 즉, keyof
를 union
타입에 대해 적용하면 해당 타입이 공유하는 key
들을 union
타입으로 만들어 준다는 사실을 알 수 있다.
사실 이는 keyof
를 쓰는 이유를 생각해보면 답이 금방 나오는데, keyof
는 특정 타입의 key
들을 가져와 union
으로 만들어서, 추후에 안전하게 해당 프로퍼티에 접근하기 위함이고, 따라서 A|ABC
타입에 안전하게 접근할 수 있는 프로퍼티는 둘 다 존재하는 'a'
이므로 그 결과가 'a'
인 것이다.
마찬가지의 이유로 keyof (A|B)
가 never
인 이유를 이제는 알 수 있다. A|B
타입의 key
에 유효한 값이 없기 때문에 never
인 것이다.
그렇다면 keyof (A&B)
에 대해서도 그 결과를 예측할 수 있다. keyof (A&B)
가 바로 'a' | 'aa' | 'b' | 'bb'
일 것이다. A&B
타입은 A
와 B
의 모든 key
를 포함한 타입이므로, keyof
는 모든 key
를 열거할 것이다.
따라서, 다음과 같은 등식이 성립한다고 볼 수 있다.
keyof (A|B) = (keyof A) & (keyof B)
keyof (A&B) = (keyof A) | (keyof B)
Type predicate는 Type assertion으로 동작하므로, 이를 이용하면 Nominal typing을 흉내낼 수 있다.
예를 들어, 절대경로를 나타내는 문자열을 AbsolutePath
라는 타입으로 표현하는 것은 타입시스템만으로는 어렵다. 하지만 Type predicate를 이용한 트릭으로 이 목표를 달성할 수 있다.
// `string`이면서 `_brand` 프로퍼티를 가진 타입은 실제로 만들 수 없다.
type AbsolutePath = string & { _brand: 'abs' };
function listAbsolutePath(s: AbsolutePath) {
// ...
}
// Type predicate를 이용하여 리턴되는 타입을 `AbsolutePath`으로 단언하게 만든다.
function isAbsolutePath(s: string): s is AbsolutePath {
return s.startsWith('/');
}
const path = '/foo/bar';
if (isAbsolutePath(path)) {
// 타입 가드가 동작하면서 이 영역에서는 `path`가 `AbsolutePath`으로 단언되어 동작한다.
listAbsolutePath(path);
}
listAbsolutePath(path); // Error: Argument of type 'string' is not assignable to parameter of type 'AbsolutePath'.
즉, 오직 타입시스템에서만 존재가능한 타입을 정의하여 단언과 함께 타입가드를 이용하여 버그를 줄일 수 있다. 물론 단점이라고 한다면, 타입 가드 시스템을 반드시 이용해야만 하므로 타입 단언이 필수라는 점이다.
this
자바스크립트에서 this
는 일반적인 OOP 언어에서의 this
와는 다르게 동작하기 때문에, 익숙해지기 전까지는 꽤나 골치 아픈 문제를 일으킨다. 개인적으로도 자바스크립트의 this
동작은 언어설계의 실수라는 생각을 한다.
하지만 어쩌겠는가? 이미 그렇게 되어있는 것을. 타입스크립트에서는 자바스크립트의 this
를 좀 더 안전하게 사용하는 방법을 마련해준다.
this
는 함수의 호출 컨텍스트에 따라 가리키는 값이 달라지는 Dynamic scoping 특징을 갖고 있다. 자바스크립트에서는 호출주체에 따라 값이 달라진다고 생각하면 더 이해하기가 쉬운 것 같다.
타입스크립트에서는 이런 this
를 좀 더 안정적으로 사용할 수 있도록 해주는데, Python이나 Rust처럼 첫번째 매개변수로 this
를 받는 syntax를 사용하는 방법이다(물론 이 둘은 self
라고 받는다).
자바스크립트에서 this
로 인해 가장 많은 문제가 발생하는 부분은 바로 콜백함수에서 사용되는 this
일 것이다. 특히 리액트에서, React Hook이 도입되기 전 클래스 컴포넌트 시절에 아마 가장 많이 사람들을 괴롭히지 않았을까 한다.
잠깐 과거로 돌아가보자.
class Button extends Component {
constructor() {
super();
this.state = { counter: 0 };
}
handleClick() {
this.setState({ counter: this.state.counter++ });
}
render() {
return (
<div>
<span>{ this.state.counter }</span>
<button onClick={handleClick} />;
</div>
);
}
}
이 코드의 문제점은 바로 handleClick
이 호출될 때, 그 호출주체가 Button
컴포넌트가 아니라는 점이다. 따라서 this
에는 setState
가 존재하지 않고, 그에 따라 호출이 불가능하다는 에러가 발생할 것이다.
이를 해결하려면 handleClick
에 현재 this
컨텍스트를 바인딩해줘야 한다. 이는 Function.prototype.bind
혹은 Arrow function 등으로 해결할 수 있다.
즉, 함수에서 this
를 사용하고 있는데 이 함수가 만약 콜백함수라면, 이 함수가 호출되는 시점에 this
값이 달라질 수 있으므로 이를 반드시 기억해야만 한다.
타입스크립트에서는 이렇게 this
를 명시적으로 바인딩해야 하는 경우 아래와 같이 this
매개변수에 대한 타입을 명시하여 사용할 수 있다.
function attachKeydownEventHandler(
el: HTMLElement,
fn: (this: HTMLElement, e: KeyboardEvent) => void
) {
el.addEventListener('keydown', (e) => {
fn.call(el, e);
});
}
이제 fn
은 반드시 this
를 바인딩할 수 있는 Function.prototype
메서드들을 사용해야만 한다. 그냥 fn
을 호출하면 타입스크립트의 타입시스템은 에러를 발생시켜줄 것이다.
타입스크립트는 참 훌륭한 언어라는 생각이 든다. 덕타이핑을 Strutual type system으로 풀어내는 것도 참 괜찮은 방법이라는 생각이 들었다. Go
나 Rust
의 경우 Nominal type system임에도 덕타이핑을 각자의 방식대로 훌륭하게 풀어냈다.
프로그래밍의 핵심은 덕타이핑일까? 생각해보면 프로그래밍에서 추상화가 정말 중요한데, 이게 결국 덕타이핑과 그 결이 비슷하지 않은가?
이펙티브 타입스크립트는 3.8버전을 기준으로 작성됐으므로 그 이후에 추가된 타입스크립트의 스펙들에 대해서도 앞으로 더 공부해야겠다. 최근에 타입스크립트 5.0이 나왔는데, 갈 길이 아직 멀다.