이펙티브 타입스크립트 2장 ( 6 ~ 18 )
포스트
취소

이펙티브 타입스크립트 2장 ( 6 ~ 18 )

해당 포스트는 이펙티브 타입스크립트 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️⃣ 타입들

  1. never: 아무것도 들어갈 수 없는 타입 ( 공집합 )
  2. unit, literal: 한 가지 값만 갖는 타입 ( a, b )
  3. 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(),
};

interfacetypekeyof + | 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,
};

단, classenum은 타입과 값 둘 다 사용이 가능하기 때문에 중복될 수 없습니다.

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 타입 즉, 원시 타입이 어떻게 객체 타입처럼 메서드를 사용하는지 의아합니다.
바로 래퍼 객체의 존재 때문입니다.

원시 타입에 메서드를 사용하면 자동적으로 래퍼 객체로 변환 후 처리하고 다시 원시 타입으로 변경하는 과정을 통해서 해당 코드가 동작합니다.

TypeScriptstring과 래퍼 객체의 String을 혼동해서 타입에 적용하면 문제가 생길 수 있습니다.
stringString에 대입할 수 있지만 그 반대는 오류가 나기 때문입니다.

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. 함수 자체에 타입 적용하기
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 ( 타입과 인터페이스의 차이점 알기 )

대부분의 경우 typeinterface의 차이는 없으니 둘 중에 하나를 골라서 일관성 있게 사용하시는 것이 좋습니다.

typeinterface에 접두사로 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️⃣ 보강과 선언 병합

interfacetype에 없는 보강이라는 기능이 있습니다.

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 vs interface는 이후에 선언 병합될 가능성이 있다면 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를 통해서 TAnimal의 부분집합에 만족하게 만드는 것입니다.
즉, {name: string; age: number;}을 포함한 타입이어야 된다는 의미입니다.

📌 Item 15 ( 동적 데이터에 인덱스 시그니처 사용하기 )

0️⃣ 인덱스 시그니처

키의 이름, 키의 타입, 값의 타입 세 가지 의미를 갖고 있습니다.

  1. 키의 이름: 키의 위치를 표시하는 용도.. 아무 값이나 가능 ( 변수 같은 용도 )
  2. 키의 타입: 일반적으로 string을 사용 ( Symbol 가능, 배열의 경우 number )
  3. 값의 타입: 일반적으로 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];

인덱스 시그니처가 유용한 문법이 맞긴 하지만 단점이 존재합니다.

  1. 모든 키를 허용 ( 아래 예시의 경우 string이라면 뭐든 허용하죠 (1) )
  2. 키를 갖지 않은 경우 허용
  3. 키마다 고유의 타입을 가질 수 없음
  4. 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️⃣ 인덱스 시그니처의 대안 두 가지

첫 번째로 TypeScriptUtility TypesRecord를 사용하는 것입니다.

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 사용하기 )

objectkeystring | symbol입니다.
그리고 arrayobject입니다.
그렇다면 뭔가 이상한게 있습니다.
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
});
배열의 중간에 멈춰야 한다면 forArray.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

constreadonly의 차이는 아래와 같습니다.
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;
};

📮 레퍼런스

  1. « 이펙티브 타입스크립트 » ( 댄 밴더캄 지음, 장원호 옮김, 인사이트, 2021 )
  2. 1-blue - 구조적 타이핑
  3. 1-blue - Record
이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.

JSDoc ( .js에서 타입 적용하기 )

자바스크립트 완벽 가이드 5장 정리