본문으로 건너뛰기

좁힘이 달라지는 곳 — 대입과 반복

타입스크립트 목차유니언과 좁히기레슨 23 / 51

목차

좁힘은 확인할 때만 생기지 않습니다. 이름을 다른 값에 다시 붙이는 대입 뒤에도 검사기는 그 이름의 지금 타입을 선언 타입 가운데 새 값이 드는 쪽으로 좁혀 보고, 반복 머리(loop head)에서는 처음 들어오는 길과 덩어리 끝에서 돌아온 길의 타입을 합칩니다.

자바스크립트에서는 let 이름을 여러 값에 다시 붙이는 일이 흔하고, 타입스크립트에서도 선언 타입 안의 값이면 다시 붙일 수 있습니다. 이 레슨에서 검사 규칙 5의 뒤 절반(대입 · 반복 머리)을 세웁니다.

10단계 중 1단계. 이름 줄의 '선언' 꼬리표는 선언 타입, 표의 지금 타입 칸은 그 줄에서 검사기가 보는 타입입니다.

1대입하면 지금 타입이 바뀐다

주문 번호 id를 수와 글자에 번갈아 붙이는 코드입니다.

let id: string | number = 7;
console.log(id + 1);
id = 'a7';
console.log(id.toUpperCase());
id = 8;
console.log(id.toFixed(0));
실행 결과
8
A7
8

id의 선언 타입은 1번 줄에 적은 string | number로 끝까지 그대로지만, 검사기는 2 · 4 · 6번 줄에서 id를 각각 다른 타입으로 봅니다.

줄이름지금 타입
2idnumber — 1번 줄에서 7에 붙은 뒤
4idstring — 3번 줄에서 'a7'에 다시 붙은 뒤
6idnumber — 5번 줄에서 8에 다시 붙은 뒤

대입하면 검사기는 그 이름의 지금 타입을 선언 타입 가운데 새 값이 드는 쪽으로 좁힙니다 — 'a7'이 드는 string, 8이 드는 number. 그래서 4번 줄에서는 toUpperCase()가, 6번 줄에서는 toFixed()가 지나갑니다. 선언 타입이 유니언이 아니면 대입해도 지금 타입은 선언 타입 그대로입니다.

if 갈래 안에서 대입해도 같습니다. 좁히기 레슨 2절에서는 5번 줄에서 찍기만 해 7번 줄이 string | number로 막혔지만, 이번에는 5번 줄에서 v를 글자에 다시 붙입니다.

function show(
  v: string | number,
) {
  if (typeof v === 'number') {
    v = v + '번';
  }
  return v.toUpperCase();
}
console.log(show(7));
console.log(show('a7'));
실행 결과
7번
A7
줄이름지금 타입
2vstring | number
4vstring | number → 참 number 거짓 string
5vstring — 대입 뒤
7v만나는 줄 — 참 string + 거짓 string → string

7번 줄에는 두 길이 다 오지만, 참 길도 5번 줄의 대입으로 string을 가져와 합쳐도 string입니다. 만나는 줄의 타입은 늘 그 줄로 오는 길들이 가져온 타입을 합친 것입니다.

2대입 검사는 늘 선언 타입으로

지금 타입이 대입마다 바뀐다면, 이름을 아무 값에나 다시 붙여도 될까요? 3번 줄을 보세요.

let id: string | number = 7;
id = 'a7';
id = true;
검사 결과 오류 1개
main.ts(3,1): error TS2322: Type 'boolean' is not assignable to type 'string | number'.

오류 문장 끝의 받는 자리 타입이 2번 줄 뒤의 지금 타입 string이 아니라 선언 타입 string | number입니다. 검사기는 대입하는 줄마다 오른쪽 값이 선언 타입에 드는지 봅니다(검사 규칙 2). 타입스크립트 핸드북은 이것을 '넣을 수 있는지는 늘 선언 타입으로 검사한다(assignability is always checked against the declared type)'고 적습니다.

두 말을 견주면 이렇습니다.

선언 타입지금 타입
무엇그 이름이 가리킬 수 있는 값의 모음그 줄에서 검사기가 보는 타입
정해지는 때선언할 때 한 번확인 · 대입마다, 길이 만나는 줄마다
쓰는 곳대입하는 줄의 검사속성 · 메서드를 쓰는 줄의 검사

3반복 머리에서 합쳐진다

JS 다시 보기: for 문은 덩어리를 한 바퀴 돈 뒤 머리로 돌아가 i++를 하고 조건을 다시 봅니다(JS 'for의 세 칸').

첫 잔은 4500원, 둘째 잔부터는 무료라서 덩어리 끝에서 price를 '무료'에 다시 붙이는 코드입니다. 1~2번 줄은 한 문장입니다. 4번 줄에서 price의 지금 타입은 무엇일까요? 검사를 지나갈까요? 답을 정한 뒤 펼쳐 보세요.

let price: string | number =
  4500;
for (let i = 0; i < 2; i++) {
  const won = price.toFixed(0);
  console.log(won + '원');
  price = '무료';
}
검사 결과와 실행 결과 보기
검사 결과 오류 1개
main.ts(4,21): error TS2339: Property 'toFixed' does not exist on type 'string | number'.  Property 'toFixed' does not exist on type 'string'.
실행 결과
4500원main.ts:4  const won = price.toFixed(0);                    ^ TypeError: price.toFixed is not a function    at main.ts:4:21

예상과 다르면 그곳이 배울 곳입니다. 4번 줄에서 price의 지금 타입은 string | number이고, 검사는 4번 줄에서 막힙니다. 들여 쓴 둘째 줄이 막힌 쪽 string을 짚습니다. 1~2번 줄에서 price가 4500에 붙었으니 반복으로 처음 들어오는 길은 number를 가져옵니다. 그러나 반복에는 덩어리 끝(6번 줄)에서 머리(3번 줄)로 돌아오는 길도 있고, 그 길은 price가 '무료'에 다시 붙은 뒤라 string을 가져옵니다. 검사기는 값을 모르니 몇 바퀴 도는지 세지 않고, 머리로 오는 두 길의 타입을 합쳐 string | number로 봅니다.

Node로 돌리면 첫 바퀴는 4500원을 찍고, 둘째 바퀴의 4번 줄에서 멈춥니다. 검사기가 막은 길이 둘째 바퀴에서 실제로 돈 것입니다.

반복 머리는 갈래 뒤처럼 길이 다시 만나는 줄이라, 오는 길들의 타입이 합쳐집니다. 이제 검사 규칙 5를 다 적을 수 있습니다.

검사 규칙 5. 검사기는 값을 모릅니다. 그래서 갈 수 있는 길을 모두 읽습니다 — if·else는 두 갈래를 모두, 반복은 덩어리 끝에서 머리로 돌아오는 길까지 읽습니다. 확인 뒤의 갈래와 대입 뒤에서는 이름의 지금 타입이 좁혀지고, 길이 다시 만나는 줄(갈래 뒤 · 반복 머리)에서는 그 줄로 오는 길들의 지금 타입이 합쳐집니다. return으로 끝난 길은 오지 않습니다.

나중에 불리는 콜백 안의 좁힘은 7단원 'Promise와 async의 타입'에서 더합니다.

반복이 있는 코드도 타입 추적 표로 적습니다. 반복 머리 행은 덩어리 끝에서 돌아오는 길을 알아야 채울 수 있어서, 덩어리 끝의 대입 줄을 먼저 적고 머리 행을 채웁니다.

줄이름지금 타입
1pricenumber — 4500에 붙은 뒤(선언 타입은 string | number)
6pricestring — 덩어리 끝에서 '무료'에 다시 붙은 뒤
3price반복 머리 — 처음 number + 돌아온 길 string → string | number
4pricestring | number — toFixed가 없는 string 쪽 때문에 막힘

예제

다른 이름에 담아 고치기

3절 코드는 price 하나를 수에도 글자에도 붙였습니다. 오류가 난 4번 줄을 고칠 줄로 보기 쉽지만, 원인은 지금 타입을 바꾼 6번 줄의 대입입니다. 찍을 글자를 다른 이름 label에 담으면 price는 수로 남습니다.

const price = 4500;
let label = price + '원';
for (let i = 0; i < 2; i++) {
  console.log(label);
  label = '무료';
}
실행 결과
4500원
무료

label의 선언 타입은 첫 값 '4500원'에서 넓힌 string이고, 반복 머리로 들어오는 길도 돌아오는 길도 string을 가져와 합쳐도 string입니다. 쓰기 전에 typeof price === 'number'⁠로 확인하는 것도 고치는 길입니다.

자주 틀리는 곳

틀린 생각: 한 번 좁힌 타입은 그 이름을 쓰는 곳 끝까지 간다.

3절 코드의 price도 반복 안에서 number일 것처럼 보였습니다.

맞는 규칙: 좁힌 타입은 다음 대입이나 길이 만나는 줄에서 다시 정해집니다. 반복 머리에서는 덩어리 끝에서 돌아온 타입과 합쳐집니다(검사 규칙 5).

틀린 생각: id = 'a7';처럼 대입하면 id의 타입이 string으로 바뀌어, 그 뒤로는 글자에만 다시 붙일 수 있다.

지금 타입이 대입마다 바뀌니, 다시 붙일 수 있는 값도 함께 바뀐다고 여기기 쉽습니다. 2절 표처럼 두 타입은 쓰는 곳이 다릅니다.

맞는 규칙: 대입이 바꾸는 것은 지금 타입이고 선언 타입은 그대로입니다. 값을 넣을 수 있는지는 늘 선언 타입으로 검사합니다(검사 규칙 3).

틀린 생각: 검사기는 실제로 도는 바퀴만 따라가므로, 한 바퀴만 도는 반복은 첫 바퀴만 보면 된다.

바퀴가 하나면 덩어리 끝의 대입은 다음 바퀴에 아무 일도 하지 않으니, 검사기도 그렇게 볼 것 같습니다. 3절 코드의 조건을 i < 1로 바꿔 한 바퀴만 돌게 했습니다.

let price: string | number =
  4500;
for (let i = 0; i < 1; i++) {
  const won = price.toFixed(0);
  console.log(won + '원');
  price = '무료';
}
검사 결과 오류 1개
main.ts(4,21): error TS2339: Property 'toFixed' does not exist on type 'string | number'.  Property 'toFixed' does not exist on type 'string'.
실행 결과
4500원

실행은 4500원을 찍고 끝나지만 검사는 그대로 4번 줄에서 막힙니다. 검사기는 바퀴를 세지 않고, 덩어리 끝에서 머리로 돌아오는 길이 있다는 것만 봅니다.

맞는 규칙: 검사기는 몇 바퀴 도는지 세지 않습니다. 덩어리 끝에서 머리로 돌아오는 길이 있으면, 그 길이 가져온 타입을 반복 머리에서 합칩니다(검사 규칙 5).

확인 문제

1. 3절 코드의 6번 줄을 price = 0;으로 바꿨습니다. 검사 결과와 실행 결과로 맞는 것을 고르세요.

let price: string | number =
  4500;
for (let i = 0; i < 2; i++) {
  const won = price.toFixed(0);
  console.log(won + '원');
  price = 0;
}
  • ① 검사를 지나고, 실행하면 4500원과 0원이 찍힌다.
  • ② 검사는 4번 줄에서 막히고, 실행하면 4500원과 0원이 찍힌다.
  • ③ 검사는 4번 줄에서 막히고, 실행하면 둘째 바퀴에서 멈춘다.
힌트

반복 머리로 오는 두 길이 각각 무엇을 가져오는지 적어 봅니다.

정답
검사 결과 오류 없음
실행 결과
4500원
0원

①입니다. 들어오는 길은 number(4500), 돌아오는 길도 number(0)를 가져와 반복 머리에서 합쳐도 number입니다. 4번 줄의 toFixed가 지나가고, 실행하면 둘째 바퀴에 0이 0원으로 찍힙니다.

  • ② 반복 머리에서는 늘 선언 타입 string | number로 돌아간다고 본 것입니다. 머리에서는 오는 길들의 타입만 합칩니다.
  • ③ 3절 코드의 결과를 그대로 옮긴 것입니다. 이번에 덩어리 끝에서 붙인 0은 수라서 둘째 바퀴도 돕니다.

2. 3절 코드의 타입 추적 표에서 반복 머리 행을 채우세요. 칸 (가)~(다)에 들어갈 타입을 쓰세요.

줄이름지금 타입
1pricenumber
6pricestring
3price반복 머리 — 처음 (가) + 돌아온 길 (나) → (다)
힌트

반복 머리로 오는 길은 둘입니다 — 반복 앞의 1~2번 줄에서 처음 들어오는 길과, 덩어리 끝의 6번 줄에서 돌아온 길.

정답

(가) number · (나) string · (다) string | number

처음 들어오는 길은 1~2번 줄에서 price가 4500에 붙은 뒤라 number, 돌아온 길은 6번 줄에서 '무료'에 다시 붙은 뒤라 string입니다. 머리에서 둘을 합쳐 string | number가 됩니다.

흔한 오답은 (나)를 빼고 (다)를 number로 쓰는 것입니다(검사기가 실행을 따라간다는 생각). 첫 바퀴만 보면 그렇지만, 검사기는 돌아오는 길까지 읽습니다.

3. 걸음 수 목표를 이틀 동안 찍는 코드입니다. 둘째 날부터는 목표를 없앤다고 goal을 null에 다시 붙였습니다. 이 코드의 검사 결과와 실행 결과로 맞는 것을 고르세요.

let goal: number | null = 10000;
let day = 1;
while (day <= 2) {
  console.log(goal.toFixed(0));
  goal = null;
  day = day + 1;
}
  • ① 검사는 4번 줄에서 막히고, 실행하면 10000을 찍은 뒤 둘째 바퀴에서 멈춘다.
  • ② 검사를 지나고, 실행하면 10000을 두 번 찍는다.
  • ③ 검사는 4번 줄에서 막히고, 실행되지 않는다.
힌트

while의 머리(3번 줄)로 돌아오는 길이 goal에 무엇을 가져오는지 봅니다. 실행은 바퀴마다 따라갑니다.

정답
검사 결과 오류 1개
main.ts(4,15): error TS18047: 'goal' is possibly 'null'.
실행 결과
10000main.ts:4  console.log(goal.toFixed(0));                   ^ TypeError: Cannot read properties of null (reading 'toFixed')    at main.ts:4:20

①입니다. while도 덩어리 끝에서 머리로 돌아오는 길이 있어, 3번 줄에서 number(처음)와 null(돌아온 길)이 합쳐집니다. 4번 줄의 goal이 number | null이라 TS18047입니다. 실행은 첫 바퀴에 10000을 찍고, 둘째 바퀴에서 null의 toFixed를 읽으려다 멈춥니다.

  • ② 첫 바퀴만 보고 goal이 늘 number라고 본 것입니다(검사기가 실행을 따라간다는 생각). 둘째 바퀴는 5번 줄의 null을 가져옵니다.
  • ③ 검사에서 막히면 실행되지 않는다고 본 것입니다(검사 오류가 실행을 막는다는 생각). Node는 검사하지 않고 돌립니다.

컴퓨터에서 해 보기(선택) — 점수에 들지 않습니다. 폰으로만 보고 있다면 건너뛰세요. 3절 코드를 main.ts로 저장하고 npx tsc로 검사합니다. 덩어리 끝의 6번 줄 price = '무료';를 지우면 4번 줄 price의 지금 타입과 검사 결과가 어떻게 될지 먼저 적고, 지워서 맞춰 봅니다. 편집기에서 4번 줄의 price에 마우스를 올려 봅니다. 검사하고 돌리는 법은 내 컴퓨터에서 검사하고 돌리기에 있습니다.

끝까지 내려오면 저절로 표시됩니다. 이 기기에만 저장합니다.

타입스크립트 목차로