기초
기초 (The Basics)
TypeScript를 처음 접할 때 가장 먼저 맞닥뜨리는 질문이 "타입 형식이 대체 왜 필요한 거지?"일 거예요. 이 장은 그 질문에서 출발해, 값의 타입이 무엇인지, 정적 타입 검사가 코드를 실행하기 전에 어떤 버그를 잡아주는지, 그리고 tsc 컴파일러가 어떻게 동작하는지 하나씩 짚어 볼게요. TypeScript 공식 핸드북의 첫 페이지로, 여기서 익힌 개념들이 뒤의 모든 장의 기초가 됩니다.
출처: TypeScript 핸드북
JavaScript의 모든 값에는 우리가 여러 연산을 실행하며 관찰할 수 있는 동작들의 집합이 있습니다. 추상적으로 들리지만, message라는 변수에 대해 실행할 수 있는 몇 가지 연산을 예로 들어 봅시다.
// Accessing the property 'toLowerCase'
// on 'message' and then calling it
message.toLowerCase();
// Calling 'message'
message();
이를 나눠보면, 첫 번째 실행 가능한 코드 줄은 toLowerCase라는 프로퍼티에 접근한 다음 호출합니다. 두 번째는 message를 직접 호출하려고 합니다.
하지만 message의 값을 모른다고 가정하면(그리고 그런 경우가 꽤 흔하죠), 이 코드들을 실행하려고 할 때 어떤 결과가 나올지 확실히 말할 수 없습니다. 각 연산의 동작은 우리가 처음에 어떤 값을 가졌는지에 전적으로 달려 있거든요.
message는 호출 가능한가?toLowerCase라는 프로퍼티를 갖고 있는가?- 갖고 있다면,
toLowerCase는 호출 가능한가? - 이 두 값이 모두 호출 가능하다면, 무엇을 반환하는가?
이 질문들에 대한 답은 보통 JavaScript를 쓸 때 머릿속에 담아두는 것들이고, 우리는 모든 세부 사항을 제대로 기억하길 바라야 합니다.
message가 다음과 같이 정의되었다고 해 봅시다.
const message = "Hello World!";
예상할 수 있듯이, message.toLowerCase()를 실행하면 같은 문자열을 소문자로만 바꿔서 얻을 수 있습니다.
그럼 두 번째 코드 줄은 어떨까요? JavaScript에 익숙하다면 이것이 예외로 실패한다는 걸 알 거예요.
TypeError: message is not a function
이런 실수를 피할 수 있다면 얼마나 좋을까요.
코드를 실행할 때, JavaScript 런타임이 무엇을 할지 선택하는 방식은 값의 타입 — 그 값이 어떤 동작과 능력을 갖는지 — 을 파악하는 것입니다. 그 TypeError가 암시하는 것의 일부가 바로 그거예요. 문자열 "Hello World!"는 함수로 호출될 수 없다고 말하는 것이죠.
일부 값들, 예를 들어 원시 타입인 string과 number는 typeof 연산자를 사용해 런타임에 그 타입을 식별할 수 있어요. 하지만 함수 같은 다른 것들은 그 타입을 식별할 상응하는 런타임 메커니즘이 없습니다. 예를 들어 이 함수를 생각해 봅시다.
function fn(x) {
return x.flip();
}
코드를 읽으면 이 함수가 호출 가능한 flip 프로퍼티를 가진 객체가 주어질 때만 동작한다는 것을 관찰 할 수 있지만, JavaScript는 코드가 실행되는 동안 우리가 확인할 수 있는 방식으로 이 정보를 드러내지 않습니다. 순수 JavaScript에서 fn이 특정 값으로 무엇을 하는지 알 수 있는 유일한 방법은 호출해서 무슨 일이 일어나는지 보는 것입니다. 이런 종류의 동작은 코드가 실행되기 전에 무엇을 할지 예측하기 어렵게 만들며, 작성하는 동안 코드가 무엇을 할지 알기 어렵게 만듭니다.
이런 관점에서 보면, 타입 은 어떤 값들이 fn에 전달될 수 있고 어떤 값들이 crash 할지 설명하는 개념입니다. JavaScript는 진정으로 동적 타이핑만을 제공합니다 — 코드를 실행해서 무슨 일이 일어나는지 보는 것이죠.
대안은 정적 타입 시스템을 사용해 코드가 실행되기 전에 코드가 무엇을 하도록 기대되는지 예측하는 것입니다.
정적 타입 검사 (Static type-checking)
앞서 string을 함수로 호출하려고 해서 얻었던 그 TypeError를 다시 떠올려 봅시다. 대부분의 사람들 은 코드를 실행할 때 어떤 종류의 에러도 받는 것을 좋아하지 않습니다 — 그것들은 버그로 간주되니까요! 그리고 새 코드를 작성할 때 우리는 새 버그를 도입하지 않으려 최선을 다합니다.
코드를 조금 추가하고, 파일을 저장하고, 코드를 다시 실행하고, 즉시 에러를 본다면 문제를 빠르게 격리할 수 있을지도 모릅니다. 하지만 항상 그런 건 아니에요. 기능을 충분히 철저히 테스트하지 않아서 던져질 수 있는 잠재적인 에러를 실제로 절대 만나지 못할 수도 있고요. 혹은 운 좋게 에러를 목격했다 해도, 큰 리팩터링을 하고 파고들어야 할 많은 다른 코드를 추가한 후일 수도 있어요.
이상적으로는 코드가 실행되기 전에 이런 버그를 찾는 데 도움이 되는 도구를 가질 수 있다면 좋겠죠. TypeScript 같은 정적 타입 검사기가 하는 일이 바로 그겁니다. 정적 타입 시스템 은 프로그램을 실행할 때 우리의 값들이 가질 모양과 동작을 설명합니다. TypeScript 같은 타입 검사기는 그 정보를 사용해 사물이 잘못되고 있을 때 우리에게 알려 줍니다.
// @errors: 2349
const message = "hello!";
message();
마지막 예시를 TypeScript로 실행하면 코드를 처음 실행하기 전에 에러 메시지를 받게 됩니다.
예외가 아닌 실패 (Non-exception Failures)
지금까지 우리는 런타임 에러와 같은 것들에 대해 이야기해 왔습니다 — JavaScript 런타임이 무언가가 무의미하다고 생각한다고 알려주는 경우들이죠. 이런 경우들은 ECMAScript 명세가 언어가 예상치 못한 것을 만났을 때 어떻게 동작해야 하는지에 대한 명확한 지침을 갖고 있기 때문에 발생합니다.
예를 들어 명세는 호출 가능하지 않은 것을 호출하려고 하면 에러를 던져야 한다고 말합니다. 그것이 "당연한 동작"처럼 들릴 수도 있지만, 객체에 존재하지 않는 프로퍼티에 접근하는 것도 에러를 던져야 한다고 상상할 수 있을 거예요. 대신 JavaScript는 다른 동작을 제공하며 undefined 값을 반환합니다.
const user = {
name: "Daniel",
age: 26,
};
user.location; // returns undefined
결국 정적 타입 시스템은 즉시 에러를 던지지 않는 "유효한" JavaScript라 해도, 시스템에서 어떤 코드를 에러로 표시할지 결정해야 합니다. TypeScript에서 다음 코드는 location이 정의되지 않았다는 에러를 만들어냅니다.
// @errors: 2339
const user = {
name: "Daniel",
age: 26,
};
user.location;
때로는 이것이 여러분이 표현할 수 있는 것에 있어 트레이드오프를 의미하기도 하지만, 의도는 프로그램에서 정당한 버그를 잡아내는 것입니다. 그리고 TypeScript는 많은 정당한 버그를 잡아냅니다.
예를 들어, 오타,
// @noErrors
const announcement = "Hello World!";
// How quickly can you spot the typos?
announcement.toLocaleLowercase();
announcement.toLocalLowerCase();
// We probably meant to write this...
announcement.toLocaleLowerCase();
호출되지 않은 함수,
// @noUnusedLocals
// @errors: 2365
function flipCoin() {
// Meant to be Math.random()
return Math.random < 0.5;
}
또는 기본적인 논리 오류 같은 것들이죠.
// @errors: 2367
const value = Math.random() < 0.5 ? "a" : "b";
if (value !== "a") {
// ...
} else if (value === "b") {
// Oops, unreachable
}
도구를 위한 타입 (Types for Tooling)
TypeScript는 코드에서 실수를 할 때 버그를 잡아낼 수 있습니다. 훌륭하죠. 하지만 TypeScript는 또한 그러한 실수를 애초에 하지 않도록 해 줄 수도 있어요.
타입 검사기는 변수와 다른 프로퍼티들에 올바른 프로퍼티에 접근하고 있는지 같은 것을 검사할 정보를 갖고 있습니다. 그 정보를 갖게 되면, 어떤 프로퍼티를 사용하고 싶을지 제안 하기 시작할 수도 있어요.
즉 TypeScript는 코드 편집에도 활용될 수 있고, 핵심 타입 검사기는 에디터에서 타이핑할 때 에러 메시지와 코드 완성을 제공할 수 있습니다. 이것이 사람들이 TypeScript에서 툴링(tooling)에 대해 말할 때 자주 언급하는 것의 일부입니다.
// @noErrors
// @esModuleInterop
import express from "express";
const app = express();
app.get("/", function (req, res) {
res.sen
// ^|
});
app.listen(3000);
TypeScript는 툴링을 진지하게 여기며, 그건 타이핑할 때의 완성과 에러를 넘어섭니다. TypeScript를 지원하는 에디터는 에러를 자동으로 고쳐주는 "빠른 수정(quick fixes)", 코드를 쉽게 재구성하는 리팩터링, 변수의 정의로 점프하거나 주어진 변수에 대한 모든 참조를 찾는 유용한 탐색 기능을 제공할 수 있습니다. 이 모든 것은 타입 검사기 위에 구축되며 완전히 크로스 플랫폼이라, 여러분이 좋아하는 에디터에 TypeScript 지원이 있을 가능성이 높습니다.
tsc, TypeScript 컴파일러
지금까지 우리는 타입 검사에 대해 이야기했지만, 아직 우리의 타입 검사기 를 사용하지는 않았어요. 우리의 새로운 친구 tsc, TypeScript 컴파일러를 알아봅시다. 먼저 npm을 통해 가져와야 합니다.
npm install -g typescript
이 명령은 TypeScript 컴파일러인
tsc를 전역으로 설치합니다. 로컬node_modules패키지에서tsc를 실행하는 것을 선호한다면npx또는 유사한 도구를 사용할 수 있어요.
이제 빈 폴더로 이동해 우리의 첫 TypeScript 프로그램 hello.ts를 작성해 봅시다.
// Greets the world.
console.log("Hello world!");
여기에는 꾸밈이 없다는 점을 주목하세요. 이 "hello world" 프로그램은 JavaScript에서 작성하는 "hello world" 프로그램과 동일해 보입니다. 이제 typescript 패키지가 우리를 위해 설치해 준 tsc 명령을 실행해 타입 검사를 해 봅시다.
tsc hello.ts
짜잔!
잠깐, 정확히 "짜잔" 뭐가 뭐라구요? tsc를 실행했는데 아무 일도 일어나지 않았어요! 음, 타입 에러가 없었으므로 보고할 것이 없어서 콘솔에 출력을 얻지 못한 것입니다.
하지만 다시 확인해 보세요 — 대신 파일 출력을 얻었습니다. 현재 디렉토리를 보면 hello.ts 옆에 hello.js 파일이 보일 거예요. 그것은 tsc가 hello.ts를 평범한 JavaScript 파일로 컴파일 또는 변환 한 후의 출력입니다. 내용을 확인하면 TypeScript가 .ts 파일을 처리한 후 뱉어내는 것을 볼 수 있어요.
// Greets the world.
console.log("Hello world!");
이 경우 TypeScript가 변환해야 할 것이 거의 없었으므로 우리가 작성한 것과 동일해 보입니다. 컴파일러는 사람이 작성할 것처럼 보이는 깨끗하고 읽을 수 있는 코드를 방출하려고 노력합니다. 항상 쉬운 건 아니지만, TypeScript는 일관되게 들여쓰기하고, 코드가 여러 줄에 걸칠 때를 신경 쓰며, 주석을 유지하려고 노력합니다.
그런데 만약 우리가 실제로 타입 검사 에러를 도입한다면 어떨까요? hello.ts를 다시 작성해 봅시다.
// @noErrors
// This is an industrial-grade general-purpose greeter function:
function greet(person, date) {
console.log(`Hello ${person}, today is ${date}!`);
}
greet("Brendan");
다시 tsc hello.ts를 실행하면 명령줄에 에러가 표시되는 것을 볼 수 있어요!
Expected 2 arguments, but got 1.
TypeScript가 우리에게 greet 함수에 인자를 전달하는 것을 잊었다고 알려주고 있으며, 그럴 만합니다. 지금까지 우리는 표준 JavaScript만 작성했는데도 타입 검사가 여전히 우리 코드의 문제를 찾을 수 있었어요. 고마워요 TypeScript!
에러가 있어도 방출하기 (Emitting with Errors)
마지막 예시에서 눈치채지 못했을 한 가지는 hello.js 파일이 다시 변경된 것입니다. 그 파일을 열면 내용이 여전히 우리 입력 파일과 기본적으로 같아 보이는 것을 볼 수 있을 거예요. tsc가 우리 코드에 대해 에러를 보고했음에도 이는 다소 놀라울 수 있지만, 이는 TypeScript의 핵심 가치 중 하나에 기반합니다: 많은 경우 당신이 TypeScript보다 더 잘 알 것입니다.
앞서 다시 강조하자면, 타입 검사는 실행할 수 있는 프로그램의 종류를 제한하므로 타입 검사기가 수용 가능하다고 여기는 것에 대한 트레이드오프가 있습니다. 대부분의 경우 그건 괜찮지만, 그 검사들이 방해가 되는 시나리오가 있기도 해요. 예를 들어 JavaScript 코드를 TypeScript로 마이그레이션하면서 타입 검사 에러를 도입하는 상황을 상상해 봅시다. 결국 타입 검사기를 위해 정리하게 되겠지만, 그 원래 JavaScript 코드는 이미 _동작하고 있었거든요! TypeScript로 변환한다고 실행을 막아야 할 이유가 뭘까요?
그래서 TypeScript는 방해하지 않습니다. 물론 시간이 지나면서 실수에 대해 좀 더 방어적으로 행동하고 싶고 TypeScript가 조금 더 엄격하게 작동하길 원할 수도 있어요. 그런 경우 noEmitOnError 컴파일러 옵션을 사용할 수 있습니다. hello.ts 파일을 바꾸고 그 플래그로 tsc를 실행해 보세요.
tsc --noEmitOnError hello.ts
hello.js가 절대 갱신되지 않는 것을 볼 수 있을 거예요.
명시적 타입 (Explicit Types)
지금까지 우리는 TypeScript에 person이나 date가 무엇인지 알려주지 않았어요. person은 string이고, date는 Date 객체여야 한다고 TypeScript에 알려주도록 코드를 수정해 봅시다. date에는 toDateString() 메서드도 사용할 거예요.
function greet(person: string, date: Date) {
console.log(`Hello ${person}, today is ${date.toDateString()}!`);
}
우리가 한 것은 person과 date에 타입 주석 을 추가해 greet가 어떤 타입의 값으로 호출될 수 있는지 설명한 것입니다. 그 시그니처는 "greet은 string 타입의 person과 Date 타입의 date를 받는다"로 읽을 수 있습니다.
이를 통해 TypeScript는 greet이 잘못 호출되었을 수 있는 다른 경우에 대해 우리에게 말해 줄 수 있어요. 예를 들어...
// @errors: 2345
function greet(person: string, date: Date) {
console.log(`Hello ${person}, today is ${date.toDateString()}!`);
}
greet("Maddison", Date());
어? TypeScript가 두 번째 인자에 대해 에러를 보고했는데, 왜일까요?
아마도 놀랍게도, JavaScript에서 Date()를 호출하면 string을 반환합니다. 반면 new Date()로 Date를 생성하면 실제로 우리가 기대했던 것을 줍니다.
어쨌든 에러를 빠르게 고칠 수 있어요.
function greet(person: string, date: Date) {
console.log(`Hello ${person}, today is ${date.toDateString()}!`);
}
greet("Maddison", new Date());
명심하세요, 우리가 항상 명시적 타입 주석을 작성해야 하는 것은 아닙니다. 많은 경우 TypeScript는 주석을 생략해도 우리를 위해 타입을 추론 (또는 "알아내기") 할 수 있습니다.
let msg = "hello there!";
// ^?
TypeScript에 msg가 string 타입이라고 말하지 않았는데도 그것을 알아냈어요. 그것은 기능이며, 타입 시스템이 어차피 같은 타입을 추론하게 될 때는 주석을 추가하지 않는 것이 가장 좋습니다.
참고: 이전 코드 샘플 안의 말풍선은 단어 위에 마우스를 올렸을 때 에디터가 보여줄 내용입니다.
지워지는 타입 (Erased Types)
위의 greet 함수를 tsc로 컴파일해 JavaScript를 출력하면 무슨 일이 일어나는지 살펴봅시다.
// @showEmit
// @target: es5
function greet(person: string, date: Date) {
console.log(`Hello ${person}, today is ${date.toDateString()}!`);
}
greet("Maddison", new Date());
여기서 두 가지를 주목하세요.
- 우리의
person과date매개변수에 더 이상 타입 주석이 없습니다. - 우리의 "템플릿 문자열" — 역따옴표(
`문자)를 사용했던 그 문자열 — 이 연결(concatenation)을 사용한 일반 문자열로 변환되었습니다.
두 번째 점에 대해서는 나중에 더 다루지만, 지금은 첫 번째 점에 집중해 봅시다. 타입 주석은 JavaScript(아니면 더 까다롭게 말해 ECMAScript)의 일부가 아니므로, 수정 없이 TypeScript를 그대로 실행할 수 있는 브라우저나 다른 런타임은 실제로 없습니다. 그래서 TypeScript에 컴파일러가 필요한 것입니다 — TypeScript 특유의 코드를 제거하거나 변환할 어떤 방법이 필요해서, 실행할 수 있게 해 주는 거죠. 대부분의 TypeScript 특유의 코드는 지워지며, 마찬가지로 여기서도 우리의 타입 주석이 완전히 지워졌습니다.
기억하세요: 타입 주석은 프로그램의 런타임 동작을 절대 바꾸지 않습니다.
다운레벨링 (Downleveling)
위의 또 다른 차이점은 우리의 템플릿 문자열이
`Hello ${person}, today is ${date.toDateString()}!`;
에서
"Hello ".concat(person, ", today is ").concat(date.toDateString(), "!");
로 다시 쓰여졌다는 것입니다.
왜 이런 일이 일어났을까요?
템플릿 문자열은 ECMAScript 2015(일명 ECMAScript 6, ES2015, ES6 등 — 묻지 마세요)라는 ECMAScript 버전의 기능입니다. TypeScript는 ECMAScript의 최신 버전에서 ECMAScript 3이나 ECMAScript 5(일명 ES5) 같은 더 오래된 버전으로 코드를 다시 쓰는 능력이 있습니다. ECMAScript의 더 새롭거나 "높은" 버전에서 더 오래되거나 "낮은" 버전으로 이동하는 이 과정을 때로는 다운레벨링 이라고 합니다.
기본적으로 TypeScript는 극도로 오래된 ECMAScript 버전인 ES5를 대상으로 합니다. target 옵션을 사용해 좀 더 최신의 것을 선택할 수도 있었어요. --target es2015로 실행하면 TypeScript가 ECMAScript 2015를 대상으로 하도록 바뀌는데, 이는 코드가 ECMAScript 2015가 지원되는 어디서든 실행될 수 있어야 한다는 뜻입니다. 그래서 tsc --target es2015 hello.ts를 실행하면 다음 출력을 얻습니다.
function greet(person, date) {
console.log(`Hello ${person}, today is ${date.toDateString()}!`);
}
greet("Maddison", new Date());
기본 target은 ES5이지만, 현재 브라우저의 대부분은 ES2015를 지원합니다. 따라서 특정 고대 브라우저와의 호환이 중요한 게 아니라면, 대부분의 개발자는 target을 ES2015 이상으로 안전하게 지정할 수 있습니다.
엄격성 (Strictness)
서로 다른 사용자들이 타입 검사기에서 서로 다른 것을 원하며 TypeScript를 찾아옵니다. 어떤 사람들은 프로그램의 일부만 검증하는 데 도움이 되고 여전히 괜찮은 툴링을 가질 수 있는, 더 느슨한 옵트인 경험을 찾습니다. 이것이 TypeScript의 기본 경험이며, 타입이 선택 사항이고, 추론이 가장 관대한 타입을 취하고, 잠재적 null/undefined 값에 대한 검사가 없습니다. tsc가 에러 앞에서도 방출하는 것과 마찬가지로, 이런 기본값들은 방해가 되지 않도록 설정되어 있습니다. 기존 JavaScript를 마이그레이션한다면 그것은 바람직한 첫 단계일 수 있어요.
대조적으로 많은 사용자들은 TypeScript가 가능한 한 많이 바로 검증하길 선호하며, 그래서 언어가 엄격성 설정도 제공합니다. 이 엄격성 설정들은 정적 타입 검사를 스위치(코드가 검사되거나 그렇지 않거나)에서 다이얼에 더 가까운 것으로 바꿔줍니다. 이 다이얼을 올릴수록 TypeScript가 더 많이 검사해 줍니다. 이는 약간의 추가 작업이 필요할 수 있지만, 일반적으로 장기적으로는 그 비용을 상쇄하며, 더 철저한 검사와 더 정확한 툴링을 가능하게 합니다. 가능하다면 새 코드베이스는 항상 이 엄격성 검사들을 켜야 합니다.
TypeScript에는 켜거나 끌 수 있는 여러 타입 검사 엄격성 플래그가 있으며, 우리의 모든 예시는 달리 명시되지 않는 한 모두 활성화된 상태로 작성될 것입니다. CLI의 strict 플래그 또는 tsconfig.json의 "strict": true는 그것들을 모두 동시에 켜지만, 개별적으로 해제할 수 있습니다. 알아두어야 할 두 가지 가장 큰 것은 noImplicitAny와 strictNullChecks입니다.
noImplicitAny
일부 위치에서 TypeScript는 우리를 위해 타입을 추론하려고 하지 않고 가장 관대한 타입인 any로 대체한다는 점을 기억하세요. 이것이 일어날 수 있는 최악의 일은 아닙니다 — 어차피 any로 대체되는 것은 평범한 JavaScript 경험일 뿐이니까요.
하지만 any를 사용하는 것은 종종 TypeScript를 사용하는 목적 자체를 무너뜨립니다. 프로그램이 더 타입이 지정될수록 더 많은 검증과 툴링을 얻으며, 코딩할 때 버그를 덜 만나게 된다는 뜻이죠. noImplicitAny 플래그를 켜면 타입이 암시적으로 any로 추론되는 모든 변수에 에러를 발행합니다.
strictNullChecks
기본적으로 null과 undefined 같은 값은 어떤 다른 타입에도 할당 가능합니다. 이러면 일부 코드를 쓰기가 더 쉬워질 수 있지만, null과 undefined를 처리하는 것을 잊는 것은 세상에서 셀 수 없이 많은 버그의 원인입니다 — 어떤 이들은 이를 billion dollar mistake이라고 여기죠! strictNullChecks 플래그는 null과 undefined를 더 명시적으로 처리하게 만들고, null과 undefined를 처리하는 것을 잊었는지 걱정하지 않도록 해 줍니다.