[TS] 좋은놈, 어려운놈, 이상한놈

undefcat·2023년 3월 19일
0

Typescript

목록 보기
1/2
post-thumbnail

이펙티브 타입스크립트를 공부하면서 주관적으로 중요한 점들을 정리해봤다.

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이다. unknownany의 가장 큰 단점을 해결한 타입으로, 바로 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의 할당관계를 표로 정리해보면 다음과 같을 것이다.

TypeType을 T에 할당T를 Type에 할당
anyOO
unknownXO
neverOX

{}, 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가 좀 더 나은 선택이 될 수 있다.


Structual type system

타입스크립트는 좀 더 엄격하게 자바스크립트를 사용할 수 있게 해주지만, 그럼에도 일반적인 정적타입언어보다는 훨씬 유연한 편이다.

일반적으로 정적타입언어들의 경우, 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
}

AAA의 구조는 완전히 동일하지만, 역시 함수 fA만을 허용한다. 하지만 타입스크립트는 결국 자바스크립트이고, 자바스크립트의 중요한 특징중 하나인 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).


Excess property checking

타입스크립트는 구조적 타입 시스템을 가지고 있다. 따라서, 구조만 만족한다면 타입의 이름이 다를지라도 할당이 허용된다. 이는 위에서 알아봤다.

하지만 예외의 경우가 있는데, 바로 리터럴 표현식을 사용하는 경우이다.

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 typeunion type이 존재한다.

개인적으로 타입스크립트의 타입은 가능성의 개념으로 보고 있다. 타입스크립트 핸드북에서는 intersection typeunion type은 기존 타입에서 새로운 타입을 구축하는 방식이라고 설명하고, 이펙티브 타입스크립트에서는 집합의 개념으로 설명하기도 한다. 각자 편한 방식의 멘탈모델로 언어를 이해하다 나중에 더 알맞는 모델로 이동하면 될 일이다.

intersection type

개인적으로 이름 때문에 헷갈렸는데, 보통 일반적으로 우리가 교집합을 생각하면 벤다이어그램을 그리고, 두 집합의 공통된 원소 부분을 생각하기 마련이다. 그렇게 생각하며 아래와 같은 코드를 보면 never 타입이라고 생각하기 쉽다.

type A = { a: string; };
type B = { b: string; };

// A와 B가 공유하는 구조가 없으니, `AB`는 존재할 수 없는 타입이겠네!(❌)
type AB = A & B;

하지만 실제로 AB 타입은 AB의 구조를 모두 포함하는 타입이 된다. 즉, 무의식적으로 타입의 프로퍼티를 집합의 원소라고 생각하기 때문인 것 같다. 하지만 조금 더 생각해보면, 우리는 타입에 대한 교집합을 구하는 것이지, 타입이 포함하는 프로퍼티에 대한 교집합을 구하는 것이 아니다.

따라서, AB의 교집합이 되는 타입은 두 구조적 모양을 동시에 가진 타입이 되어야만 한다.

// A타입과 B타입 둘 다 포함되는 타입이 곧 A & B 이다.
type AB = { a: string; b: string; }

union type

이는 나열한 타입들 중 하나의 타입이 될 수 있는 가능성을 나타내는 타입이다.

type A = B | C | D;

이 경우, AB 혹은 C 혹은 D 중 하나가 될 수 있다는 뜻이다. 따라서 intersection type보다 union type의 범위가 더 넓은데, B & C & D 타입은 B | C | D에 포함될 수 있지만, 그 역은 성립하지 않는다.

keyof (A|B), keyof (A&B)

keyof TT 타입의 구조에서 key 값들을 union 타입으로 추출하는 타입 연산자이다. 이를 union, intersection 타입과 같이 쓸 때 A|BA&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'이다. 즉, keyofunion 타입에 대해 적용하면 해당 타입이 공유하는 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 타입은 AB의 모든 key를 포함한 타입이므로, keyof는 모든 key를 열거할 것이다.

따라서, 다음과 같은 등식이 성립한다고 볼 수 있다.

keyof (A|B) = (keyof A) & (keyof B)
keyof (A&B) = (keyof A) | (keyof B)

Type predicate를 이용한 Nominal typing

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으로 풀어내는 것도 참 괜찮은 방법이라는 생각이 들었다. GoRust의 경우 Nominal type system임에도 덕타이핑을 각자의 방식대로 훌륭하게 풀어냈다.

프로그래밍의 핵심은 덕타이핑일까? 생각해보면 프로그래밍에서 추상화가 정말 중요한데, 이게 결국 덕타이핑과 그 결이 비슷하지 않은가?

이펙티브 타입스크립트는 3.8버전을 기준으로 작성됐으므로 그 이후에 추가된 타입스크립트의 스펙들에 대해서도 앞으로 더 공부해야겠다. 최근에 타입스크립트 5.0이 나왔는데, 갈 길이 아직 멀다.

profile
undefined cat

0개의 댓글