async 함수는 돌려주는 값을 Promise 상자에 담아 Promise<T> 타입으로 돌려주고, await가 그 상자를 열어 T 타입의 값을 꺼냅니다. 검사기는 이 타입을 따라가, await를 빠뜨리고 상자째 셈하는 줄을 실행하기 전에 짚어 줍니다.
제네릭 타입 — Array·Map·내 타입에서 타입 이름에 단 칸을 채워 썼습니다. Promise도 T 칸 하나를 단 제네릭 타입입니다. 이 레슨에서는 async 함수가 돌려주는 타입, 나중에 도는 콜백 안의 좁힘, 거부된 Promise가 던지는 오류의 타입을 차례로 읽습니다.
JS 다시 보기: async 함수는 return한 값을 Promise 상자에 감싸 돌려주고, Node는 그 상자를 Promise { 4000 }처럼 찍습니다. await는 상자 안의 값을 꺼내며, 모듈 파일에서는 맨 위에서도 쓸 수 있습니다(JS 'async와 await').
1async 함수는 Promise<T>를 돌려준다
async function getPrice() {
return 4000;
}
const price = await getPrice();
console.log(price + 500);
4500돌려주는 타입은 return에서 추론합니다(검사 규칙 3). 2번 줄의 4000은 넓혀 number가 되고, 그 타입이 Promise 상자의 T 칸에 들어가 getPrice는 Promise<number>를 돌려줍니다.
4번 줄의 await는 상자를 열어 T 칸의 값을 꺼냅니다. 그래서 price는 T 칸에 채운 number이고, 5번 줄의 셈이 검사를 지납니다(검사 규칙 7).
4번 줄에서 await를 빼 봅니다.
async function getPrice() {
return 4000;
}
const price = getPrice();
console.log(price + 500);
검사를 지나갈까요? 지나가지 못하면 몇 번 줄에서 막힐까요? Node로 돌리면 무엇이 찍힐까요?
검사 결과와 실행 결과 보기
main.ts(5,13): error TS2365: Operator '+' cannot be applied to types 'Promise<number>' and '500'.[object Promise]500예상과 다르면 그곳이 배울 곳입니다. await가 없으니 price는 상자째인 Promise<number>입니다. 5번 줄의 TS2365는 '+ 연산자는 Promise<number>와 500에 쓸 수 없다'는 뜻입니다. 터미널의 꾸민 꼴에는 Did you forget to use 'await'?('await를 빠뜨렸나요?')라는 도움말 줄도 붙습니다.
검사 오류가 있어도 Node는 타입을 지우고 돌립니다(검사 규칙 1). +는 상자를 글자 [object Promise]로 바꾸고, 한쪽이 글자이니 500을 이어 붙입니다.
5번 줄이 지나가리라 봤다면 getPrice가 4000을 바로 돌려준다고 본 것입니다(async 함수가 값을 돌려준다는 생각).
2나중에 도는 콜백 안의 좁힘
JS 다시 보기: setTimeout에 넘긴 콜백은 0ms여도 지금 코드가 끝까지 돈 뒤에 불립니다(JS '나중에 하기: setTimeout'). 콜백은 만들어진 칸을 기억해, 나중에 불려도 그 칸의 이름을 읽습니다(JS '클로저: 만들어진 칸을 기억하는 함수').
확인한 갈래 안에서는 이름의 지금 타입이 좁혀집니다(검사 규칙 5). 그 갈래 안에서 만든 콜백이 나중에 불리면 어떻게 될까요? 메모가 있으면 타이머로 메모의 글자 수를 찍습니다. 1~2번 줄은 폰 화면에 맞추려고 = 뒤에서 나눈 한 문장입니다.
let memo: string | null =
'얼음 적게';
if (memo !== null) {
setTimeout(() => {
console.log(memo.length);
}, 0);
}
memo = null;
검사를 지나갈까요? 지나가지 못하면 몇 번 줄에서 막힐까요? Node로 돌리면 무엇이 찍힐까요?
검사 결과와 실행 결과 보기
main.ts(5,17): error TS18047: 'memo' is possibly 'null'.main.ts:5 console.log(memo.length); ^ TypeError: Cannot read properties of null (reading 'length') at Timeout._onTimeout (main.ts:5:22)예상과 다르면 그곳이 배울 곳입니다. 3번 줄의 확인 바로 아래에서 memo는 string으로 좁혀집니다. 그런데 5번 줄은 콜백 안이라, 검사기는 memo를 선언 타입 string | null로 되돌려 봅니다. 콜백이 언제 불릴지 모르는데, 8번 줄이 memo에 null을 다시 대입하기 때문입니다. TS18047은 'memo는 null일 수 있다'는 뜻입니다.
실행하면 검사기 말대로 됩니다. 콜백은 0ms여도 8번 줄까지 다 돈 뒤에 불리고, 기억해 둔 이름 memo의 그때 값을 읽습니다 — JS 규칙 8(이벤트 루프)과 JS 규칙 7(클로저)입니다. memo는 이미 null이라 5번 줄에서 TypeError로 멈춥니다.
5번 줄이 지나가리라 봤다면 3번 줄의 좁힘이 콜백 안까지 간다고 본 것입니다(좁힘이 끝까지 간다는 생각).
오류가 난 5번 줄을 고칠 줄로 보기 쉽지만, 원인은 콜백을 만든 뒤에 memo를 다시 대입하는 8번 줄입니다. 8번 줄이 없으면 memo가 null로 바뀔 길이 없어, 같은 코드가 검사를 지나고 5를 찍습니다.
플레이그라운드에서는 맨 위의 export {};를 빠뜨리면 코드를 이 과목과 다른 방식으로 읽어, 8번 줄을 지워도 TS18047이 납니다(타입 검사기가 하는 일의 '해 보기').
이제 검사 규칙 5에 콜백 줄을 더합니다.
검사 규칙 5. 검사기는 값을 모릅니다. 그래서 갈 수 있는 길을 모두 읽습니다 —
if·else는 두 갈래를 모두, 반복은 덩어리 끝에서 머리로 돌아오는 길까지 읽습니다. 확인 뒤의 갈래와 대입 뒤에서는 이름의 지금 타입이 좁혀지고, 길이 다시 만나는 줄(갈래 뒤 · 반복 머리)에서는 그 줄로 오는 길들의 지금 타입이 합쳐집니다.return으로 끝난 길은 오지 않습니다. 콜백은 언제 불릴지 몰라서, 콜백을 만든 뒤에 다시 대입하는let이름은 콜백 안에서 좁힘을 풀고 선언 타입으로 봅니다.
고치는 길은 둘입니다. 하나는 확인한 값을 const 이름에 받아 콜백에 넘기는 것입니다.
let memo: string | null =
'얼음 적게';
if (memo !== null) {
const text = memo;
setTimeout(() => {
console.log(text.length);
}, 0);
}
memo = null;
54번 줄의 text는 const라 다시 대입할 수 없으니, 콜백 안에서도 string입니다. 9번 줄이 memo를 null로 바꿔도 text는 '얼음 적게'에 붙어 있어 글자 수 5를 찍습니다.
다른 하나는 콜백 안에서 if (memo !== null)로 다시 확인하는 것입니다. 이 길도 검사를 지나지만, 타이머가 돌 때 memo가 이미 null이라 아무것도 찍히지 않습니다.
예제
검사 결과와 실행 결과를 먼저 적어 본 뒤 펼쳐 보세요. 맞혀 보기는 점수에 들지 않습니다.
JS 다시 보기: async 함수 안에서 던진 오류는 돌려주는 Promise를 거부(rejected) 상태로 만들고, 그 Promise를 await하면 그 자리에서 다시 던져져 try·catch로 잡힙니다(JS '기다리는 일의 오류').
빙수는 품절이라 getPrice가 값 대신 오류를 던집니다. 그 Promise를 try 안에서 await로 엽니다.
async function getPrice(
name: string,
) {
if (name === '빙수') {
throw new Error('품절');
}
return 4000;
}
try {
const p =
await getPrice('빙수');
console.log(p + 500);
} catch (e) {
console.log(e.message);
}
검사를 지나갈까요? 지나가지 못하면 몇 번 줄에서 막힐까요? Node로 돌리면 무엇이 찍힐까요?
검사 결과와 실행 결과 보기
main.ts(14,15): error TS18046: 'e' is of type 'unknown'.품절예상과 다르면 그곳이 배울 곳입니다. getPrice는 Promise<number>를 돌려주고, 11번 줄의 await가 꺼낸 p는 number입니다. 그러나 Promise<T>에는 거부될 때 던질 값의 타입을 적는 칸이 없습니다. 그래서 13번 줄 catch의 e는 unknown이고, 확인 없이 e.message를 꺼낸 14번 줄이 TS18046으로 막힙니다(unknown — 확인하고 쓰기).
실행하면 5번 줄의 throw가 Promise를 거부 상태로 만들고, 11번 줄의 await가 그 오류를 다시 던집니다. 12번 줄은 건너뛰고 catch로 가 품절을 찍습니다.
14번 줄을 if (e instanceof Error)로 확인한 갈래 안에 넣으면 검사를 지나고, 같은 품절을 찍습니다.
14번 줄이 지나가리라 봤다면 getPrice가 던지는 값이 Error라는 것을 검사기도 안다고 본 것입니다.
자주 틀리는 곳
틀린 생각: async 함수를 부르면 return한 값이 바로 나온다.
return 4000;이라 적었으니, 부른 자리에 4000이 올 것 같습니다.
async function getPrice() {
return 4000;
}
const n: number = getPrice();
main.ts(4,7): error TS2322: Type 'Promise<number>' is not assignable to type 'number'.맞는 규칙: async 함수는 늘 Promise 상자를 돌려줍니다. 부른 자리의 타입은 Promise<number>이고, number는 await로 상자를 연 뒤에 얻습니다(1절).
틀린 생각: async 함수의 돌려주는 타입에는 return하는 값의 타입을 적는다.
return하는 값이 4000이니, 돌려주는 타입 자리에 number를 적으면 될 것 같습니다.
async function getPrice(
name: string,
): number {
if (name === '빙수') {
throw new Error('품절');
}
return 4000;
}
getPrice('커피').then((p) => {
console.log(p + 500);
});
main.ts(3,4): error TS1064: The return type of an async function or method must be the global Promise<T> type. Did you mean to write 'Promise<number>'?main.ts(9,16): error TS2339: Property 'then' does not exist on type 'number'.main.ts(9,22): error TS7006: Parameter 'p' implicitly has an 'any' type.맞는 규칙: async 함수의 돌려주는 타입은 상자째인 Promise<number>로 적습니다. TS1064는 'async 함수의 돌려주는 타입은 전역 Promise<T> 타입이어야 한다. Promise<number>라고 쓰려던 것인가?'라는 뜻입니다. 9번 줄의 두 오류는 3번 줄의 number를 믿어 생긴 것이라, 3번 줄을 ): Promise<number> {로 고치면 셋이 함께 사라지고 4500을 찍습니다. 오류가 여럿이면 위에서부터 고치고 다시 검사합니다.
틀린 생각: fetch로 받은 응답을 json()으로 꺼내면, 그 값에도 알맞은 타입이 붙는다.
검사기가 fetch의 타입을 알고 있으니, 꺼낸 메뉴의 모양도 알 것 같습니다.
async function getMenu(
url: string,
) {
const res = await fetch(url);
return res.json();
}
맞는 규칙: 4번 줄의 res는 응답 타입 Response이지만, 5번 줄의 res.json()은 Promise<any>를 돌려줍니다. 그래서 getMenu도 Promise<any>를 돌려주고, await로 꺼낸 메뉴는 any라 무엇을 꺼내도 검사를 지납니다(검사 규칙 6). 바깥에서 받은 값은 unknown으로 받아 확인한 뒤에 씁니다(바깥 값 확인하기).
틀린 생각: 확인한 갈래 안에서 만든 콜백이면 좁힘이 콜백 안까지 남으니, TS18047은 !로 덮어도 된다.
확인한 줄 바로 아래에서 만든 콜백이라, 검사기가 지나치게 조심하는 것처럼 보입니다. 2절 코드의 5번 줄을 console.log(memo!.length);로 바꾸면 검사는 지나갑니다. 그러나 Node는 타입 글자인 !를 빈칸으로 지우고 돌려, 2절과 같은 TypeError로 멈춥니다.
맞는 규칙: !는 검사기가 null을 짚지 않게 할 뿐, 값은 그대로 둡니다(검사 규칙 6). 나중에 불릴 콜백은 그 사이 다시 대입된 값을 보니, 확인한 값을 const 이름에 받아 넘기거나 콜백 안에서 확인합니다(2절 · 검사 규칙 5).
확인 문제
1. 손님 번호로 이름을 만드는 async 함수를 부릅니다. 6 · 7번 줄의 a · b는 각각 어떤 타입일까요? Node가 찍을 값도 적어 보세요.
async function getName(
id: number,
) {
return '손님' + id;
}
const a = getName(1);
const b = await getName(2);
console.log(a, b);
힌트
async 함수가 돌려주는 것은 늘 Promise 상자입니다. 상자는 await가 엽니다.
정답
| 이름 | 타입 |
|---|---|
| a | Promise<string> |
| b | string |
Promise { '손님1' } 손님24번 줄의 '손님' + id는 글자라 getName은 Promise<string>을 돌려줍니다. 6번 줄은 await 없이 받아 a가 상자째이고, 7번 줄은 await로 열어 b가 string입니다. Node는 상자를 Promise { '손님1' }처럼 찍습니다.
흔한 오답은 둘입니다.
- a를 string으로 적음 — 함수가 글자를 바로 돌려준다고 본 것입니다(async 함수가 값을 돌려준다는 생각).
- 첫 값을
손님1로 적음 — console.log가 상자를 열어 준다고 본 것입니다. console.log는 상자째 찍습니다.
2. 잔 수로 적립 점수를 셉니다. 검사를 지나도록 3번 줄의 빈칸에 돌려주는 타입을 적으세요. 6번 줄 p의 타입과 Node가 찍을 값도 적어 보세요.
async function getPoint(
cups: number,
): ____ {
return cups * 100;
}
const p = await getPoint(3);
console.log(p + 50);
힌트
async 함수가 돌려주는 상자의 타입을 적습니다. 상자의 T 칸에는 return하는 값의 타입이 들어갑니다.
정답
빈칸은 Promise<number>입니다. cups * 100은 수라 상자의 T 칸이 number입니다. 6번 줄의 await가 상자를 열어 p는 number이고, Node는 300에 50을 더한 350을 찍습니다.
다르게 써도 되는 곳: 3번 줄의 : ____을 통째로 지워 ) {로 써도, 검사기가 return한 값에서 Promise<number>를 정해 결과가 같습니다. Promise<any>도 지나가지만 p가 any가 되어 검사를 끄는 것이라 쓰지 않습니다(any는 검사를 끈다).
흔한 오답은 return하는 값의 타입 number를 그대로 적는 것입니다. 그러면 3번 줄이 TS1064로 막힙니다(자주 틀리는 곳).
3. 메뉴 값마다 팁을 더해 찍습니다. 검사를 지나갈까요? 지나가지 못하면 몇 번 줄에서 막힐까요? Node로 돌리면 무엇이 찍힐까요?
let tip: number | null = 500;
if (tip !== null) {
[4000, 4500].forEach((p) => {
console.log(p + tip);
});
}
tip = null;
힌트
forEach가 3번 줄의 콜백을 언제 부르는지, 7번 줄이 tip에 무엇을 하는지 봅니다.
정답
4번 줄이 막힙니다.
main.ts(4,21): error TS18047: 'tip' is possibly 'null'.4500
50007번 줄이 콜백을 만든 뒤에 tip을 다시 대입하니, 검사기는 콜백 안 4번 줄의 tip을 number | null로 봐 TS18047로 막습니다(검사 규칙 5). 검사기는 콜백이 언제 불리는지 따지지 않습니다.
실행하면 forEach는 콜백을 그 자리에서 원소마다 부릅니다. 7번 줄보다 먼저 불려 tip이 아직 500이라 4500과 5000이 찍힙니다. 검사기는 실행 차례를 따라가지 않아, 이번처럼 실제로는 문제없이 도는 코드도 막을 수 있습니다.
흔한 오답은 둘입니다.
- 막히는 줄이 없다 — forEach가 바로 부르는 것을 검사기도 안다고 본 것입니다(검사기가 실행을 따라간다는 생각).
- 실행하면 TypeError — 2절의 setTimeout처럼 나중에 불린다고 본 것입니다. forEach는 지금 부르고, setTimeout은 지금 코드가 끝난 뒤에 부릅니다.
컴퓨터에서 해 보기(선택) — 점수에 들지 않습니다. 폰으로만 보고 있다면 건너뛰세요.
1절 첫 코드를 main.ts로 저장하고, 4번 줄의 await를 지운 뒤 5번 줄을 console.log(price);로 바꿉니다. 검사 결과와 Node가 찍을 값을 먼저 적고, npx tsc로 검사한 뒤 node main.ts로 돌려 맞춰 봅니다. 편집기에서 price에 마우스를 올려 price의 타입도 확인합니다.
검사하고 돌리는 법은 내 컴퓨터에서 검사하고 돌리기에 있습니다.