TypeScript를 언어 자체를 바꾸지 않고 기본적으로 불변으로 만들 수 있는지 탐구한 실험기. 내장 라이브러리를 교체해 배열과 Record(그리고 Map/Set 등)는 기본 불변으로 만드는 데 성공했지만, 일반 객체는 실패하여 해결책을 구한다.
저는 변수들이 기본적으로 불변인 프로그래밍 언어를 좋아합니다. 예를 들어 Rust에서는 let이 불변 변수를, let mut이 가변 변수를 선언합니다. 저는 오래전부터 TypeScript 같은 다른 언어에서도 이것을 원했는데, TypeScript는 기본적으로 가변적—제가 원하는 것의 정반대입니다!
저는 궁금했습니다: TypeScript 값을 기본적으로 불변으로 만들 수 있을까요?
제 목표는 TypeScript 자체를 바꾸지 않고, 순수하게 TypeScript만으로 이걸 해내는 것이었습니다. 즉, 린트 규칙이나 다른 도구는 사용하지 않겠다는 뜻이죠. 가능한 한 “순수한” 해법을 원했고… 게다가 그 편이 더 재미있어 보였습니다.
저는 저녁 시간을 들여 시도해 봤습니다. 결국 실패했지만, 진전은 있었습니다! 배열과 Record는 기본적으로 불변으로 만들었지만, 일반 객체에는 성공하지 못했습니다. 이걸 완전히 해내신다면 꼭 연락 주세요—정말 알고 싶습니다!
TypeScript에는 Array, Date, String 같은 JavaScript API를 위한 내장 타입 정의가 있습니다. 만약 TSConfig에서 target이나 lib 옵션을 바꿔본 적이 있다면, 어떤 정의들이 포함될지를 조정해 본 셈입니다. 예를 들어, 최신 런타임을 목표로 한다면 “ES2024” 라이브러리를 추가할 수도 있죠.
제 목표는 내장 라이브러리를 기본-불변 대체물로 바꾸는 것이었습니다.
첫 단계는 어떤 내장 라이브러리도 사용하지 않게 만드는 것이었습니다. 저는 TSConfig에서 noLib 플래그를 다음과 같이 켰습니다:
{
"compilerOptions": {
"noLib": true
}
}
그리고 아주 간단한 스크립트를 작성해 test.ts에 넣었습니다:
console.log("Hello world!");
tsc를 실행하자, 오류가 잔뜩 나왔습니다:
Cannot find global type 'Array'.
Cannot find global type 'Boolean'.
Cannot find global type 'Function'.
Cannot find global type 'IArguments'.
Cannot find global type 'Number'.
Cannot find global type 'Object'.
Cannot find global type 'RegExp'.
Cannot find global type 'String'.
진행 상황입니다! 이제 TypeScript의 기본 라이브러리를 완전히 제거하는 데 성공했음을 알 수 있었습니다. String이나 Boolean 같은 핵심 타입을 찾지 못하고 있으니까요.
이제 대체물을 쓸 차례입니다.
이 프로젝트는 프로토타입이었습니다. 따라서 형식 검사만 통과하는 최소한의 해법으로 시작했습니다. 잘 만들 필요는 없었죠!
lib.d.ts를 만들고 다음을 넣었습니다:
// In lib.d.ts:
declare var console: any;
interface Boolean {}
interface Function {}
interface IArguments {}
interface Number {}
interface RegExp {}
interface String {}
interface Object {}
// TODO: We'll update this soon.
interface Array<T> {}
이제 tsc를 실행했을 때 오류가 사라졌습니다! TypeScript가 필요로 하는 내장 타입들을 모두 정의했고, 더미 console 객체도 만들었습니다.
보시다시피, 이 해법은 프로덕션에선 비현실적입니다. 우선 이 인터페이스들에는 어떤 프로퍼티도 없습니다! 예를 들어 "foo".toUpperCase() 같은 것은 정의되어 있지 않죠. 괜찮습니다. 지금은 프로토타입이니까요. 프로덕션용이라면 이런 것들을 전부 정의해야 할 텐데—지루하겠지만, 원리적으로는 간단합니다.
저는 테스트 주도 개발 스타일로 접근하기로 했습니다. 타입 검사를 통과해야 하는 코드를 먼저 쓰고, 그게 실패 하는 것을 본 다음, 고치는 식으로요.
test.ts를 다음처럼 바꿨습니다:
// In test.ts:
const arr = [1, 2, 3];
// Non-mutation should be allowed.
console.log(arr[1]);
console.log(arr.map((n) => n + 1));
// @ts-expect-error Mutation should not be allowed.
arr[0] = 9;
// @ts-expect-error Mutation should not be allowed.
arr.push(4);
이 코드는 세 가지를 테스트합니다:
arr[1]이나 arr.map() 같은 비변이 연산은 허용되어야 합니다.arr[1] = 9 같은 배열을 변이시키는 연산은 금지되어야 합니다.tsc를 실행해 보니, 두 가지 문제가 보였습니다:
arr[0] = 9가 허용되고 있습니다. 그래서 거기에 붙은 @ts-expect-error가 사용되지 않습니다.arr.map이 존재하지 않습니다.그래서 lib.d.ts의 Array 타입을 다음처럼 고쳤습니다:
// In lib.d.ts:
interface Array<T> {
readonly [n: number]: T;
map<U>(
callbackfn: (value: T, index: number, array: readonly T[]) => U,
thisArg?: any
): U[];
}
프로퍼티 접근자—readonly [n: number]: T 줄—는 배열 프로퍼티에 숫자 인덱스로 접근할 수 있지만 읽기 전용임을 TypeScript에 알려줍니다. 이제 arr[1]은 가능하지만 arr[1] = 9는 불가능해야 합니다.
map 메서드 정의는 TypeScript 소스 코드에서 변경 없이 그대로 가져왔습니다(자동 포매팅 정도만). 이제 arr.map()을 호출할 수 있어야 합니다.
push는 정의하지 않았다는 점에 주목하세요. 우리는 불변 배열에서 그걸 호출하면 안 되니까요!
다시 tsc를 돌려 보니… 성공! 오류가 없습니다! 이제 불변 배열을 갖게 됐습니다!
이 시점에서, 추가 애너테이션 없이 모든 배열을 불변으로 만들도록 TypeScript를 구성할 수 있음을 보였습니다. readonly string[]이나 ReadonlyArray<number>가 필요 없습니다! 즉, 기본적으로 어느 정도 불변성을 얻었습니다.
물론 여기의 코드는(이 글 전체와 마찬가지로) 단순합니다. filter()나 join(), forEach() 같은 다른 배열 메서드가 아주 많습니다! 프로덕션 품질로 만든다면 읽기 전용 배열 메서드 전체를 빠짐없이 정의했을 겁니다.
하지만 지금은 가변 배열로 넘어갈 준비가 되었습니다.
저는 불변성을 선호하지만, 가끔은 가변 배열을 정의할 필요도 있습니다. 그래서 테스트를 하나 더 만들었습니다:
// In test.ts:
const arr = [1, 2, 3] as MutableArray<number>;
arr[0] = 9;
arr.push(4);
배열을 가변으로 만들려면 약간의 추가 작업이 필요합니다. 즉, 기본값이 아니라는 뜻이죠.
TypeScript가 MutableArray를 찾을 수 없다고 해서, 다음처럼 정의했습니다:
// In lib.d.ts:
interface MutableArray<T> extends Array<T> {
[n: number]: T;
push(...items: T[]): number;
}
그리고 역시 형식 검사를 통과했습니다!
이제 불변 배열과 가변 배열을 모두 갖췄고, 기본은 불변입니다. 단순하긴 하지만, 이 개념 증명에는 충분합니다!
저에게는 무척 흥미로웠습니다. 최소한 배열에 대해서는, 다른 도구 없이 언어를 포크하지 않고도 TypeScript를 기본적으로 불변에 가깝게 구성할 수 있었습니다.
더 많은 것들도 불변으로 만들 수 있을까요?
Record에도 동일하게배열을 넘어설 수 있을지 보고 싶었습니다. 다음 목표는 TypeScript 유틸리티 타입인 Record였습니다. 그래서 배열과 비슷한 테스트 케이스 두 개를 더 만들었습니다:
// In test.ts:
// Immutable records
const obj1: Record<string, string> = { foo: "bar" };
console.log(obj1.foo);
// @ts-expect-error Mutation should not be allowed.
obj1.foo = "baz";
// Mutable records
const obj2: MutableRecord<string, string> = { foo: "bar" };
obj2.foo = "baz";
TypeScript는 Record나 MutableRecord를 찾지 못한다고 불평했습니다. 사용되지 않은 @ts-expect-error도 있었는데, 이는 변이가 허용되고 있음을 뜻합니다.
소매를 걷어붙이고 다음처럼 고쳤습니다:
// In lib.d.ts:
declare type PropertyKey = string | number | symbol;
type Record<KeyT extends PropertyKey, ValueT> = {
readonly [key in KeyT]: ValueT;
};
type MutableRecord<KeyT extends PropertyKey, ValueT> = {
[key in KeyT]: ValueT;
};
이제 Record는 불변 키-값 쌍이 되었고, 가변 버전도 생겼습니다. 배열과 똑같이요!
이 아이디어는 Set이나 Map 같은 다른 내장 타입들로도 확장할 수 있습니다. 배열과 레코드에서 했던 것처럼 하면 꽤 쉽게 할 수 있을 겁니다. 이 부분은 독자 여러분께 연습 문제로 남기겠습니다.
마지막 테스트는 일반 객체(레코드나 배열이 아닌)를 불변으로 만드는 것이었습니다. 불행히도, 저는 이 부분을 해결하지 못했습니다.
제가 작성한 테스트 케이스는 다음과 같습니다:
// In test.ts:
const obj = { foo: "bar" };
console.log(obj.foo);
// @ts-expect-error Mutation should not be allowed.
obj.foo = "baz";
이게 저를 막아섰습니다. 무엇을 하든, 이 변이를 금지할 타입을 작성할 수가 없었습니다. 생각나는 모든 방식으로 Object 타입을 바꿔 봤지만, 번번이 실패했어요!
물론 obj에 주석을 달아 불변으로 만드는 방법들은 있습니다. 하지만 그건 제가 세운 목표의 취지와 다릅니다. 저는 기본이 불변이길 원합니다!
아쉽지만, 저는 여기에서 포기했습니다.
저는 TypeScript를 기본적으로 불변으로 만들고 싶었습니다. 배열, Record, 그리고 Map과 Set 같은 타입들까지는 성공했습니다. 아쉽게도 obj = { foo: "bar" } 같은 일반 객체 정의에는 적용하지 못했습니다.
아마 린트 규칙으로 이걸 강제하는 방법은 있을 겁니다. 변이 연산을 금지한다든지, 모든 곳에서 Readonly 주석을 요구한다든지요. 그런 접근이 어떤 모양일지 보는 것도 흥미로울 것 같습니다.
만약 여러분이 다른 도구 없이 TypeScript를 기본적으로 불변으로 만드는 법을 알아내신다면, 꼭 알려 주세요. 이 글을 업데이트하겠습니다. 저의 실패한 시도가 누군가의 성공으로 이어지길 바랍니다.
다시 한 번, 해결책을 찾으셨거나 다른 생각이 있으시다면 연락 주세요.