해당 포스트는
이펙티브 타입스크립트
2장을 읽고 정리한 포스트입니다.
책의 모든 내용을 작성하는 것이 아닌 주관적인 기준에 따라 필요한 정보만 정리했습니다.
📖 2장 타입스크립트의 타입 시스템
📌 Item 6 ( 편집기를 사용하여 타입 시스템 탐색하기 )
0️⃣ tsc
타입 스크립트 컴파일러
TypeScript
파일(.ts
)을 JavaScript
파일(.js
)로 변환시켜줍니다.
1
npx tsc index.ts
1️⃣ 타입 추론
TypeScript
는 기본적으로 타입을 추론합니다.
1
2
3
4
5
6
// number: 10
const number = 10;
// digit: number
let digit = 10;
// n: number | string
let n = digit === 10 ? 12 : "12";
따라서 굳이 필요하지 않은 상황이라면 강제로 타입을 지정할 필요는 없습니다.
조건문을 거쳐가면 그 시점에 타입을 좁힐 수 있습니다.
1
2
3
4
5
6
7
let v:string | number | null = null;
if(typeof v === "string") {
// 여기서 "v"는 "string"이라고 판단합니다.
} else {
// 여기서 "v"는 "number" | "null"이라고 판단합니다.
}
📌 Item 7 ( 타입이 값들의 집합이라고 생각하기 )
“타입은 값들의 집합이다.”라는 말을 계속 의식하고 교재를 읽어야 이해할 수 있습니다.
할당 가능한 값들의 집합(또는 범위)이 타입이다.
타입 체커는 하나의 집합이 다른 집합의 부분 집합인지 확인한다.
1
2
3
4
5
6
7
const n:10 = 10;
let d:10 | 20 = 20;
// n의 타입은 d의 타입의 부분 집합이므로 정상 동작
// 즉, 10은 10 | 20의 부분 집합
d = n;
0️⃣ 타입들
never
: 아무것도 들어갈 수 없는 타입 ( 공집합 )unit
,literal
: 한 가지 값만 갖는 타입 (a
,b
)union
: 두 가지 이상의 값을 갖는 타입 (a | b
)
1️⃣ union(|
) vs intersection(&
)
일반적으로 union
은 합집합, intersection
은 교집합처럼 계산됩니다.
1
2
3
4
// number | string 즉, 둘 중 하나
type MyTypeUnion = number | string;
// never 즉, 두 개의 공통된 타입이 없기 때문에 never
type MyTypeIntersection = number & string;
interface
, type
로 |
or &
를 사용하는 경우는 내부의 속성을 보지 말고 interface
, type
자체를 하나의 묶음(타입은 값들의 집합이다.
)으로 생각하고 봐야합니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
interface Person {
name: string;
}
interface Life {
birth: Date;
death?: Date;
}
type MyTypeUnion = Person | Life;
// "Person & Life"의 타입은 "never"이라고 생각할 수 있음
// 각각의 속성들이 공통으로 갖는 값은 없으니까
// 하지만 TS에서는 값의 집합 자체를 하나의 묶음으로 생각하고 타입이 정해짐 ( 즉 하나하나 나눠서가 아닌 "Person"묶음과 "Life"묶음을 통으로 비교 )
type MyTypeIntersection = Person & Life;
// (1)과 (2) 둘 중 하나라도 존재해야 정상 동작 ( 단, (2)의 경우 "death"의 존재는 영향을 끼치지 않음 ( 옵셔널이기 때문 ) )
const mtu: MyTypeUnion = {
// (1)
name: "1",
// (2)
birth: new Date(),
death: new Date(),
};
// "Person"과 "Life" 각각의 묶음으로 판단하기 때문에 -> "Person"가 존재하고, "Life"도 존재해야 만족
// (1)과 (2) 모두 존재해야 정상 동작 ( 단, (2)의 경우 "death"의 존재는 영향을 끼치지 않음 ( 옵셔널이기 때문 ) )
const mti: MyTypeIntersection = {
// (1)
name: "a",
// (2)
birth: new Date(),
death: new Date(),
};
interface
나 type
에 keyof
+ |
or &
를 적용하면 생각한 것과 조금 다르게 결과가 나올 수 있습니다.
결과는 아래처럼 나오는데 저도 아직 확실하게 이거다라고 이해가 안가서 계속 읽다보니 공식처럼 외워졌습니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
interface Person {
name: string;
}
interface Life {
birth: Date;
death?: Date;
}
// never
type KeyUnion = keyof (Person | Life);
// "name" | "birth" | "death"
type KeyIntersection = keyof (Person & Life);
// "name" | "birth" | "death"
type SameKeyUnion = keyof Person | keyof Life;
// never
type SameKeyIntersection = keyof Person & keyof Life;
2️⃣ 타입은 서브 타입인 경우 할당할 수 있다.
타입은 영역이 큰 것에 작은 것을 할당할 수 있습니다.
즉, 서브 타입(부분 집합)인 경우 할당할 수 있습니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 가능
const numberTwo1: [number, number] = [1, 2];
const NumberArr1: number[] = numberTwo1;
// 에러
const NumberArr2: number[] = [1, 2];
const numberTwo2: [number, number] = NumberArr2;
// 가능
const xOrY1: "x" | "y" = "x";
const str1: string = xOrY1;
// 에러
const str2: string = "";
const xOrY2: "x" | "y" = str2;
// 객체의 형태도 정해진 조건의 모든 속성을 가지고 있다면 더 가지고 있는 것은 상관 없습니다.
객체 형태 예시( 구조적 타이핑 )를 보면
Person
을 받는 함수가Duck
객체(속성을 더 가진 객체)를 받아도 정상적으로 동작합니다.
📌 Item 8 ( 타입 공간과 값 공간의 심벌 구분하기 )
TypeScript
가 사용하는 공간과 값 공간(변수, 함수 등)은 서로 분리되어 있어서 같은 이름으로 사용할 수 있습니다.
0️⃣ 타입 공간 vs 값 공간
1
2
3
4
5
6
7
8
9
10
// 서로 다른 공간을 사용하기 때문에 오류가 나지 않음
type Person = {
name: string;
age: number;
};
const Person = {
name: "alice",
age: 26,
};
단, class
와 enum
은 타입과 값 둘 다 사용이 가능하기 때문에 중복될 수 없습니다.
1
2
3
4
5
6
type Person = {
name: string;
age: number;
};
class Person {}
typeof
연산자의 경우에도 타입과 값 둘 다 사용이 가능하고 상황에 따라 다른 동작을 합니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
type Person = {
name: string;
age: number;
};
const person: Person = {
name: "alice",
age: 26,
};
type p = typeof person;
const x = typeof "x";
1️⃣ 속성 접근자
타입에서 []
로 접근하는 방법을 의미합니다.
문법적 오류가 아니면 어떠한 값도 넣을 수 있습니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
type Person = {
name: string;
age: number;
vision: {
right: number;
left: number;
};
};
const vision1: Person["vision"] = {
right: 1.2,
left: 1.5,
};
// 아래와 같이 속성 접근자에는 "어떤한 값도 가능"합니다.
const vision2: Person[keyof Person] = {
right: 1.2,
left: 1.5,
};
📌 Item 9 ( 타입 단언보다는 타입 선언을 사용하기 )
타입 단언(as
) 보다는 타입 선언(:
)을 사용하는 것이 더 좋습니다.
0️⃣ 타입 단언 vs 타입 선언
1
2
3
4
5
6
7
8
9
10
11
type Person = { name: string };
// 아래의 결과는 같습니다. 둘 다 "Person"이라는 타입을 갖죠.
const person1: Person = { name: "alice" };
const person2 = { name: "alice" } as Person;
// 하지만 아래 예시처럼 타입을 덜/더 넣는 경우 문제가 생깁니다.
const person3: Person = { }; // 오류
const person4 = { } as Person; // 오류 안남
const person5: Person = { name: "alice", age: 26 }; // 오류
const person6 = { name: "alice", age: 26 } as Person; // 오류 안남
결과적으로 타입 선언은 “타입이 올바른지 타입 체커한테 확인해줘!”이고, 타입 단언은 “내가 확실하게 타입을 넣었으니까 확인할 필요 없어!”라고 하는 것입니다.
그리고 타입 단언은 잘못하면 런타임에 오류가 발생합니다.
1
2
3
4
5
type Person = { say: () => void };
const person = {} as Person; // 오류 안남
console.log(person.say()); // "say is not function"
1️⃣ 특별한 타입 단언(!)
null
이 아님을 단언하는 방법입니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 아래 둘 다 같은 타입을 갖습니다.
const $button1 = document.querySelector(".button") as HTMLElement;
const $button2 = document.querySelector(".button")!;
// 정말 확실한 경우에만 단언을 하는게 맞다고 하는데 저도 그렇게 생각합니다.
// 그래도 위의 코드는 단언문을 사용하기 보다는 아래와 같이 사용하는 게 더 좋지 않나 생각합니다.
(() => {
const $button = document.querySelector(".button");
if(!($button instanceof HTMLButtonElement)) return alert(".button인 태그가 없습니다!");
// ... 이후에는 $button은 "HTMLButtonElement" 타입이 확정됩니다.
})()
2️⃣ unknown
unknown
은 모든 타입의 서브 타입입니다.
즉, 모든 타입에 대신 들어갈 수 있는 타입이라는 의미죠.
이 unknown
을 이용해서 말도 안되는 타입 단언을 할 수 있습니다.
1
2
3
4
5
6
7
8
9
10
11
12
type Person = {
name: string;
age: number;
};
// 아래 코드는 에러가 발생합니다.
// "HTMLElement"과 "Person"의 간극이 너무 크기 때문이죠
const el1 = document.body as Person;
// 하지만 아래 코드는 그 간극조차도 무시하고 타입을 적용합니다.
// 즉, 어떠한 타입도 바꿀 수 있는 방법이죠. ( 마치 css의 "!important"처럼요 )
const el2 = document.body as unknown as Person;
css
의 !important
같은 경우는 쓰는 것을 추천하지 않습니다.
일반적인 상식에 맞는 규칙을 모두 깨버리는 존재가 돼서 판단하기가 힘들어집니다.
같은 이유로 위와 같은 코드는 지양하는게 좋은 것 같습니다.
catch(){}
의 error
는 자동으로 unknown
인데 그런 경우를 제외하고는 딱히 써본적이 없습니다.
📌 Item 10 ( 객체 래퍼 타입 피하기 )
이 이야기를 TypeScript
에서 할 줄은 몰랐지만 하겠습니다.
JavaScript
의 기본 타입중에 string
, number
, boolean
등의 타입은 래퍼 객체가 존재합니다.
저희가 일반적으로 "apple".toLocaleUpperCase();
와 같은 구문을 사용할 수 있습니다.
하지만 따지고 보면 엄청 이상합니다.
string
타입 즉, 원시 타입이 어떻게 객체 타입처럼 메서드를 사용하는지 의아합니다.
바로 래퍼 객체의 존재 때문입니다.
원시 타입에 메서드를 사용하면 자동적으로 래퍼 객체로 변환 후 처리하고 다시 원시 타입으로 변경하는 과정을 통해서 해당 코드가 동작합니다.
TypeScript
의 string
과 래퍼 객체의 String
을 혼동해서 타입에 적용하면 문제가 생길 수 있습니다.
string
은 String
에 대입할 수 있지만 그 반대는 오류가 나기 때문입니다.
1
2
3
4
5
6
7
8
let str1: String = "apple";
let str2: string = "apple";
// 가능 ( "String" -> "string" )
str1 = str2;
// 오류 ( "string" -> "String" )
str2 = str1;
따라서 애초에 래퍼 객체를 타입으로 사용하지 않는 것이 좋습니다.
그러면 위와 같은 경우를 생각할 필요도 없겠죠.
📌 Item 11 ( 잉여 속성 체크의 한계 인지하기 )
잉여 속성 체크란 타입이 명시된 변수에 객체 리터럴을 할당할 때 해당 타입이 존재하는지 그리고 그 이외의 타입이 존재하지는 않는지확인하는 것을 말합니다.
0️⃣ 잉여 속성 체크
잉여 속성 체크란 아래와 같은 경우에 실행됩니다.
1
2
3
4
type Person = { name: string };
// 아래와 같은 경우 잉여 속성 체크를 통해 "age"가 선언됨은 인지하고 오류를 발생
const person: Person = { name: "alice", age: 25 };
하지만 아래와 같은 경우는 실행되지 않습니다.
1
2
3
4
5
6
type Person = { name: string };
const person1 = { name: "alice", age: 25 };
// 잉여 속성 체크 실행 X ( 즉, "age"가 추가로 있어도 문제 없이 동작... ( 구조적 타이핑 ) )
const person2: Person = person1;
즉, 잉여 속성 체크는 객체 리터럴 할당의 경우에만 실행됩니다.
하지만 객체 리터럴 할당에서도 피할 수 있는 방법((1)
, (2)
)이 존재합니다.
1
2
3
4
5
6
7
8
9
10
interface Person { name: string };
// (1) 타입 단언 ( as )
const person1 = { name: "alice", age: 26 } as Person;
// (2) 인덱스 시그니처 사용
// "Item13"에서 나올 "선언 병합" 사용
interface Person { [index: string]: any };
const person2 = { name: "alice", age: 26 };
1️⃣ 공통 속성 체크
약한 타입에서 실행되며 오타 체크에 유용합니다.
약한 타입이란 모든 속성이 optional
인 타입을 의미합니다.
1
2
3
4
5
6
7
interface Person { name: string; age: number; }
// typescript utility 사용 -> 모든 속성을 optional로 만들어 줍니다.
type OptionalPerson = Partial<Person>;
// TS 경고 발생 ( 'Partial<Person>' 형식에 'Name'이(가) 없습니다. 'name'을(를) 쓰려고 했습니까? ) ( <-- 공통 속성 체크 )
const person: OptionalPerson = { Name: "alice" };
person
의 경우 OptionalPerson
타입 즉 약한 타입이기 때문에 공통 속성 체크가 실행됩니다.
📌 Item 12 ( 함수 표현식에 타입 적용하기 )
0️⃣ 함수에 타입 적용하는 방법 2가지
함수에 타입을 적용하는 경우 두 가지 방법을 사용할 수 있습니다.
- 각각의 매개변수와 리턴 값에 타입 적용하기
- 함수 자체에 타입 적용하기
1
2
3
4
5
6
7
8
9
10
11
12
type MyFunc = (x: number, y: number) => string;
// 함수 표현식 + 함수 자체에 타입 적용
const add1: MyFunc = (x, y) => x + y + "";
// 함수 표현식 + 각각 타입 적용
const add2 = (x: number, y: number): string => x + y + "";
// 함수 선언식 사용 ( 함수 자체 타입 적용 불가능 )
function add3(x: number, y: number): string {
return x + y + "";
}
함수 선언식에는 함수 자체에 타입을 적용하는 방법이 없습니다.
함수 자체에 타입을 적용하는 것이 재사용성에 이점이 있어서 TypeScript
에서는 함수 표현식을 이용하는 것이 좋습니다.
( 사실 그게 아니라도 개인적으로 함수 표현식 + 화살표 함수 조합을 사용하는 것을 제일 좋아합니다. )
1️⃣ 함수와 typeof
typeof
를 함수에 사용하면 함수의 매개변수와 리턴 값인 타입을 얻을 수 있습니다.
1
2
3
const func = (x: number, y: number) => x + y + "";
type MyFunc = typeof func;
위 방식을 응용하면 다른 라이브러리의 함수에 추가적인 기능을 처리하는 함수를 쉽게 만들 수 있습니다.
1
2
3
4
5
6
7
8
const validatedFetch: typeof fetch = async (input, init) => {
const response = await fetch(input, init);
// 패치의 결과로 뭔가 추가적인 처리...
if (!response.ok) throw new Error("정상적인 응답이 아닙니다.");
return response;
};
굳이 fetch()
를 찾아가서 인자의 타입을 얻어서 매개변수의 타입을 지정해 줄 필요가 없어지죠.
리액트에서도 이런 타입을 제공해줍니다.
( 저는 여태까지 그것도 모르고 항상 매개변수에 타입을 강제로 지정했습니다… )
1
2
3
const onClickHandler: MouseEventHandler<HTMLButtonElement> = useCallback((e) => {
// ... 클릭 이벤트 등록
}, []);
📌 Item 13 ( 타입과 인터페이스의 차이점 알기 )
대부분의 경우 type
과 interface
의 차이는 없으니 둘 중에 하나를 골라서 일관성 있게 사용하시는 것이 좋습니다.
type
과 interface
에 접두사로 T
/I
를 붙이는 것은 C#
에서 영향을 받았다고 하네요.
TypeScript
에서는 지양하는게 좋다고 합니다.
하지만 이번 파트의 예시에서는 구분을 위해서 접두사를 붙이도록 하겠습니다.
0️⃣ 확장성
먼저 타입의 확장에서 차이가 있습니다.
type
은 복잡한 확장도 가능하지만, interface
는 기본적인 확장만 가능합니다.
intersection
(&
)의 경우에는 아래와 같이 확장이 가능합니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
type Input = { x: number };
type Output = { y: number };
// 인터페이스 확장
interface IPos extends Input, Output {
z: number;
}
const iPos: IPos = { x: 1, y: 1, z: 1 };
// 타입 확장
type TPos = { z: number; } & Input & Output;
const tPos: TPos = { x: 1, y: 1, z: 1 };
intersection
(|
)의 경우에는 인터페이스에서 확장하려면 deps
를 한 단계 더 들어가야 합니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
type Input = { x: number };
type Output = { y: number };
interface IPos {
pos: Input | Output;
z: number;
}
// 타입 확장 ( "pos"라는 깊이가 추가됨... 밖으로 빼는 방법이 없음 )
const iPos: IPos = { pos: { x: 1 }, z: 1 };
type TPos = { z: number; } & (Input | Output);
// 타입 확장 ( "interface" 보다 확장성이 좋음 )
const pos2: TPos = { y: 1, z: 1 };
따라서 저의 생각으로는 일반적으로는 type
을 이용하는 것이 좋은 것 같습니다.
사용하기도 더 편한 것 같고, 단어도 더 짧기도 하고요.
1️⃣ 보강과 선언 병합
interface
는 type
에 없는 보강이라는 기능이 있습니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
// 기존 파일에서 선언
interface Pos {
x: number;
y: number;
}
// ... 이후 다른 버전 업데이트하면서 "z"가 필요해진 경우 아래와 같이 사용
interface Pos {
z: number;
}
// "Pos"가 보강, 선언 병합이 돼서 합쳐짐
const pos: Pos = { x: 1, y: 1, z: 1 };
type
의 경우 중복된 타입을 지정했다는 오류가 나게 됩니다.
최종적으로
type
vsinterface
는 이후에 선언 병합될 가능성이 있다면interface
그게 아니라면type
을 사용하는 것이 좋은 것 같습니다.
📌 Item 14 ( 타입 연산과 제너릭 사용으로 반복 줄이기 )
DRY
( Don’t repeat yourself ): 같은 코드를 반복하지 말라.
0️⃣ 타입 중복 줄이기
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
type Person = {
name: string;
age: number;
};
// "PersonWithGender"처럼 사용하면 중복이 발생
type PersonWithGender = {
name: string;
age: number;
gender: boolean;
};
// "PersonWithGender2"은 중복 없이 기존 타입 확장 ( "interface"라면 "extends" 사용 )
type PersonWithGender2 = Person & { gender: boolean };
// 단 확장을 위해서는 "type"이 더 유용합니다.
// 아래와 같은 형태는 "interface"로는 구현할 수 없기 때문이죠.
type PersonWithGender3 = Person | { gender: boolean };
PersonWithGender
의 경우에는 중복 발생과 동시에 Person
이 변경되면 그에 맞게 PersonWithGender
도 같으 변경해줘야 하는 번거로움이 발생합니다.
하지만 PersonWithGender2
의 경우는 Person
을 참조하기 때문에 수정사항을 반영할 필요가 없죠. 즉, 확장성이 좋습니다.
1️⃣ 타입 중복 줄이기 ( indexing, keyof )
indexing
이란 []
를 이용해서 타입을 추출/결정하는 방법입니다.
keyof
란 특정 타입의 key
들을 union
형태로 추출하는 연산자입니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
type Person = {
name: string;
age: number;
gender: boolean;
};
// ===== indexing =====
// 'Person["name"]' -> string
type PersonNameValue = Person["name"];
// Person["age"] -> number
type PersonAgeValue = Person["age"];
// Person["gender"] -> boolean
type PersonGenderValue = Person["gender"];
const personName: PersonNameValue = "alice";
const personAge: PersonAgeValue = 26;
const personGender: PersonGenderValue = true;
// Person["name" | "age"] -> string | number
type PersonNameOrAgeValue = Person["name" | "age"];
const personNameOrAge1: PersonNameOrAgeValue = "alice";
const personNameOrAge2: PersonNameOrAgeValue = 26;
// ===== keyof =====
// "keyof Person" -> "name" | "age" | "gender"
type PersonKeys = keyof Person;
const key1: PersonKeys = "name";
const key2: PersonKeys = "age";
const key3: PersonKeys = "gender";
// ===== keyof + indexing =====
// Person[keyof Person] -> Person["name" | "age" | "gender"] -> string | number | boolean
type PersonKeyofAndIndexing = Person[keyof Person];
const personKeyofAndIndexing1: PersonKeyofAndIndexing = "alice";
const personKeyofAndIndexing2: PersonKeyofAndIndexing = 26;
const personKeyofAndIndexing3: PersonKeyofAndIndexing = true;
union
(|
)을 indexing
하면 반복을 줄일 수 있습니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
type Person = {
name: string;
age: number;
gender: boolean;
};
// "indexing" 사용 1
type CopyPerson = {
name: Person["name"];
age: Person["age"];
gender: Person["gender"];
};
// "indexing" 사용 2
type CopyPerson2 = {
[key in "name" | "age" | "gender"]: Person[key];
};
// "indexing"과 "keyof" 사용
type CopyPerson3 = {
[key in keyof CopyPerson]: Person[key];
};
2️⃣ 제너릭
제너릭은 타입을 변수로 만들어서 사용 시점에 타입을 결정하는 방법입니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// "T"라는 타입인 변수를 만듦
type Func<T> = (x: T, y: T) => T;
// 사용 시점에 타입 "T" 즉, 타입을 결정
const func1: Func<number> = (x, y) => x + y;
const func2: Func<string> = (x, y) => x + y;
func1(1, 2);
func2("1", "2");
// 에러
// func2(1, 2);
// 에러
// func1("1", "2");
extends
를 이용하면 제너릭의 범위를 정할 수 있습니다.
1
2
3
4
5
6
7
8
9
10
11
12
type Animal = {
name: string;
age: number;
};
// (1) : 최소한 "name"과 "age"는 있어야 만족
type Animals<T extends Animal> = T[];
const animals: Animals<{ name: string; age: number; gender: boolean }> = [
{ name: "monkey", age: 1, gender: true },
{ name: "dog", age: 2, gender: true },
{ name: "cat", age: 4, gender: true },
];
extends
는 확장보다는 부분집합이라고 생각하면 더 이해에 도움이 됩니다.
(1)
에서 extends
를 통해서 T
가 Animal
의 부분집합에 만족하게 만드는 것입니다.
즉, {name: string; age: number;}
을 포함한 타입이어야 된다는 의미입니다.
📌 Item 15 ( 동적 데이터에 인덱스 시그니처 사용하기 )
0️⃣ 인덱스 시그니처
키의 이름
, 키의 타입
, 값의 타입
세 가지 의미를 갖고 있습니다.
- 키의 이름: 키의 위치를 표시하는 용도.. 아무 값이나 가능 ( 변수 같은 용도 )
- 키의 타입: 일반적으로
string
을 사용 (Symbol
가능, 배열의 경우number
) - 값의 타입: 일반적으로
any
즉, 어떤 값이든 사용 가능
1
2
3
4
5
6
7
8
9
10
type MyArray = {
/**
* 키의 이름 -> index
* 키의 타입 -> number
* 값의 타입 -> number | string | boolean
*/
[index: number]: number | string | boolean;
};
const arr: MyArray = [1, "2", true];
인덱스 시그니처가 유용한 문법이 맞긴 하지만 단점이 존재합니다.
- 모든 키를 허용 ( 아래 예시의 경우
string
이라면 뭐든 허용하죠(1)
) - 키를 갖지 않은 경우 허용
- 키마다 고유의 타입을 가질 수 없음
TypeScript
언어 서비스의 도움을 받지 못함 ( 자동완성 안됨 )
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
type MyPerson = {
[key: string]: number | string | boolean;
};
const person: MyPerson = {
// (1): 문제 없이 동작
Name: "alice",
age: 26,
gender: true,
};
// (2): 키를 갖지 않는 경우 허용
const person2: MyPerson = {};
// (3): 키마다 고유의 타입을 가질 수 없음
const person3: MyPerson = {
name: 26,
age: "alice",
};
따라서 특수한 경우에 알맞은 목적으로 사용해야 한다고 생각합니다.
1️⃣ 인덱스 시그니처의 대안 두 가지
첫 번째로 TypeScript
의 Utility Types
인 Record
를 사용하는 것입니다.
1
2
3
4
5
6
7
type Pos = Record<"x" | "y" | "z", number>;
const pos: Pos = {
x: 1,
y: 1,
z: 1,
};
근데 “Record
쓸거면 그냥 타입 선언하면 되지 않나?”라는 생각이 들긴 하는데 어떤 목적이 있는거겠죠?
아마 제가 부족해서 큰 뜻을 이해하지 못하나 봅니다…
두 번째 방법은 맵핑된 타임을 사용하는 것입니다.
1
2
3
4
5
6
7
8
9
type Person = {
// "extends"는 조건부 타입 ( 직관적으로 어떻게 동작하는지 느껴지죠.. 교재에서는 "Item 50"에서 설명한다고 합니다. )
[key in "name" | "age"]: key extends "name" ? string : number;
};
const eerson: Person = {
name: "alice",
age: 26,
};
결론은 인덱스 시그니처보다는 type
, Record
, 맵핑된 타입
같은 방식을 사용하는 것이 더 좋습니다.
📌 Item 16 ( number 인덱스 시그니처보다는 Array, tuple, ArrayLike 사용하기 )
object
의 key
는 string
| symbol
입니다.
그리고 array
는 object
입니다.
그렇다면 뭔가 이상한게 있습니다.
array
에서 key
에 해당하는 index
는 분명 number
인데 오류 없이 동작합니다.
사실 런타임에는 모두 string
으로 변환돼서 index
로 사용됩니다.
그리고 사용의 편의성을 위해서 TypeScript
에서 정의만 number
로 해둔 것이죠.
0️⃣ 배열을 순회하는 좋은 방법들
인덱스를 신경쓰지 않아도 된다면 for ~ of
를 사용하는 것이 좋습니다.
1
2
3
4
5
const array = ["apple", "blue", "color"];
for (const word of array) {
// ... word
}
인덱스를 신경써야 한다면 Array.prototype.forEach()
를 사용하는 것이 좋습니다.
1
2
3
4
5
const array = ["apple", "blue", "color"];
array.forEach((word, i) => {
// ... word, i
});
배열의 중간에 멈춰야 한다면 for | Array.prototype.every() | Array.prototype.some() 을 사용하는 것이 좋습니다. |
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
const array = ["apple", "blue", "color"];
for (let i = 0; i < array.length; i++) {
// ... array[i]
// ... break;
}
let targetIndex = -1;
let count = 0;
array.some((v, i) => {
count++;
if (v === "blue") {
targetIndex = i;
return true;
}
return false;
});
console.log(array[targetIndex], count); // blue 2
targetIndex = -1;
count = 0;
array.every((v, i) => {
count++;
if (v === "blue") {
targetIndex = i;
return false;
}
return true;
});
console.log(array[targetIndex], count); // blue 2
1️⃣ ArrayLike
배열을 사용하는데 Array.prototype
에 등록된 메서드들을 사용하지 않을 경우에 타입으로 지정하면 좋습니다.
1
2
3
4
5
6
7
8
9
10
11
const likeArray1: ArrayLike<string> = ["apple", "blue", "color"];
const likeArray2: ArrayLike<string> = {
0: "apple",
1: "blue",
2: "color",
length: 3,
};
// 배열 메서드는 사용할 수 없습니다.
console.log(likeArray1.length);
근데 이거를 왜 쓰는지는 모르겠습니다.
어차피 안 쓸거라도 prototype
에 있으니 메모리를 차지하는 것도 아니고 사용에 제한을 두는 것 같다고 느껴집니다.
적다가 보니까 그 사용에 제한을 두기 위해서 사용하는 목적인 것 같기도 합니다.
결론은 인덱스 시그니처에 number
를 사용하는 것((1)
)은 지양하고 Array
, ArrayLike
, tuple
을 사용하자는 의미인 것 같습니다.
1
2
3
4
type MyArray = {
// (1)
[index: number]: string;
};
📌 Item 17 ( 변경 관련된 오류 방지를 위해 readonly 사용하기 )
먼저 아래의 예시를 살펴보겠습니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
const arraySum = (arr: number[]) => {
let sum = 0;
let num: number | undefined = 0;
while ((num = arr.pop()) !== undefined) {
sum += num;
}
return sum;
};
const myPrint = (n: number) => {
const nums: number[] = [];
for (let i = 0; i < n; i++) {
nums.push(i);
console.log(arraySum(nums));
}
};
myPrint(5); // 0 1 2 3 4
arraySum()
이라는 함수명으로 추측해보면 결과를 0 1 3 6 10
이 나와야 하지만 다르게 나온 것으로 볼 때 잘못 동작한 것 같습니다.
왜 잘못된 결과가 나왔을까요? arraySum()
에서 원본 배열을 수정해서 그렇습니다. ( Array.prototype.pop()
)
이런 잘못된 행위를 TypeScript
에서 체크해주는 방법을 먼저 알아보겠습니다.
0️⃣ readonly
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// readonly 추가
const arraySum = (arr: readonly number[]) => {
let sum = 0;
let num: number | undefined = 0;
// 에러: 'readonly number[]' 형식에 'pop' 속성이 없습니다
while ((num = arr.pop()) !== undefined) {
sum += num;
}
return sum;
};
const myPrint = (n: number) => {
const nums: number[] = [];
for (let i = 0; i < n; i++) {
nums.push(i);
console.log(arraySum(nums));
}
};
myPrint(5);
readonly
를 사용하면 원본을 수정할 수 없습니다.
아직 써본적이 없어서 확신은 아니지만 객체에만 readonly
를 사용할 수 있는 것 같습니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
type Person = {
readonly name: string;
readonly age: number;
};
type MyArray = {
readonly [index: number]: string;
};
type MyArray2 = readonly string[];
type MyArray3 = string[];
const arr1: MyArray2 = ["apple", "blue"];
// 원본 배열을 수정하는 메서드들은 사용 금지
// arr1.push();
// arr1.sort();
// 할당 불가능 (1)
// const arr2: MyArray3 = arr1;
만약 (1)
과 같은 경우가 가능하다면 arr2
에서 변경해버리면 arr1
이 변경되기 때문에 정상적인 동작이네요.
1
2
3
const arraySum = (arr: readonly number[]) => {
// ...
}
위와 같이 readonly
를 사용한다면 매개변수로 인한 side effect
가 발생할 수 없는 것은 코드만으로 확신할 수 있습니다.
교재에서도 이렇게 명시적으로 사용하는 것이 모두에게 좋다고 하네요.
1️⃣ 깊은 readonly와 얕은 readonly
readonly
는 얕은 복사처럼 적용됩니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
type Person = {
name: string;
age: number;
vision: {
left: number;
right: number;
};
};
const person: Readonly<Person> = {
name: "alice",
age: 26,
vision: {
left: 1.0,
right: 1.2,
},
};
// 에러: 읽기 전용 속성이므로 'name'에 할당할 수 없습니다.
person.name = "blue";
// 정상 동작
person.vision.left = 1.5;
깊은 readonly
를 사용하려면 ts-essentials
같은 라이브러리를 사용하면 된다고 하네요.
저도 아직 안써봐서 사용해보고 나면 사용법을 작성해보겠습니다.
2️⃣ const vs readonly
const
와 readonly
의 차이는 아래와 같습니다.
array
이 아니고 readonly
를 사용하는 어떤 것에도 적용되는 개념인 것 같습니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// (1) readonly
let arr1: readonly number[] = [1, 2, 3];
// (2) const
const arr2: number[] = [1, 2, 3];
// (3) readonly + const
const arr3: readonly number[] = [1, 2, 3];
// Error: 배열의 값을 바꿀 수 없음
arr1[0] = 10;
// Error: 배열이 담긴 변수의 값을 바꿀 수 없음
arr2 = [];
// Error: 배열의 값, 배열이 담긴 변수의 값 모두 바꿀 수 없음
arr3[0] = 10;
arr3 = [];
📌 Item 18 ( 매핑된 타입을 사용하여 값을 동기화하기 )
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
type ScatterProps = {
// data
xs: number[];
ys: number[];
// display
xRange: [number, number];
yRange: [number, number];
color: string;
// // 특정 속성이 추가된 경우 (1)
// name: string;
// events
onClick: (x: number, y: number, index: number) => void;
};
0️⃣ 실패에 닫힌 접근법
보수적 접근법
이라고도 불리며 ScatterProps
에 새로운 속성이 추가되면 실행마다 무조건 shouldUpdate()
가 실행되게 됩니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// "React"의 "Hook"처럼 상태가 변하는지 감지 ( 최적화 방법 ) ( "useCallback"이라고 가정 )
// 변했다면 true를 리턴 ( 다시 생성 후 캐싱 )
// 변하지 않았다면 false리턴 ( 캐싱된 데이터 사용 )
const shouldUpdate = (oldProps: ScatterProps, newProps: ScatterProps) => {
let key: keyof ScatterProps;
for (key in oldProps) {
// (2)
if (oldProps[key] !== newProps[key]) {
if (key !== "onClick") return true;
}
}
return false;
};
제가 해석하기로는 oldProps
에는 추가된 name
((1)
)이 들어있지 않으니 (2)
에서 반드시 true
가 나와서 다른 데이터가 변경되지 않아도 항상 다시 생성 후 캐싱하는 과정을 거친다는 의미 같습니다.
이 방법은 정확하지만 너무 자주 다시 생성되는 문제를 갖고 있습니다.
1️⃣ 실패에 열린 접근법
가장 직관적이지만 (1)
이 추가된다면 기억하고 있다가 수동적으로 shouldUpdate()
를 추가해야 합니다.
만약 잊어버린다면 항상 다시 생성되는 문제가 또 발생하겠죠.
1
2
3
4
5
6
7
8
9
10
const shouldUpdate = (oldProps: ScatterProps, newProps: ScatterProps) => {
return (
oldProps.xs !== newProps.xs ||
oldProps.ys !== newProps.ys ||
oldProps.xRange !== newProps.xRange ||
oldProps.yRange !== newProps.yRange ||
oldProps.color !== newProps.color
// onClick은 체크하지 않기
);
};
2️⃣ 이상적인 해결법
만약 아래와 같이 코드를 작성한다면 (1)
이 추가된다면 REQUIRES_UPDATE
에서 먼저 타입 체커가 오류를 냅니다.
수동적으로 바꿔줘야 하지만 실수를 하는 경우는 없게 됩니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// 매핑된 타입을 이용해서 관련 값과 타입을 동기화
// ( 즉, 관련 타입이 변경되면 여기에서 체크할 수 있도록 타입 체커에게 도움을 받음 )
const REQUIRES_UPDATE: { [key in keyof ScatterProps]: boolean } = {
xs: true,
ys: true,
xRange: true,
yRange: true,
color: true,
onClick: false,
};
const shouldUpdate = (oldProps: ScatterProps, newProps: ScatterProps) => {
let key: keyof ScatterProps;
for (key in oldProps) {
if (oldProps[key] !== newProps[key] && REQUIRES_UPDATE[key]) {
if (key !== "onClick") return true;
}
}
return false;
};
📮 레퍼런스
- « 이펙티브 타입스크립트 » ( 댄 밴더캄 지음, 장원호 옮김, 인사이트, 2021 )
- 1-blue - 구조적 타이핑
- 1-blue -
Record