기본 타입

기본 타입 (The Basics)

TypeScript를 처음 접하는 분들을 위한 시작 문서예요. 이 페이지는 핸드북의 첫 장으로, 값의 "타입"이라는 개념이 왜 생겼는지, 그리고 정적 타입 검사기인 TypeScript가 어떤 문제를 풀기 위해 만들어졌는지를 차근차근 보여줘요. 아직 JavaScript 경험이 없다면 아래 Getting Started 가이드부터 시작해도 좋아요.

출처: TypeScript 공식문서

본문

타입(Type)이 왜 필요할까?

JavaScript에 있는 모든 값은, 각기 다른 연산을 돌려보면 관찰할 수 있는 "일정한 행동"을 가지고 있어요. 조금 추상적으로 들리죠? message라는 변수에 대해 실행할 수 있는 연산 몇 가지를 예로 들어 볼게요.

// Accessing the property 'toLowerCase'
// on 'message' and then calling it
message.toLowerCase();

// Calling 'message'
message();

이걸 하나씩 풀어보면, 첫 번째 실행 가능한 코드 줄은 toLowerCase라는 프로퍼티에 접근한 뒤 그걸 호출해요. 두 번째 줄은 message를 그냥 직접 호출하려고 하죠.

그런데 message가 실제로 어떤 값인지 모른다면 — 이건 아주 흔한 상황이에요 — 위 코드를 실행했을 때 어떤 결과가 나올지 확실하게 말할 수 없어요. 각 연산의 동작은 전적으로 그 값이 처음부터 무엇이었느냐에 달려 있거든요.

  • message는 호출 가능한 값인가요?
  • messagetoLowerCase라는 프로퍼티가 있나요?
  • 있다면, 그 toLowerCase조차 호출 가능한가요?
  • 두 값이 모두 호출 가능하다면, 그들은 무엇을 반환하나요?

이런 질문들에 답하는 정보는 보통 우리 머릿속에만 담아 두고 JavaScript를 작성하게 돼요. 그리고 그 세부사항을 전부 정확하게 기억하고 있기를 바랄 뿐이죠.

예를 들어 message가 다음과 같이 정의된다고 해 볼게요.

const message = "Hello World!";

아마 짐작하시겠지만, message.toLowerCase()를 실행하면 동일한 문자열이 그냥 소문자로 바뀌어 나와요.

그럼 두 번째 코드 줄은 어떨까요? JavaScript에 익숙하다면 이게 예외로 실패한다는 걸 알 거예요.

TypeError: message is not a function

이런 실수를 아예 막을 수 있다면 얼마나 좋을까요?

우리가 코드를 실행할 때, JavaScript 런타임이 어떻게 동작할지 정하는 기준은 값의 "타입(type)"을 알아내는 거예요 — 즉 그 값이 어떤 종류의 행동과 능력을 갖추고 있는지를 파악하는 거죠. 위 TypeError가 말하려는 것도 바로 그거예요. 문자열 "Hello World!"는 함수로 호출될 수 없다는 뜻이에요.

원시 타입(primitive)인 string이나 number 같은 값은 typeof 연산자를 쓰면 런타임에서도 타입을 알아낼 수 있어요. 하지만 함수 같은 건 그에 대응하는 런타임 메커니즘이 없어서 타입을 알아낼 방법이 없어요. 이런 함수 하나를 볼게요.

function fn(x) {
  return x.flip();
}

코드를 읽어 보면 이 함수는 flip이라는 호출 가능한 프로퍼티를 가진 객체가 들어올 때만 제대로 동작한다는 걸 알 수 있어요. 하지만 JavaScript는 이 정보를 코드가 실행되는 동안 검사할 수 있는 형태로 드러내 주지 않아요. 순수 JavaScript에서 특정 값에 대해 fn이 뭘 하는지 알아내는 유일한 방법은 그냥 호출해서 결과를 보는 것뿐이에요.

이런 특성 때문에 코드가 실행되기 전에 뭘 할지 예측하기 어려워지고, 결국 코드를 작성하는 동안에도 우리 코드가 뭘 할지 알기 어려워져요. 이런 관점에서 보면 "타입"이란, 어떤 값들이 fn에 전달될 수 있고 어떤 값들이 크래시를 일으킬지를 설명하는 개념이에요. JavaScript는 진정한 의미의 "동적 타입(dynamic typing)"만 제공해요 — 즉 실행해 봐야 알 수 있다는 거죠.

그 대안이 바로 **정적 타입 시스템(static type system)**이에요. 코드가 실행되기 전에 코드가 뭘 할 것인지 예측하는 거예요.

정적 타입 검사(Static type-checking)

아까 문자열을 함수처럼 호출해 얻은 TypeError를 다시 떠올려 볼게요. 대부분의 사람은 코드를 실행할 때 어떤 종류의 에러가 나는 걸 좋아하지 않아요 — 그건 바로 버그로 여겨지니까요. 새 코드를 작성할 때도 우리는 새로운 버그를 만들지 않으려고 최선을 다하죠.

코드를 조금 고치고 파일을 저장한 뒤 다시 실행해서 에러를 바로 보게 되면 문제를 빠르게 격리할 수 있을 거예요. 하지만 항상 그런 건 아니에요. 기능 테스트를 충분히 자세히 하지 않았다면, 실제로 던져질 수 있는 잠재적 에러를 영영 마주치지 못할 수도 있어요. 설령 운 좋게 에러를 목격했더라도, 그 사이에 대규모 리팩터링을 하면서 뒤져야 할 코드를 잔뜩 추가해 버린 뒤일 수도 있고요.

이상적으로는, 코드가 실행되기 전에 이런 버그를 찾아 주는 도구가 있으면 좋겠어요. 그게 바로 TypeScript 같은 정적 타입 검사기(static type-checker)가 하는 일이에요. 정적 타입 시스템은 프로그램을 실행했을 때 값들이 어떤 모양(shapes)과 행동을 가지게 될지 설명해 주고, TypeScript 같은 타입 검사기는 그 정보를 이용해 "뭔가 잘못 흘러가고 있다"는 신호를 알려줘요.

const message = "hello!";

message();
This expression is not callable.
  Type 'String' has no call signatures.2349This expression is not callable.
  Type 'String' has no call signatures.Try

마지막 예제를 TypeScript로 실행하면, 코드를 돌리기 전에 이미 에러 메시지를 받게 돼요.

예외가 아닌 실패(Non-exception Failures)

지금까지는 런타임 에러 같은 것들 — JavaScript 런타임이 "뭔가 말이 안 된다"고 판단하는 경우 — 을 다뤘어요. 이런 경우는 ECMAScript 명세에 언어가 예상치 못한 상황을 만났을 때 어떻게 동작해야 하는지에 대한 명확한 지침이 있기 때문에 생겨나요.

예를 들어 명세는 호출할 수 없는 것을 호출하려 하면 에러를 던져야 한다고 말해요. "그거 당연한 동작 아니야?"라고 들릴 수도 있는데, 반대로 객체에 존재하지 않는 프로퍼티에 접근하는 것도 에러를 던져야 한다고 생각할 수 있잖아요? 하지만 JavaScript는 그 대신 다른 동작을 골라서 undefined 값을 반환해요.

const user = {
  name: "Daniel",
  age: 26,
};

user.location; // returns undefined

결국 정적 타입 시스템은, 당장 에러를 던지지 않는 "유효한" JavaScript라 하더라도, 자기 시스템 안에서 어떤 코드를 에러로 표시할지 결정해야 해요. TypeScript에서는 아래 코드가 location이 정의되지 않았다는 에러를 만들어 냅니다.

const user = {
  name: "Daniel",
  age: 26,
};

user.location;
Property 'location' does not exist on type '{ name: string; age: number; }'.2339Property 'location' does not exist on type '{ name: string; age: number; }'.Try

이게 때로는 표현할 수 있는 것에 트레이드오프를 만들기도 하지만, 그 의도는 프로그램 속의 진짜 버그를 잡아내는 거예요. 그리고 TypeScript는 진짜 버그를 아주 많이 잡아내죠. 예를 들면 오타(typos) 같은 것들요.

const announcement = "Hello World!";

// How quickly can you spot the typos?
announcement.toLocaleLowercase();
announcement.toLocalLowerCase();

// We probably meant to write this...
announcement.toLocaleLowerCase();

호출되지 않는 함수(uncalled functions) 같은 것들도 잡아줘요.

function flipCoin() {
  // Meant to be Math.random()
  return Math.random < 0.5;
Operator '<' cannot be applied to types '() => number' and 'number'.2365Operator '<' cannot be applied to types '() => number' and 'number'.}

그리고 기본적인 논리 오류(logic errors)까지요.

const value = Math.random() < 0.5 ? "a" : "b";
if (value !== "a") {
  // ...
} else if (value === "b") {
This comparison appears to be unintentional because the types '"a"' and '"b"' have no overlap.2367This comparison appears to be unintentional because the types '"a"' and '"b"' have no overlap.  // Oops, unreachable
}

도구를 위한 타입(Types for Tooling)

TypeScript는 코드에 실수를 저지를 때 그걸 잡아줄 수 있어요. 그것만으로도 훌륭하지만, TypeScript는 실수를 저지르는 것 자체를 아예 막아 주기도 해요.

타입 검사기는 우리가 변수에서 올바른 프로퍼티에 접근하고 있는지 등을 검사할 수 있는 정보를 가지고 있어요. 그 정보를 확보하면, 어떤 프로퍼티를 쓰고 싶을지 제안해 주는 것도 가능해져요. 즉 TypeScript는 코드 편집 용도로도 활용될 수 있고, 핵심 타입 검사기가 편집기에서 입력하는 동안 에러 메시지와 코드 완성(code completion)을 제공해 줘요. 사람들이 TypeScript에서 "tooling"이라고 부를 때 흔히 말하는 부분이 바로 이거예요.

import express from "express";
const app = express();

app.get("/", function (req, res) {
  res.send("Hello World!");
});

app.listen(3000);

TypeScript는 툴링을 진지하게 생각해요. 그리고 이건 타이핑하면서 뜨는 완성과 에러를 넘어서요. TypeScript를 지원하는 편집기는 에러를 자동으로 고쳐 주는 "빠른 수정(quick fixes)", 코드를 쉽게 재구성하는 리팩터링, 변수 정의로 점프하는 탐색 기능, 특정 변수의 모든 참조를 찾는 기능 등을 제공해요. 이 모든 게 타입 검사기 위에 구축되어 있고 완전히 크로스플랫폼이라서, 여러분이 좋아하는 편집기에는 TypeScript 지원이 있을 가능성이 높아요.

tsc, TypeScript 컴파일러

지금까지 타입 검사에 대해 이야기했는데, 아직 우리의 타입 검사기를 실제로 써 보진 않았어요. 새로운 친구인 tsc — TypeScript 컴파일러 — 를 소개할게요. 먼저 npm으로 설치해야 해요.

npm install -g typescript

이 명령이 TypeScript 컴파일러인 tsc를 전역(globally)으로 설치해요. 로컬 node_modules 패키지에서 tsc를 실행하는 걸 선호한다면 npx 같은 도구를 써도 괜찮아요.

이제 빈 폴더로 이동해서 첫 TypeScript 프로그램 hello.ts를 작성해 볼게요.

// Greets the world.
console.log("Hello world!");

여기엔 특별한 게 전혀 없어요. 이 "hello world" 프로그램은 JavaScript로 작성할 때와 똑같이 생겼죠. 이제 typescript 패키지가 설치해 준 tsc 명령을 실행해서 타입 검사를 해 볼게요.

tsc hello.ts

짜잔!

잠깐, "짜잔"이 뭐가 어쨌다는 거죠? tsc를 실행했는데 아무 일도 일어나지 않았잖아요! 사실 타입 에러가 없었으니 콘솔에 보고할 출력이 없었던 거예요.

하지만 다시 확인해 보면, 파일 출력은 생겼어요. 현재 디렉터리를 보면 hello.ts 옆에 hello.js 파일이 생긴 걸 볼 수 있어요. 그게 tschello.ts를 평범한 JavaScript 파일로 컴파일(변환)한 결과물이에요. 내용을 열어 보면 TypeScript가 .ts 파일을 처리한 뒤 내놓는 결과를 확인할 수 있어요.

// Greets the world.
console.log("Hello world!");

이 경우엔 TypeScript가 변환할 게 거의 없어서, 우리가 쓴 것과 똑같이 보여요. 컴파일러는 사람이 직접 쓴 것처럼 보이는 깔끔하고 읽기 쉬운 코드를 내려고 노력해요. 항상 쉽진 않지만, TypeScript는 들여쓰기를 일관되게 하고, 코드가 여러 줄에 걸쳐 있을 때 세심하게 처리하며, 주석을 유지하려고 해요.

그럼 타입 검사 에러를 실제로 만들어 보면 어떨까요? hello.ts를 다시 써 볼게요.

// 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가 뭔지 알려주지 않았어요. 코드를 수정해서 personstring이고, dateDate 객체라고 알려줄게요. 그리고 datetoDateString() 메서드도 써 볼게요.

function greet(person: string, date: Date) {
  console.log(`Hello ${person}, today is ${date.toDateString()}!`);
}

우리가 한 일은 greet이 어떤 타입의 값들과 함께 호출될 수 있는지 설명하기 위해 persondate에 타입 어노테이션(type annotations)을 추가한 거예요. 이 시그니처는 "greetstring 타입의 personDate 타입의 date를 받는다"라고 읽을 수 있어요.

이제 TypeScript는 greet이 잘못 호출될 수 있는 다른 경우도 알려줄 수 있어요. 예를 들면…

function greet(person: string, date: Date) {
  console.log(`Hello ${person}, today is ${date.toDateString()}!`);
}

greet("Maddison", Date());
Argument of type 'string' is not assignable to parameter of type 'Date'.2345Argument of type 'string' is not assignable to parameter of type 'Date'.Try

어라? TypeScript가 두 번째 인자에서 에러를 보고했는데, 왜일까요?

아마 놀랍겠지만, JavaScript에서 Date()호출하면 문자열을 반환해요. 반면 new Date()처럼 생성자(construct)로 쓰면 우리가 기대하던 그 결과를 얻죠. 어쨌든 에러는 금방 고칠 수 있어요.

function greet(person: string, date: Date) {
  console.log(`Hello ${person}, today is ${date.toDateString()}!`);
}

greet("Maddison", new Date());

명심할 점은, 항상 명시적인 타입 어노테이션을 써야 하는 건 아니라는 거예요. 많은 경우 TypeScript는 생략하더라도 타입을 스스로 추론(infer)해 줘요.

let msg = "hello there!";

let msg: string

우리가 TypeScript에게 msgstring 타입이 있다고 알려주지 않았는데도 그걸 알아냈어요. 이건 기능이에요. 그리고 타입 시스템이 결국 같은 타입을 추론하게 될 상황이라면 어노테이션을 추가하지 않는 게 가장 좋아요. 참고로, 앞선 코드 샘플 안의 메시지 풍선은 단어 위에 마우스를 올렸을 때 편집기가 보여 주는 내용이에요.

타입의 삭제(Erased Types)

greet 함수를 tsc로 컴파일해서 JavaScript를 출력하면 어떤 일이 벌어지는지 볼게요.

"use strict";
function greet(person, date) {
    console.log("Hello ".concat(person, ", today is ").concat(date.toDateString(), "!"));
}
greet("Maddison", new Date());

여기서 두 가지를 눈여겨보세요.

  • 우리의 persondate 파라미터에 더 이상 타입 어노테이션이 없어요.
  • "템플릿 문자열" — 백틱(` 문자)으로 쓰던 그 문자열 — 이 연결(concatenation)을 쓰는 평문 문자열로 변환됐어요.

두 번째 부분은 나중에 다시 다루겠지만, 지금은 첫 번째 부분에 집중할게요. 타입 어노테이션은 JavaScript(정확히 말하면 ECMAScript)의 일부가 아니에요. 그래서 브라우저나 다른 런타임 중에 TypeScript를 수정 없이 그냥 실행할 수 있는 건 정말로 없어요. 그게 TypeScript에 컴파일러가 필요한 이유예요 — 실행할 수 있도록 TypeScript 전용 코드를 제거하거나 변환하는 방법이 필요한 거죠. 대부분의 TypeScript 전용 코드는 삭제(erased)되고, 마찬가지로 여기서도 우리의 타입 어노테이션이 완전히 지워졌어요.

기억하세요. 타입 어노테이션은 프로그램의 런타임 동작을 절대 바꾸지 않아요.

다운레벨링(Downleveling)

위 예제에서 또 하나 달라진 점은, 템플릿 문자열이 이것으로부터

`Hello ${person}, today is ${date.toDateString()}!`;

이것으로 다시 쓰였다는 거예요.

"Hello ".concat(person, ", today is ").concat(date.toDateString(), "!");

왜 이런 일이 생긴 걸까요? 템플릿 문자열은 ECMAScript 2015(a.k.a. ECMAScript 6, ES2015, ES6 등 — 묻지 마세요)라는 ECMAScript 버전에 속한 기능이에요. TypeScript는 새 버전의 ECMAScript 코드를 더 오래된 버전, 예를 들어 ECMAScript 3이나 ECMAScript 5(a.k.a. ES5)로 다시 쓰는 능력이 있어요. 이렇게 더 새롭거나 "높은(higher)" ECMAScript 버전에서 더 오래됐거나 "낮은(lower)" 버전으로 내려가는 과정을 가끔 **다운레벨링(downleveling)**이라고 불러요.

기본적으로 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());

기본 타깃이 ES5이긴 하지만, 현재 브라우저 대다수는 ES2015를 지원해요. 그래서 아주 오래된 브라우저와의 호환성이 중요하지 않은 한, 대부분의 개발자는 타깃을 ES2015 이상으로 안전하게 지정할 수 있어요.

엄격성(Strictness)

타입 검사기에서 서로 다른 걸 찾는 사용자들이 있어요. 어떤 사람들은 프로그램의 일부만 검증해 주면서도 괜찮은 툴링을 유지하는, 좀 더 느슨한 옵트인(opt-in) 경험을 원해요. 이게 TypeScript의 기본 경험이에요. 타입은 선택적이고, 추론은 가장 관대한 타입을 취하며, 잠재적으로 null/undefined인 값에 대한 검사는 없어요. 에러가 있을 때도 tsc가 출력을 내놓는 것처럼, 이런 기본값들은 여러분을 방해하지 않기 위해 마련된 거예요. 기존 JavaScript를 마이그레이션하는 중이라면, 그게 바람직한 첫걸음일 수 있어요.

반대로 많은 사용자는 TypeScript가 가능한 한 많은 것을 즉시 검증해 주는 걸 선호해요. 그래서 언어가 엄격성( strictness) 설정도 제공하는 거예요. 이런 엄격성 설정은 정적 타입 검사를 스위치(코드가 검사되거나 안 되거나)에서 다이얼(dial)에 더 가까운 것으로 바꿔 줘요. 이 다이얼을 올릴수록 TypeScript가 더 많이 검사해 줘요. 약간의 추가 작업이 필요할 수 있지만, 일반적으로 장기적으로는 그 값어치를 하고, 더 철저한 검사와 더 정확한 툴링을 가능하게 해요. 가능하다면, 새 코드베이스는 항상 이런 엄격성 검사를 켜야 해요.

TypeScript에는 켜거나 끌 수 있는 여러 타입 검사 엄격성 플래그가 있고, 별도로 명시하지 않는 한 우리의 모든 예제는 그것들을 전부 켠 상태로 작성할 거예요. CLI의 strict 플래그나 tsconfig.json"strict": true는 이걸 모두 동시에 켜 주지만, 개별적으로 끌 수도 있어요. 여러분이 알아야 할 가장 큰 두 가지는 noImplicitAnystrictNullChecks예요.

noImplicitAny

어떤 자리에서는 TypeScript가 타입을 추론하려 하지 않고, 가장 관대한 타입인 any로 폴백(fallback)한다는 걸 떠올려 볼게요. 이건 최악의 상황은 아니에요 — 어차피 any로 폴백하는 건 그냥 일반 JavaScript를 쓰는 경험이니까요. 하지만 any를 쓰면 TypeScript를 쓰는 목적이 무너지는 경우가 많아요. 프로그램이 더 타입화(type)될수록 더 많은 검증과 툴링을 얻게 되고, 즉 코딩할 때 버그를 덜 만나게 돼요. noImplicitAny 플래그를 켜면 타입이 암시적으로 any로 추론되는 모든 변수에 에러를 발생시켜 줘요.

strictNullChecks

기본적으로 nullundefined 같은 값은 다른 모든 타입에 할당 가능해요. 이러면 코드를 쓰기 조금 쉬워질 수 있지만, nullundefined 처리를 잊는 것은 세상의 무수한 버그의 원인이에요 — 어떤 이들은 이를 billion dollar mistake이라고 부르기도 해요! strictNullChecks 플래그는 nullundefined 처리를 더 명시적으로 만들고, nullundefined 처리를 잊었는지 걱정하지 않게 해 줘요.

더 알아보기

이어서 보면 좋은 관련 문서들이에요.