본문으로 건너뛰기

단원 1~8 섞어 풀기

타입스크립트 목차타입으로 타입 만들기문제 12개

1단원 '타입과 타입 검사기'부터 8단원 '타입으로 타입 만들기'까지를 한데 섞은 열두 문제입니다. 어느 단원 레슨을 쓰는지 미리 알려 주지 않습니다. 문제를 읽고 스스로 판단하세요. 검사 결과는 지나가는지와 막히는 줄을, 지금 타입은 검사기가 찍는 글자를, 실행 결과는 찍히는 글자를 종이에 끝까지 쓴 뒤 정답을 펼치세요. 실행이 오류로 멈추면 오류 이름이 든 줄까지만 써도 됩니다. 막히면 정답 앞의 힌트를 먼저 펼칩니다. 점수는 매기지 않습니다.

한 번에 다 풀 필요는 없습니다. 문제 1~10은 폰으로도 풀 수 있고(약 35~45분), 끝의 만들어 보기 둘은 컴퓨터에서 tsc와 Node로 직접 검사하고 돌려 봅니다. 반으로 나누면 문제 1~6과 문제 7~12로 끊어 풀 수 있습니다. 끝의 만들어 보기 둘은 골라서 풉니다.

타입 선언이 긴 문제와 만들어 보기는 코드를 블록 여럿으로 나눴습니다. 둘째 블록부터는 앞 블록에 이어 쓴 것이고 줄 번호도 이어서 셉니다. 끝의 두 문제는 있는 타입에서 새 타입을 뽑아 고치기 함수와 바깥 값 확인 함수를 직접 만들어 봅니다. 컴퓨터가 있으면 블록을 차례로 main.ts에 이어 붙여 npx tsc로 검사 오류가 0인지 본 뒤 node main.ts로 돌려 맞춰 봅니다. 플레이그라운드에서는 맨 위에 export {}; 한 줄을 더합니다. 폰으로만 본다면 문제에 적힌 값으로 내 코드를 한 줄씩 검사하고 따라가 나와야 할 화면과 맞춰 봅니다. 검사 결과는 tsc 7.0.2로, 실행 결과는 Node 26으로 돌린 것입니다.

문제 1

문구점 앱이 샘플 상품 sample에서 타입 Item을 뽑고, 'id'를 뺀 가격표 타입 Tag를 만들었습니다.

const sample = {
  id: 1,
  name: '볼펜',
  price: 1200,
};
type Item = typeof sample;
type Tag = Omit<Item, 'id'>;

위 선언에 이어 씁니다. 검사 결과와 실행 결과를 각각 쓰세요. 검사에서 막히면 막히는 줄을 모두 씁니다.

const a: Tag = sample;
const b: Tag = {
  name: '노트',
  price: 2000,
  id: 2,
};
console.log(a.name, b.name);
힌트

Tag에 남은 속성이 무엇인지 먼저 적고, 8번 줄의 sample과 9~13번 줄의 { ... }가 각각 변수 이름을 거친 값인지 새로 쓴 객체 리터럴인지 가릅니다.

힌트 둘

Omit<Item, 'id'>는 name과 price만 남깁니다. 이름을 거친 값은 필수 속성만 맞으면 남는 속성이 있어도 들어가고, 새로 쓴 객체 리터럴은 남는 속성을 막습니다.

정답

검사는 12번 줄에서 막히고, 실행하면 볼펜 노트가 찍힙니다.

검사 결과 오류 1개
main.ts(12,3): error TS2353: Object literal may only specify known properties, and 'id' does not exist in type 'Tag'.
실행 결과
볼펜 노트
  • 1~6번 줄: 객체 sample의 각 속성은 id: number, name: string, price: number로 넓혀 추론되고(검사 규칙 3), typeof sample이 그 모양을 뽑습니다.
  • 7번 줄: Tag는 Item에서 'id'를 빼 name과 price 줄만 남긴 타입입니다.
  • 8번 줄: sample은 Tag가 요구하는 name과 price를 모두 가졌고 변수 이름 sample을 거친 값이라, id가 더 있어도 지나갑니다(검사 규칙 4).
  • 12번 줄: b 자리에 새로 쓴 객체 리터럴에는 Tag에 없는 id를 적을 수 없어 TS2353으로 막힙니다(검사 규칙 4).
  • 14번 줄: 타입과 검사 오류는 실행 전에 지워져 Node는 볼펜 노트를 찍습니다(검사 규칙 1).

흔한 오답 하나는 8번 줄도 막힌다고 쓴 것입니다. Item과 Tag의 이름이 달라 넣을 수 없다고 본 것이거나(이름이 같아야 맞는다는 생각), 변수 이름을 거친 객체도 남는 속성 검사를 받는다고 본 것입니다(남는 속성은 늘 오류라는 생각). 실행 결과를 비웠다면 검사 오류가 실행을 막는다고 본 것이고(검사 오류가 실행을 막는다는 생각), sample.name의 타입이 '볼펜'이라고 봤다면 객체 속성이 넓어진다는 점을 놓친 것입니다(넓어지지 않는다는 생각).

다시 볼 레슨: 값에서 타입 뽑기 — typeof와 as const · 있는 타입에서 새 타입 — Pick·Omit·Partial · 모양으로 맞추기 · 새로 쓴 객체의 남는 속성

문제 2

포인트 카드 앱이 기본 정보 Base를 확장한 Card를 받아 보너스 점수를 계산합니다.

interface Base {
  id: string;
}
interface Card extends Base {
  pt: number | null;
}

위 선언에 이어 씁니다. 9 · 11 · 13 · 14 · 17번 줄에서 c 또는 p의 지금 타입을 차례대로 쓰고, 검사 결과와 실행 결과도 각각 쓰세요.

function bonus(
  c: Card | null,
) {
  if (c === null) {
    c = { id: 'G', pt: 0 };
  }
  let p = c.pt;
  if (typeof p === 'object') {
    p = 100;
  }
  return `${c.id}:${p + 10}`;
}
console.log(bonus(null));
힌트

10번 줄에서 c === null일 때 새 Card 객체를 대입한 뒤 13번 줄에서 두 갈래가 어떻게 합쳐지는지 봅니다. 14번 줄에서는 자바스크립트에서 typeof null이 무엇인지 떠올립니다.

힌트 둘

10~12번 줄의 참 갈래에서 c에 Card를 대입했으므로 13번 줄에서 c는 Card가 됩니다. number | null에서 typeof p === 'object'⁠가 참인 값은 null뿐이고, 15번 줄에서 100을 대입해 17번 줄의 p는 number가 됩니다.

정답

13 · 17번 줄에서 c의 지금 타입은 Card, 13번 줄에서 p는 number | null, 17번 줄에서 p는 number입니다. 검사는 오류 없이 지나가고, 실행하면 G:10이 찍힙니다.

검사 결과 오류 없음
실행 결과
G:10
줄이름지금 타입
8cCard | null
10cCard | null → 참 null 거짓 Card
11cCard (대입)
13c만나는 줄 — 참 Card + 거짓 Card → Card
13pnumber | null
14pnumber | null → 참 null 거짓 number
15pnumber (대입)
17p만나는 줄 — 참 number + 거짓 number → number
  • 8~13번 줄: 함수 안은 매개변수에 적힌 Card | null만 보고 시작합니다(검사 규칙 2). 10번 줄의 참 갈래(null) 안에서 c에 Card 모양 값을 대입해 Card가 되었고, 거짓 갈래(Card)와 합쳐져 13번 줄의 c는 Card입니다(검사 규칙 5). Card는 Base를 extends했으므로 17번 줄에서 c.id도 안전하게 읽습니다.
  • 14~17번 줄: 자바스크립트에서 typeof null은 'object'이므로 number | null 가운데 14번 줄의 참 갈래로 가는 것은 null입니다. 그 안에서 p = 100을 대입해 참 갈래도 number가 되어, 17번 줄의 p + 10은 검사를 지나갑니다(검사 규칙 5).
  • 실행: bonus(null)을 부르면 11번 줄에서 c가 { id: 'G', pt: 0 }이 되고, p는 0이라 14번 줄(typeof 0 === 'object')⁠은 거짓으로 지나가 'G:10'을 돌려줍니다.

흔한 오답 하나는 13번 줄이나 17번 줄에서 c가 여전히 Card | null이거나 p가 number | null이라 막힌다고 쓴 것입니다. 갈래를 나오면 안에서 대입한 것과 상관없이 무조건 원래 선언 타입으로 돌아간다고 본 것입니다(갈래를 나오면 선언 타입이라는 생각). 14번 줄의 typeof p === 'object'⁠가 null을 잡지 못한다고 봤다면 null의 typeof가 'object'라는 점을 놓친 것입니다(object면 null이 아니라는 생각).

JS 다시 보기: typeof null === 'object'⁠는 자바스크립트의 오랜 동작입니다(JS '수·글자·불리언과 typeof').

다시 볼 레슨: 타입에 줄 더하기 — extends와 & · null·undefined 거르기 · 좁힘이 달라지는 곳 — 대입과 반복

문제 3

물류 앱이 바깥 JSON에서 상자 무게 kg을 읽어 두 배로 계산합니다.

type Box = { kg: number };
function readKg(v: unknown) {
  if (typeof v !== 'object') {
    return 0;
  }
  if (v === null) {
    return 0;
  }
  if ('kg' in v) {
    const b = v as Box;
    return b.kg * 2;
  }
  return 0;
}

위 선언에 이어 씁니다. 3 · 6 · 10번 줄에서 v의 지금 타입과 11번 줄에서 b의 지금 타입을 차례대로 쓰고, 검사 결과와 실행 결과도 각각 쓰세요.

const raw =
  JSON.parse('{"kg":"오"}');
console.log(readKg(raw));
힌트

unknown에서 3번 줄(typeof v !== 'object')⁠과 6번 줄(v === null)이 각각 무엇을 걸러 내고 보내는지, 9번 줄('kg' in v)이 어떤 타입을 &로 덧붙이는지 따라갑니다.

힌트 둘

6번 줄을 지나면 v는 object이고, 'kg' in v가 참인 10번 줄에서는 object & Record<"kg", unknown>⁠이 됩니다. 10번 줄의 as Box는 kg이 숫자인지 확인하지 않고 검사기에게 Box라고만 말합니다.

정답

3번 줄 뒤(6번 줄)에서 v는 object | null, 6번 줄 뒤(9번 줄)에서 v는 object, 10번 줄에서 v는 object와 Record<"kg", unknown>⁠을 &로 합친 타입(아래 표의 10번 줄)이고, 11번 줄에서 b는 Box입니다. 검사는 오류 없이 지나가고, 실행하면 NaN이 찍힙니다.

검사 결과 오류 없음
실행 결과
NaN
줄이름지금 타입
2vunknown
3vunknown → 참 객체·null 아님(return) 거짓 object | null
6vobject | null → 참 null(return) 거짓 object
9vobject → 참 object & Record<"kg", unknown> 거짓 object
10vobject & Record<"kg", unknown>
11bBox
  • 3~9번 줄: unknown에서 객체가 아닌 값과 null을 차례로 걸러 내고 'kg' in v를 지나면, v는 object에 Record<"kg", unknown>⁠이 &로 합쳐진 타입이 됩니다(검사 규칙 5).
  • 10~11번 줄: 아직 v.kg의 타입은 unknown이지만, v as Box 단언이 검사를 건너뛰어 b를 Box로 만듭니다(검사 규칙 6).
  • 실행: JSON의 "kg" 값이 문자열 "오"이고 as Box는 실행 전에 지워져 값을 숫자로 바꾸지 않으므로(검사 규칙 1), "오" * 2는 NaN이 됩니다.

같은 답으로 치는 꼴: 10번 줄의 v 타입에서 "kg"을 'kg'처럼 작은따옴표로 쓴 것.

흔한 오답 하나는 10번 줄의 as Box 덕에 "오"가 숫자로 바뀌거나 실행 때 막힌다고 본 것입니다(단언이 값을 바꾼다는 생각). 검사를 통과했으니 실행 결과도 숫자일 것이라고 봤다면 단언이 건너뛴 자리를 놓친 것이고(검사를 지나면 맞다는 생각), 3번 줄만 지나도 v가 object라고 썼다면 typeof null === 'object'⁠를 놓친 것입니다(object면 null이 아니라는 생각).

JS 다시 보기: 숫자가 아닌 문자열 "오"에 * 2를 하면 NaN입니다(JS '수·글자·불리언과 typeof').

다시 볼 레슨: unknown — 확인하고 쓰기 · as는 검사기에게 하는 말 · 타입에 줄 더하기 — extends와 & · Record — 키 타입으로 만드는 객체 타입

문제 4

할인권 앱이 등급 배열 grades에서 등급 타입 Grade를 뽑아 등급별 할인율 rate와 보유 수량 bag을 적었습니다. 13번 줄에서 cnt의 지금 타입은 무엇일까요? 검사 결과와 실행 결과도 각각 쓰세요.

const grades = [
  'S', 'A',
] as const;
type Grade =
  (typeof grades)[number];
const rate: Record<
  Grade,
  number
> = { S: 10, A: 5 };
const bag: Record<
  string,
  number
> = { S: 2 };
const cnt = bag['A'];
console.log(rate.A * cnt);
힌트

6번 줄의 Record<Grade, number>⁠와 10번 줄의 Record<string, number>⁠를 견줘 보고, 13번 줄에서 꺼내는 'A'가 bag 객체 안에 실제로 들어 있는지 봅니다.

힌트 둘

Record<string, number>⁠는 어떤 문자열 키로 꺼내도 검사기가 결과를 number로 봅니다. 하지만 실행 때 bag에는 'A' 키가 없어 undefined가 나옵니다.

정답

13번 줄에서 cnt의 지금 타입은 number이고, 검사는 오류 없이 지나갑니다. 실행하면 NaN이 찍힙니다.

검사 결과 오류 없음
실행 결과
NaN
줄이름지금 타입
1gradesreadonly ["S", "A"]
6rateRecord<Grade, number>
10bagRecord<string, number>
13cntnumber

grades에 as const를 붙여 Grade는 "S" | "A"가 되고(검사 규칙 3), rate는 두 키를 모두 갖춰 검사를 지나갑니다(검사 규칙 4). bag은 키 타입이 string인 Record<string, number>⁠라 검사기는 bag['A']의 타입을 number로 보고 14번 줄의 곱셈도 통과시킵니다. 하지만 실행 때 bag에는 'A' 속성이 없어 cnt는 undefined이고, 5 * undefined는 NaN이 찍힙니다(검사 규칙 1).

흔한 오답 하나는 13번 줄이 검사에서 막힌다거나 실행 결과가 0이라고 쓴 것입니다. cnt의 타입이 number로 찍히고 검사를 지났으니 실행 때도 숫자가 들어 있다고 본 것입니다(꺼낸 값은 늘 있다는 생각 · 검사를 지나면 맞다는 생각).

JS 다시 보기: 없는 키를 읽으면 undefined이고 5 * undefined는 NaN입니다(JS '객체: 이름 붙은 칸 묶음').

다시 볼 레슨: Record — 키 타입으로 만드는 객체 타입 · 값에서 타입 뽑기 — typeof와 as const · 키가 정해지지 않은 객체 — 인덱스 시그니처

문제 5

사내 주소록 앱이 직원 타입 User에서 권한 줄 'role'을 뺀 공개용 타입 PublicUser를 만들고, 속성을 꺼내는 제네릭 함수 pickVal을 부릅니다.

interface User {
  id: number;
  name: string;
  role: 'admin' | 'member';
}
type PublicUser =
  Omit<User, 'role'>;
function pickVal<
  T,
  K extends keyof T,
>(obj: T, key: K): T[K] {
  return obj[key];
}

위 선언에 이어 씁니다. 18번 줄에서 a의 지금 타입은 무엇일까요? 검사를 지나갈까요? 지나가지 못하면 막히는 줄을 모두 쓰세요.

const u: PublicUser = {
  id: 1,
  name: '지민',
};
const a = pickVal(u, 'name');
const b = pickVal(u, 'role');
힌트

PublicUser에 남은 속성 이름이 무엇인지 먼저 적고, pickVal(u, ...)를 부를 때 T 자리에 무엇이 채워지는지 봅니다.

힌트 둘

u의 타입이 PublicUser이므로 keyof T는 "id" | "name"입니다. 'role'은 Omit으로 빠져 keyof PublicUser에 들지 않습니다.

정답

18번 줄에서 a의 지금 타입은 string이고, 검사는 19번 줄에서 막힙니다.

검사 결과 오류 1개
main.ts(19,22): error TS2345: Argument of type '"role"' is not assignable to parameter of type '"id" | "name"'.
  • 6~7번 줄: PublicUser는 User에서 'role'을 뺀 { id: number; name: string; }⁠입니다.
  • 8~13번 줄: pickVal 안에서는 K extends keyof T 조건만 믿고 obj[key]를 꺼내 T[K] 타입으로 돌려줍니다(검사 규칙 2 · 7).
  • 18번 줄: T에 PublicUser, K에 'name'이 채워져 반환 타입 PublicUser['name']은 string입니다.
  • 19번 줄: 'role'은 PublicUser에서 이미 빠진 키라 keyof PublicUser("id" | "name")에 들어가지 못해 TS2345로 막힙니다.

흔한 오답 하나는 19번 줄도 지나간다고 쓴 것입니다. 원래 User에 role이 있었으니 PublicUser에서도 꺼낼 수 있다고 보았거나, 제네릭 <T, K>에는 아무 문자열 키나 넣어도 된다고 본 것입니다(T는 아무거나라는 생각).

다시 볼 레슨: 제약 — extends로 칸에 조건 달기 · keyof와 속성 타입 꺼내기 · 있는 타입에서 새 타입 — Pick·Omit·Partial

문제 6

결제 앱이 공통 정보 Base에 결제 수단별 속성을 &로 더해 판별 유니언 Pay를 만들었습니다.

interface Base {
  id: number;
}
type Cash = Base & {
  kind: 'cash';
  paid: number;
};
type Point = Base & {
  kind: 'point';
  pt: number;
};
type Gift = Base & {
  kind: 'gift';
  code: string;
};

위 선언에 이어 씁니다. 24번 줄에서 p의 지금 타입은 무엇일까요? 검사를 지나갈까요? 지나가지 못하면 막히는 줄을 모두 쓰세요.

type Pay = Cash | Point | Gift;
function amount(p: Pay) {
  switch (p.kind) {
    case 'cash':
      return p.paid;
    case 'point':
      return p.pt;
    default: {
      const end: never = p;
      return end;
    }
  }
}
힌트

Pay에 들어 있는 세 갈래 가운데 switch (p.kind)의 case로 처리되어 return으로 빠져나간 갈래들을 지워 봅니다.

힌트 둘

'cash'와 'point' 갈래는 return으로 함수를 나가므로, default에 도착하는 p에는 아직 처리하지 않은 Gift가 남습니다.

정답

24번 줄에서 p의 지금 타입은 Gift이고, 검사는 24번 줄에서 막힙니다.

검사 결과 오류 1개
main.ts(24,13): error TS2322: Type 'Gift' is not assignable to type 'never'.
줄이름지금 타입
17pPay(Cash | Point | Gift)
20pCash (return)
22pPoint (return)
24pGift

Cash · Point · Gift는 각각 Base에 리터럴 속성 kind를 &로 합친 타입이라 p.kind로 판별할 수 있습니다. 19~22번 줄에서 'cash'와 'point'가 return으로 빠지고 나면 default에는 남은 Gift만 내려옵니다(검사 규칙 5). Gift는 빈 모음인 never 자리에 넣을 수 없어 24번 줄이 TS2322로 막힙니다(검사 규칙 4).

흔한 오답 하나는 24번 줄의 p를 never나 Pay로 쓴 것입니다. default에 오면 늘 never가 된다고 외웠거나, 앞의 case에서 좁혀 빠져나간 갈래가 아래 줄에서 빠지지 않는다고 본 것입니다(좁힘이 끝까지 간다는 생각). 함수를 부르는 코드가 없으니 검사하지 않는다고 봤다면 함수 안은 부르기 전에 선언만 보고 검사한다는 점을 놓친 것입니다(검사기가 실행을 따라간다는 생각).

다시 볼 레슨: 빠짐없이 — never로 갈래 검사 · 판별 유니언 · 타입에 줄 더하기 — extends와 &

문제 7

게시판 앱이 글 타입 Post에서 'id'와 'title'만 골라낸 요약 타입 Summary를 만들고, async 함수 toSummary로 요약 객체를 돌려줍니다. 이렇게 나와야 합니다.

공지

검사를 지나고 실행 결과가 위처럼 되도록 7번 줄과 10번 줄의 빈칸에 알맞은 타입 이름을 채우세요. any로 덮은 답은 받지 않습니다.

interface Post {
  id: number;
  title: string;
  body: string;
}
type Summary =
  ____<Post, 'id' | 'title'>;
async function toSummary(
  p: Post,
): ____<Summary> {
  return {
    id: p.id,
    title: p.title,
  };
}

위 선언에 이어 씁니다.

toSummary({
  id: 1,
  title: '공지',
  body: '휴무 안내',
}).then((s) => {
  console.log(s.title);
});
힌트

7번 줄은 Post에서 'id' | 'title' 두 속성만 골라내는 내장 타입이고, 10번 줄은 async 함수의 반환 타입을 감싸는 제네릭 타입입니다.

힌트 둘

원하는 속성만 골라낼 때는 Pick, async 함수가 돌려주는 값은 늘 Promise<...>로 감싸집니다.

정답

받는 답: 7번 줄 Pick · 10번 줄 Promise

7번 줄의 Pick<Post, 'id' | 'title'>⁠은 Post에서 id와 title 줄만 골라 { id: number; title: string; } 모양을 만듭니다. async 함수는 반환값을 늘 프로미스로 감싸므로 10번 줄의 반환 타입 자리는 Promise<Summary>가 됩니다(검사 규칙 2 · 7).

다르게 써도 되는 곳: 7번 줄 전체를 Omit<Post, 'body'>로 써도 같은 모양이지만, 빈칸 자리(____<Post, 'id' | 'title'>)⁠에는 'id' | 'title'을 고르는 Pick이 들어갑니다.

틀리는 꼴:

  • 7번 줄 Omit — id와 title을 빼 버려 { body: string; }만 남으므로 12 · 13 · 21번 줄이 모두 막힙니다.
  • 10번 줄에 Promise 대신 다른 타입 이름을 쓴 것 — async 함수의 반환 타입은 반드시 Promise<...>여야 하므로 TS1064로 막힙니다.
  • any — 검사를 끈 답이라 받지 않습니다(any가 무난하다는 생각).

다시 볼 레슨: 있는 타입에서 새 타입 — Pick·Omit·Partial · Promise와 async의 타입

문제 8

하고 싶은 일마다 쓸 타입 이름이나 키워드를 쓰세요.

  • (가) 바깥에서 온 값의 타입을 아직 모를 때, 바로 쓰는 것을 막고 확인한 뒤에 쓰게 강제하는 안전한 타입 이름
  • (나) switch 문의 default 갈래에서 모든 유니언 갈래를 다 처리했는지 검사할 때 const end: ____ = x;에 쓰는 빈 모음 타입 이름
  • (다) 이미 있는 변수나 상수 값 sample에서 타입을 뽑아낼 때 type Item = ____ sample;⁠의 빈칸에 쓰는 키워드
  • (라) 키 유니언 K의 모든 키에 같은 값 타입 V를 매기는 객체 타입을 만들 때 ____<K, V>에 쓰는 내장 타입 이름
힌트

(가)는 any 대신 쓰는 안전한 짝이고, (다)는 값 자리에서도 종류를 확인할 때 쓰는 키워드와 철자가 같습니다.

정답

받는 답: (가) unknown · (나) never · (다) typeof · (라) Record

틀리는 꼴:

  • (가) any — 확인 없이 아무 속성이나 부르게 검사를 꺼 버립니다(any가 무난하다는 생각).
  • (나) void나 undefined — 남은 갈래가 있을 때도 오류로 막아 주지 못하거나 용도가 다릅니다.
  • (다) keyof — 있는 타입에서 속성 이름 유니언을 꺼내는 키워드입니다. 값에서 타입을 뽑을 때는 typeof를 씁니다(타입과 값을 섞는 생각).
  • (라) Partial이나 Pick — 이미 있는 객체 타입에서 속성을 선택으로 바꾸거나 일부만 골라내는 타입입니다.

다시 볼 레슨: unknown — 확인하고 쓰기 · 빠짐없이 — never로 갈래 검사 · 값에서 타입 뽑기 — typeof와 as const · Record — 키 타입으로 만드는 객체 타입

문제 9

배송비 앱이 요금표 상수 fee에서 배송 방식 타입 Way를 뽑아 특급('exp') 배송비에 포장비 500원을 더한 5500을 찍으려 합니다. 그런데 오타 'fast'를 적고 let으로 선언해 검사에서 막히고 실행하면 NaN이 찍힙니다.

const fee = {
  std: 3000,
  exp: 5000,
} as const;
type Way = keyof typeof fee;
function ship(w: Way) {
  return fee[w] + 500;
}

위 선언에 이어 쓴 코드와 검사 · 실행 결과입니다.

let mode = 'fast';
console.log(ship(mode));
검사 결과 오류 1개
main.ts(10,18): error TS2345: Argument of type 'string' is not assignable to parameter of type '"exp" | "std"'.
실행 결과
NaN

아래 고치기 가운데 검사 오류를 없애는 것을 모두 고르세요. 그 가운데 실행 결과까지 5500이 되는 것도 모두 고르세요.

  • ① 9번 줄을 const mode: Way = 'exp';⁠로 고친다
  • ② 9번 줄을 let mode = 'exp';로만 고친다
  • ③ 10번 줄을 console.log(ship(mode as Way));⁠로 고친다
  • ④ 6번 줄을 function ship(w: string) {⁠로 고친다
힌트

Way가 어떤 리터럴 유니언인지 먼저 적고, 보기마다 mode의 타입이 넓어지는지(let과 const), 그리고 실행 때 fee[w]가 무엇을 꺼내는지 따라가 봅니다.

힌트 둘

Way는 "exp" | "std"입니다. 타입 표기 없는 let mode = 'exp';는 여전히 string으로 넓어져 막히고, mode as Way는 검사만 건너뛰고 'fast' 값을 바꾸지 않습니다.

정답

검사 오류를 없애는 것은 ①③이고, 실행 결과까지 5500이 되는 것은 ①뿐입니다.

keyof typeof fee는 fee의 타입에서 키 이름을 꺼내 "exp" | "std"가 됩니다. ①처럼 const mode: Way = 'exp';(또는 let mode: Way = 'exp';)⁠로 고치면 타입이 Way에 맞고 실행 때도 fee['exp'] + 500인 5500이 찍힙니다.

  • ②는 값을 'exp'로 바꿨지만 타입 표기 없는 let이라 mode의 타입이 string으로 넓어져(검사 규칙 3), 10번 줄이 똑같이 TS2345로 막힙니다.
  • ③은 as Way 단언으로 10번 줄의 검사를 건너뛰지만(검사 규칙 6), 실행 때 mode는 여전히 'fast'라 fee['fast']가 undefined가 되어 NaN이 찍힙니다.
  • ④는 10번 줄 오류 대신 7번 줄의 fee[w]가 TS7053(string으로 fee를 인덱싱할 수 없음)으로 막힙니다.

흔한 오답 하나는 ②도 검사를 지난다고 고른 것입니다. 'exp'로 고쳤으니 let이어도 리터럴 타입이 유지된다고 본 것입니다(넓어지지 않는다는 생각). ③도 5500이 된다고 골랐다면 단언이 값을 바꿔 주거나 검사를 지나면 실행도 맞다고 본 것입니다(단언이 값을 바꾼다는 생각 · 검사를 지나면 맞다는 생각).

다시 볼 레슨: 값에서 타입 뽑기 — typeof와 as const · keyof와 속성 타입 꺼내기 · 적지 않아도 붙는 타입 — 추론 · as는 검사기에게 하는 말

문제 10

호텔 예약 앱이 방 번호 'no'를 뺀 수정 타입 RoomPatch로 등급을 'vip'로 바꿉니다. 실행하면 vip가 찍히지만 검사에서 막힙니다.

interface Room {
  no: number;
  tier: 'std' | 'vip';
  rate: number;
}
type RoomPatch =
  Partial<Omit<Room, 'no'>>;
function editRoom(
  r: Room,
  p: RoomPatch,
): Room {
  return { ...r, ...p };
}

위 선언에 이어 쓴 코드와 검사 결과입니다. 몇 번 줄의 무엇을 고쳐야 할까요? 고친 줄을 쓰세요.

const r0: Room = {
  no: 101,
  tier: 'std',
  rate: 80000,
};
const up = {
  tier: 'vip',
};
const r1 = editRoom(r0, up);
console.log(r1.tier);
검사 결과 오류 1개
main.ts(22,25): error TS2345: Argument of type '{ tier: string; }' is not assignable to parameter of type 'Partial<Omit<Room, "no">>'.  Types of property 'tier' are incompatible.    Type 'string' is not assignable to type '"std" | "vip" | undefined'.
힌트

들여 쓴 마지막 줄(Type 'string' is not...)⁠을 읽고, up.tier가 몇 번 줄에서 왜 string이 되었는지 찾습니다.

힌트 둘

19번 줄에서 타입 표기 없이 만든 객체 up의 tier 속성이 string으로 넓어졌습니다. 19번 줄에 : RoomPatch를 적거나 넓힘을 막습니다.

정답

19번 줄을 const up: RoomPatch = {⁠으로 고칩니다.

interface Room {
  no: number;
  tier: 'std' | 'vip';
  rate: number;
}
type RoomPatch =
  Partial<Omit<Room, 'no'>>;
function editRoom(
  r: Room,
  p: RoomPatch,
): Room {
  return { ...r, ...p };
}

고친 코드를 위 타입 선언에 이어 씁니다.

const r0: Room = {
  no: 101,
  tier: 'std',
  rate: 80000,
};
const up: RoomPatch = {
  tier: 'vip',
};
const r1 = editRoom(r0, up);
console.log(r1.tier);
실행 결과
vip

오류 번호는 22번 줄에 찍혔지만 원인은 19번 줄입니다. 타입 표기 없이 만든 객체 리터럴 up의 tier는 'vip'에서 string으로 넓어져(검사 규칙 3), RoomPatch의 tier 자리("std" | "vip" | undefined)⁠에 들어가지 못합니다(검사 규칙 4 · 7). 19번 줄에 : RoomPatch를 적으면 만드는 자리에서 리터럴로 검사되고, 실수로 no를 넣거나 tier에 오타를 내도 19~20번 줄에서 바로 잡아 줍니다.

  • 다르게 고쳐도 되는 곳: 21번 줄을 } as const;로 고치거나 20번 줄을 tier: 'vip' as const,⁠로 고쳐도 지나갑니다.

흔한 오답은 오류가 찍힌 22번 줄을 // @ts-ignore나 up as any로 덮거나(검사 규칙 6), 3번 줄의 Room에서 tier: string;으로 넓히는 것입니다. 그렇게 덮으면 tier: 'vipp' 같은 오타나 바뀌면 안 되는 no: 999가 들어가도 검사를 지나가 실행 결과가 틀어집니다(오류 줄이 고칠 줄이라는 생각 · 넓어지지 않는다는 생각 · 검사를 지나면 맞다는 생각).

다시 볼 레슨: 있는 타입에서 새 타입 — Pick·Omit·Partial · 타입 오류 읽기 · 값에서 타입 뽑기 — typeof와 as const

문제 11 (만들어 보기)

회원 타입 Member 하나에서 가입용 타입 NewMember('id'를 뺀 타입)와 수정용 타입 MemberPatch(NewMember의 모든 속성을 선택으로 바꾼 타입)를 뽑고, 수정을 반영한 뒤 별명 nick이 null이면 name을 돌려주는 tag로 찍어 봅니다. 다 채워 돌리면 이렇게 나와야 합니다.

지민 푸른별

patchMember의 둘째 인자를 { name: '서연' }으로 바꾸면 이렇게 나와야 합니다.

서연 서연

뼈대입니다. 주석 셋을 따라 8 · 9 · 12 · 13번 줄을 채우세요. 검사 오류는 0이어야 하고, 파생 타입을 손으로 다시 풀어 적거나 any로 덮은 답은 받지 않습니다.

// 1. 원본 타입 하나
interface Member {
  id: number;
  name: string;
  nick: string | null;
}
// 2. Pick·Omit·Partial로 만들기
type NewMember = ____;
type MemberPatch = ____;
// 3. 원본만 고치기
function patchMember(
  m: ____, p: ____,
): ____ {
  return { ...m, ...p };
}

앞 코드에 이어 씁니다.

function tag(m: Member) {
  return m.nick ?? m.name;
}
const d: NewMember = {
  name: '지민',
  nick: null,
};
const a: Member = {
  id: 1, ...d,
};
const b = patchMember(
  a,
  { nick: '푸른별' },
);
console.log(tag(a), tag(b));
힌트

NewMember는 Member에서 'id'를 빼고, MemberPatch는 NewMember(또는 Member)의 모든 속성을 선택으로 바꿉니다. patchMember는 Member와 MemberPatch를 받아 Member를 돌려줍니다.

정답

모범 답입니다. 1~5번 줄이 '1. 원본 타입 하나', 6~9번 줄이 '2. Pick·Omit·Partial로 만들기', 10~15번 줄이 '3. 원본만 고치기'입니다.

interface Member {
  id: number;
  name: string;
  nick: string | null;
}
type NewMember =
  Omit<Member, 'id'>;
type MemberPatch =
  Partial<NewMember>;
function patchMember(
  m: Member,
  p: MemberPatch,
): Member {
  return { ...m, ...p };
}

둘째 블록은 그대로 이어 씁니다.

function tag(m: Member) {
  return m.nick ?? m.name;
}
const d: NewMember = {
  name: '지민',
  nick: null,
};
const a: Member = {
  id: 1, ...d,
};
const b = patchMember(
  a,
  { nick: '푸른별' },
);
console.log(tag(a), tag(b));
실행 결과
지민 푸른별

NewMember는 Omit<Member, 'id'>로, MemberPatch는 Partial<NewMember>로 뽑아 Member 한 곳만 고쳐도 파생 타입이 함께 따라갑니다(검사 규칙 4 · 7). tag 안의 m.nick ?? m.name은 m.nick이 null일 때 m.name(string)을 돌려주어 반환 타입이 string으로 좁혀집니다(검사 규칙 5). 둘째 시험 값 { name: '서연' }으로 바꾸면 b.nick이 그대로 null이라 b.name인 서연이 나와 서연 서연이 찍힙니다.

  • 다르게 써도 되는 곳: MemberPatch를 Partial<Member>로 적어도 이 코드는 똑같이 지나갑니다(Partial<NewMember>로 적으면 고유 번호 id를 실수로 덮어쓰는 것까지 막아 줍니다).
  • 검사 오류가 0이고, 찍은 화면이 위와 같고, 둘째 시험 값도 맞으면 맞습니다.
  • 흔한 오답 하나는 p: NewMember로 적은 것입니다. 그러면 { nick: '푸른별' }에서 name이 빠졌다고 TS2741로 막힙니다.

다시 볼 레슨: 있는 타입에서 새 타입 — Pick·Omit·Partial · null·undefined 거르기

문제 12 (만들어 보기)

이번에는 빈 데서 씁니다. 회원 등급 상수 배열 tiers에서 등급 타입 Tier를 뽑고, 등급별 배송비 표 FeeTable(Record 사용)과 일부 등급만 고치는 FeePatch(Partial 사용), 표를 고치는 patchFee를 만듭니다. 여기에 바깥에서 온 키 k: unknown이 Tier인지 확인해 배송비를 돌려주고 아니면 -1을 돌려주는 readFee까지 만들어 봅니다. 다 채워 돌리면 이렇게 나와야 합니다.

2500 -1

patch를 { vip: 1000 }으로 바꾸고 readFee(next, 'vip')과 readFee(next, 'std')를 찍으면 이렇게 나와야 합니다.

1000 3000

아래 시작 값에 이어서 타입 Tier · FeeTable · FeePatch, 함수 patchFee · readFee, 그리고 base에 patch를 반영한 next에서 'std'와 'gold'의 배송비를 찍는 코드를 쓰세요. 검사 오류는 0이어야 하고, as 단언이나 any로 덮은 답은 받지 않습니다.

const tiers = [
  'std', 'vip',
] as const;
const base = {
  std: 3000,
  vip: 0,
};
const patch = { std: 2500 };
힌트

먼저 tiers에서 (typeof tiers)[number]⁠로 Tier를 뽑고, Record<Tier, number>와 Partial<FeeTable>로 FeeTable과 FeePatch를 만듭니다. readFee 안에서는 k === 'std' || k === 'vip'⁠으로 k를 Tier로 좁힌 뒤 f[k]를 읽습니다.

힌트 둘

patchFee는 f: FeeTable과 p: FeePatch를 받아 { ...f, ...p }를 돌려줍니다. readFee는 조건이 거짓일 때 -1을 돌려줍니다.

정답

모범 답입니다.

const tiers = [
  'std', 'vip',
] as const;
type Tier =
  (typeof tiers)[number];
type FeeTable =
  Record<Tier, number>;
type FeePatch =
  Partial<FeeTable>;
function patchFee(
  f: FeeTable,
  p: FeePatch,
): FeeTable {
  return { ...f, ...p };
}

앞 코드에 이어 씁니다.

function readFee(
  f: FeeTable,
  k: unknown,
): number {
  if (
    k === 'std' ||
    k === 'vip'
  ) {
    return f[k];
  }
  return -1;
}

이어서 실행 코드를 씁니다.

const next = patchFee(
  { std: 3000, vip: 0 },
  { std: 2500 },
);
console.log(
  readFee(next, 'std'),
  readFee(next, 'gold'),
);
실행 결과
2500 -1

tiers에 as const가 붙어 Tier는 "std" | "vip"이 되고(검사 규칙 3), FeeTable은 두 등급 키가 모두 number인 객체 타입, FeePatch는 일부 등급만 적는 수정 타입이 됩니다(검사 규칙 4 · 7). readFee는 unknown으로 받은 k를 k === 'std' || k === 'vip'⁠으로 확인해 "std" | "vip"(Tier)으로 좁힌 뒤에만 f[k]를 꺼내므로 단언 없이도 안전합니다(검사 규칙 5).

  • 다르게 써도 되는 곳: 시작 값의 base: FeeTable과 patch: FeePatch 변수를 선언해 patchFee(base, patch)⁠로 넘겨도 똑같이 동작합니다. isTier(k: unknown): k is Tier 확인 함수를 따로 만들어 써도 좋습니다.
  • 검사 오류가 0이고, 찍은 화면이 위와 같고, 둘째 시험 값도 맞으면 맞습니다.
  • 흔한 오답 하나는 readFee 안에서 확인 없이 return f[k];를 쓴 것입니다. k가 unknown이라 인덱스 자리에 쓸 수 없다고 막힙니다.
  • 다른 흔한 오답은 return f[k as Tier];로 단언한 것입니다. 검사는 건너뛰지만 'gold'가 들어왔을 때 -1 대신 undefined가 나와 실행 결과가 틀어집니다(단언이 값을 바꾼다는 생각 · 검사를 지나면 맞다는 생각).

다시 볼 레슨: 값에서 타입 뽑기 — typeof와 as const · Record — 키 타입으로 만드는 객체 타입 · 있는 타입에서 새 타입 — Pick·Omit·Partial · unknown — 확인하고 쓰기

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

타입스크립트 목차로