작은 주문표는 음료와 음식이 섞인 주문 목록에서 주문마다 금액을 셈해 합계를 찍는 프로그램입니다. 4·5단원에서 배운 리터럴 타입·좁히기·판별 유니언·never를 한 프로그램에 모아 처음부터 끝까지 만듭니다.
지금까지는 짧은 코드를 읽고 검사 결과와 지금 타입을 썼습니다. 이번에는 쓸모 있는 프로그램 하나를 끝까지 만듭니다. 갈래가 새로 생기면 검사기가 고칠 곳을 알려 주는 주문표입니다. 빈 화면에서 바로 쓰기는 누구에게나 어렵습니다. 그래서 할 일을 하위 목표 다섯으로 나누고, 블록 셋으로 조립합니다. 폰으로 보고 있다면 확인 문제의 순서 맞추기로 조립하고, 컴퓨터가 있다면 직접 쳐서 검사하고 돌려 봅니다.
1무엇을 만드나 — 하위 목표 다섯
만들 주문표가 하는 일을 실행 모습으로 먼저 정합니다. 라테(4500원, 추가 500원)와 쿠키(2000원)를 주문하면 이렇게 찍혀야 합니다.
합계 7000원
4500 + 500 + 2000 = 7000입니다. 이 일을 하위 목표 다섯으로 나눕니다.
| 하위 목표 | 하는 일 | 블록 |
|---|---|---|
| 1. 갈래 타입 정하기 | 음료 · 음식 주문의 모양을 interface 둘로 적고 유니언 Order로 묶는다 | 첫째 |
| 2. 갈래마다 금액 | cost가 switch로 갈래를 나눠 금액을 돌려준다 | 둘째 |
| 3. 나머지는 never로 | default에서 남은 갈래가 없는지 검사기에게 맡긴다 | 둘째 |
| 4. 하나씩 꺼내 더하기 | 주문 목록에서 주문을 하나씩 꺼내 금액을 total에 더한다 | 셋째 |
| 5. 쓰기 | 합계를 합계 7000원 꼴로 찍는다 | 셋째 |
1~3은 빠짐없이 — never로 갈래 검사의 풀이 예제와 같은 꼴이고, 4·5는 자바스크립트에서 하던 합 모으기입니다. 주문 목록 orders는 받은 값이라 하위 목표에 넣지 않았습니다.
2블록 셋으로 조립하기
블록 셋을 차례로 씁니다. 둘째 · 셋째 블록은 앞 블록에 이어 쓰고, 셋을 차례로 한 파일(main.ts)에 두면 프로그램이 됩니다. 줄 번호도 셋을 이어서 셉니다.
첫째 블록은 갈래 타입입니다. 갈래 속성 kind의 타입이 갈래마다 값 하나짜리 리터럴이라, Order는 판별 유니언입니다.
// 1. 갈래 타입 정하기
interface Drink {
kind: 'drink';
price: number;
extra: number;
}
interface Food {
kind: 'food';
price: number;
}
type Order = Drink | Food;
앞 코드에 이어 둘째 블록, cost를 씁니다. 검사 오류가 없습니다.
// 2. 갈래마다 금액
function cost(o: Order) {
switch (o.kind) {
case 'drink':
return o.price + o.extra;
case 'food':
return o.price;
// 3. 나머지는 never로
default:
const left: never = o;
return left;
}
}
- 2. 갈래마다 금액:
case 'drink'아래에서o는Drink라 추가 금액까지 더하고,case 'food'아래에서o는Food라 가격만 돌려줍니다. - 3. 나머지는 never로: 두
case가 갈래를 다 맡아default의o는never입니다. 뒤에 갈래가 늘었는데case를 빠뜨리면 21번 줄이 막혀 알려 줍니다.
셋째 블록은 주문 목록과 합 모으기입니다.
JS 다시 보기: for...of는 배열에서 값을 하나씩 꺼내 이름을 붙이고 덩어리를 실행합니다(JS 'for...of로 배열 돌기').
JS 다시 보기: 합 모으기는 합을 0으로 두고, 하나씩 꺼내 더하고, 끝나고 씁니다(JS '합 모으기와 세기').
앞 코드에 이어 셋째 블록을 씁니다. 실행하면 1절에서 정한 대로 찍힙니다.
const orders: Order[] = [
{
kind: 'drink',
price: 4500,
extra: 500,
},
{ kind: 'food', price: 2000 },
];
// 4. 하나씩 꺼내 더하기
let total = 0;
for (const o of orders) {
total = total + cost(o);
}
// 5. 쓰기
console.log(`합계 ${total}원`);
합계 7000원주문 목록에도 Order[]로 타입을 적습니다. 적지 않으면 객체 속성의 'drink'가 string으로 넓어져, cost(o)에 넘기는 36번 줄에서 TS2345로 막힙니다(검사 규칙 3).
Order[] 자리에 바로 쓴 주문 객체는 검사기가 kind 값으로 갈래 하나를 골라 그 갈래의 모양과 맞춥니다. 그래서 고른 갈래에 없는 속성은 남는 속성으로 잡힙니다(새로 쓴 객체의 남는 속성).
36번 줄에서 cost에 넘기는 o의 지금 타입은 무엇일까요? 첫 바퀴에 꺼내는 주문은 라테입니다.
지금 타입 보기
Order입니다. 예상과 다르면 그곳이 배울 곳입니다.
검사기는 orders에 어떤 주문이 어떤 차례로 들었는지 모릅니다. Order[]에서 꺼낸 값이니 o는 어느 바퀴에서든 Order입니다. 첫 바퀴의 라테가 음료라는 것은 실행할 때의 일입니다.
그래서 갈래를 가르는 일은 cost 안의 switch가 합니다. cost가 돌려주는 타입은 number라, total도 끝까지 number입니다.
예제
손으로 셈해 맞춰 보기
검사를 지났다는 것은 타입이 맞는다는 뜻일 뿐, 합계가 맞는다는 뜻은 아닙니다. 2절 프로그램의 주문마다 금액을 손으로 셈해 출력과 맞춰 봅니다. 아래 값은 실행할 때의 값입니다 — 검사기는 값을 보지 않습니다.
| 바퀴 | o | 가는 갈래 | cost(o) | total |
|---|---|---|---|---|
| 1 | 라테 | case 'drink' | 4500 + 500 = 5000 | 5000 |
| 2 | 쿠키 | case 'food' | 2000 | 7000 |
마지막 total 7000이 출력 합계 7000원과 같습니다. 주문을 더하거나 금액 계산을 바꿨을 때도 이렇게 몇 바퀴만 손으로 셈해 출력과 견줍니다.
자주 틀리는 곳
틀린 생각: 새 갈래는 첫째 블록의 타입에만 더하면 된다.
기프트 카드(Gift)를 팔게 되어 첫째 블록에 interface Gift와 | Gift를 더했다고 합시다. 타입만 고치고 둘째 블록은 그대로 두면, const left: never = o; 줄이 TS2322(Gift 타입은 never 타입에 넣을 수 없다)로 막힙니다. 빠짐없이 — never로 갈래 검사 3절에서 본 그대로입니다.
맞는 규칙: 갈래를 더하면 그 갈래를 맡는 case도 더합니다. 빠뜨린 곳은 never 줄이 알려 주니, 검사 오류가 0이 될 때까지 고칩니다.
틀린 생각: 주문 목록에 쓴 갈래 값은 대소문자가 달라도 된다.
주문 목록에서 라테를 kind: 'Drink'로 쓰면, 그 줄에서 TS2820('Drink'를 "drink" | "food" 자리에 넣을 수 없다)이 납니다. 끝에 붙은 Did you mean '"drink"'?가 짐작한 값입니다. 판별 유니언의 자주 틀리는 곳 셋째와 같은 오류입니다.
맞는 규칙: 갈래 속성의 타입은 그 글자 하나만 든 모음이라, 갈래 값은 interface에 적은 글자 그대로 씁니다(검사 규칙 2).
틀린 생각: 금액은 글자로 넣어도 수처럼 더해진다.
자바스크립트에서는 '2000'도 금액처럼 보입니다. 검사기는 price 자리가 number라 글자 값을 막습니다.
interface Food {
kind: 'food';
price: number;
}
const cookie: Food = {
kind: 'food',
price: '2000',
};
main.ts(8,3): error TS2322: Type 'string' is not assignable to type 'number'.검사기가 막지 않았다면 실행할 때 글자가 이어 붙어 엉뚱한 합계가 찍힙니다. 다만 검사기는 코드에 쓴 값만 봅니다. 파일이나 화면 입력처럼 바깥에서 받아 온 금액이 글자인지는 실행할 때 확인해야 합니다 — 6단원 '바깥 값 확인하기'에서 배웁니다.
맞는 규칙: number 자리에는 수 값만 들어갑니다(검사 규칙 2). 금액은 따옴표 없이 수로 적습니다.
틀린 생각: 검사를 지나면 합계도 맞다.
검사기가 실수를 많이 잡아 주니, 검사 오류가 없으면 프로그램도 맞을 것 같습니다. 그러나 합을 더하는 줄을 빼기로 잘못 쳐도, 수에서 수를 빼는 일은 타입이 맞습니다.
주문 금액 둘(5000 · 2000)을 모으는 줄만 떼어 보면 이렇습니다.
const prices = [5000, 2000];
let total = 0;
for (const p of prices) {
total = total - p;
}
console.log(`합계 ${total}원`);
합계 -7000원검사 오류는 없는데 합계가 -7000원입니다. 손으로 셈한 7000과 다르니 합을 바꾸는 4번 줄부터 봅니다.
맞는 규칙: 검사기는 타입이 맞는지만 보고, 계산이 맞는지는 모릅니다(검사 규칙 1). 검사 오류를 없앤 뒤에도 작은 값으로 결과를 손으로 셈해 맞춰 봅니다.
확인 문제
1. 2절 프로그램에서 쿠키 주문에 extra: 0을 붙여 음료와 모양을 맞췄습니다. 타입 선언과 그 주문만 옮겨 왔습니다. 검사를 지날까요? 막힌다면 몇 번 줄일까요?
interface Drink {
kind: 'drink';
price: number;
extra: number;
}
interface Food {
kind: 'food';
price: number;
}
type Order = Drink | Food;
앞 코드에 이어 쿠키 주문을 씁니다.
const cookie: Order = {
kind: 'food',
price: 2000,
extra: 0,
};
힌트
12번 줄의 갈래 값으로 이 객체가 어느 갈래의 모양과 맞춰지는지 봅니다.
정답
main.ts(14,3): error TS2353: Object literal may only specify known properties, and 'extra' does not exist in type 'Food'.14번 줄에서 막힙니다. kind가 'food'라 검사기는 이 객체를 Food의 모양과 맞춥니다. Food에는 extra 줄이 없어, 새로 쓴 객체의 남는 속성으로 잡힙니다(새로 쓴 객체의 남는 속성).
흔한 오답은 '지나간다'입니다. Order의 한 갈래인 Drink에 extra가 있으니 붙여도 된다고 본 것입니다. 판별 유니언은 갈래 속성으로 갈래 하나를 골라 그 모양과 맞춥니다.
2. 주문표를 하위 목표 단위로 조립합니다.
(가) 하위 목표 다섯의 차례가 섞였습니다. 처음부터 차례로 기호를 늘어놓으세요.
- ㉠ 쓰기
- ㉡ 갈래마다 금액
- ㉢ 하나씩 꺼내 더하기
- ㉣ 갈래 타입 정하기
- ㉤ 나머지는 never로
(나) 셋째 블록의 합 모으기 다섯 줄이 섞였습니다. 줄 앞의 들여쓰기는 그대로입니다. 차례로 기호를 늘어놓으세요.
㉠ total = total + cost(o);
㉡ console.log(`합계 ${total}원`);
㉢ }
㉣ for (const o of orders) {
㉤ let total = 0;
힌트
(가)는 1절의 표를 떠올립니다. (나)는 (가)에서 정한 하위 목표 차례에 맞춰 줄을 놓습니다.
놓은 뒤 물음
(다) (나)를 다 놓은 뒤에 고르세요. (나)에서 ㉤이 ㉣보다 앞이어야 하는 까닭은 무엇일까요?
- ① 반복 안에 두면 바퀴마다 0으로 돌아가고, 반복 밖의 쓰기 줄에서는
total을 찾지 못하기 때문 - ② 검사기가
let줄을 반복 앞에서만 읽기 때문 - ③
total의 선언 타입을 반복이 정하기 때문
정답
(가) ㉣ ㉡ ㉤ ㉢ ㉠ — 갈래 타입 정하기 · 갈래마다 금액 · 나머지는 never로 · 하나씩 꺼내 더하기 · 쓰기입니다.
(나) ㉤ ㉣ ㉠ ㉢ ㉡입니다. 바꿔도 되는 줄은 없습니다.
(다) ①입니다. let total = 0;을 반복 안에 두면 바퀴마다 total이 0에서 다시 시작하고, 쓰기 줄에서는 TS2304(그런 이름을 찾을 수 없다)로 막힙니다. 덩어리 안에서 만든 이름은 덩어리 밖에서 쓸 수 없습니다.
- ② 검사기는 차례대로 한 번씩 읽는 기계가 아닙니다. 이름이 어느 덩어리에서 만들어졌는지를 봅니다.
- ③
total의 선언 타입은 첫 값 0에서number로 정해집니다(검사 규칙 3).
3. 장보기 계산입니다. 사과는 1kg에 3000원이라 2kg이면 6000원이어야 합니다. 이 코드는 검사를 지납니다. 실행하면 무엇이 찍힐까요? 6000이 아니라면 어느 줄을 고칠까요?
interface Fruit {
kind: 'fruit';
kg: number;
}
interface Pack {
kind: 'pack';
count: number;
}
type Item = Fruit | Pack;
function won(i: Item) {
if (i.kind === 'fruit') {
return i.kg + 3000;
}
return i.count * 1000;
}
앞 코드에 이어 사과 2kg을 넘깁니다.
const apples: Item = {
kind: 'fruit',
kg: 2,
};
console.log(won(apples));
힌트
apples가 가는 갈래의 줄에 2와 3000을 넣어 손으로 셈해 봅니다.
정답
3002검사 오류는 없고 3002가 찍힙니다. 12번 줄에서 무게와 가격을 곱하지 않고 더했습니다. return i.kg * 3000;으로 고치면 6000이 찍힙니다.
흔한 오답은 6000입니다. 검사를 지났으니 값도 맞다고 본 것입니다(검사를 지나면 맞다는 생각). 2 + 3000은 수끼리의 더하기라 타입이 맞아, 검사기는 막을 까닭이 없습니다.
컴퓨터에서 해 보기(선택) — 점수에 들지 않습니다. 폰으로만 보고 있다면 건너뛰세요.
2절의 블록 셋을 차례로 main.ts에 저장해 npx tsc로 검사하고 node main.ts로 돌립니다(합계 7000원). 그다음 orders에 녹차(음료 3500원, 추가 0원)를 하나 더하면 무엇이 찍힐지 먼저 적고, 다시 검사하고 돌려 맞춰 봅니다.
검사하고 돌리는 법은 내 컴퓨터에서 검사하고 돌리기에 있습니다.