TypeScript 입문

TypeScript 기초 개념

typescript
Published

December 8, 2025

Abstract

웹 개발을 위한 타입스크립트 기초 학습

처음 배우는 언어 중 하나로 타입스크립트(TypeScript)를 선택하신 것을 축하합니다. 좋은 결정입니다! 타입스크립트가 자바스크립트의 “플레이버(flavor)” 또는 “변종(variant)”라는 말을 들어보셨을 것입니다. 타입스크립트(TS)와 자바스크립트(JS)의 관계는 현대 프로그래밍 언어들 사이에서 상당히 독특합니다. 이 관계를 이해하면 타입스크립트가 자바스크립트에 어떤 가치를 더하는지 알 수 있습니다.

1. Introduction for Beginners to TypeScript

자바스크립트(ECMAScript라고도 함)는 브라우저를 위한 간단한 스크립팅 언어로 시작했습니다. 처음 개발될 당시에는 웹 페이지에 삽입되는 짧은 코드 조각에 사용될 것으로 예상되었고, 수십 줄 이상의 코드를 작성하는 것은 이례적이었습니다. 이 때문에 초기 웹 브라우저는 이러한 코드를 매우 느리게 실행했습니다.

하지만 시간이 지나면서 JS의 인기는 점점 높아졌고, 웹 개발자들은 이를 사용해 상호작용적인 경험을 만들기 시작했습니다. 웹 브라우저 개발자들은 증가하는 JS 사용량에 대응해 실행 엔진을 최적화(동적 컴파일)하고 기능을 확장(API 추가)했습니다. 이로 인해 웹 개발자들이 JS를 더욱 많이 사용하게 되었습니다. 오늘날 최신 웹사이트에서 브라우저는 수백 줄에 달하는 애플리케이션을 빈번하게 실행합니다.

더 나아가 JS는 Node.js를 사용한 서버 구현처럼 브라우저의 맥락을 벗어나 사용될 만큼 대중화되었습니다. JS의 “어디서나 실행되는(run anywhere)” 특성은 크로스 플랫폼 개발에 매력적인 선택지가 되었습니다. 요즘에는 전체 스택을 자바스크립트만으로 프로그래밍하는 개발자도 많습니다!

요약하면, 빠른 사용을 위해 설계되었다가 수백만 줄의 애플리케이션을 작성하는 완전한 도구로 성장한 언어입니다. 모든 언어에는 고유한 기벽(quirks), 즉 이상하고 놀라운 점들이 있습니다. 자바스크립트의 소박한 시작은 많은 기벽을 만들었습니다. 몇 가지 예는 다음과 같습니다.

  1. 자바스크립트의 동등 연산자(==)는 피연산자를 강제 변환하여 예기치 않은 동작을 유발합니다.
if ("" == 0) {   
  // 참입니다! 하지만 왜??
}
  1. 다음 코드는 x 값에 상관없이 항상 참입니다.
if (1 < x < 3) {
  // x의 어떤 값에 대해서도 참!
}
  1. 자바스크립트는 존재하지 않는 프로퍼티에 접근하는 것도 허용합니다.
const obj = { width: 10, height: 15 };
// 왜 이것이 NaN일까요? 오타는 어렵습니다!
const area = obj.width * obj.heigth;

대부분의 프로그래밍 언어는 이런 종류의 오류가 발생하면 에러를 발생시키며, 일부는 코드가 실행되기 전인 컴파일 중에 그렇게 합니다. 작은 프로그램을 작성할 때 이러한 기벽들은 성가시지만 관리할 수 있습니다. 하지만 수백, 수천 줄의 코드로 애플리케이션을 작성할 때 이런 계속되는 놀라움은 심각한 문제가 됩니다.

TypeScript: Static Type Checker

앞서 어떤 언어들은 그러한 버그가 있는 프로그램이 아예 실행되지 않도록 막는다고 했습니다. 타입스크립트는 프로그램이 실행되기 전에 값의 종류(kind of values)를 기반으로 오류를 검사하는 정적 타입 검사기(static type checker) 역할을 합니다. 예를 들어, 위 마지막 예제는 obj의 타입 때문에 오류가 발생했습니다. 다음은 타입스크립트가 찾아낸 오류입니다.

const obj = { width: 10, height: 15 };
const area = obj.width * obj.heigth;

`Property 'heigth' does not exist on type '{ width: number; height: number; }'. Did you mean 'height'?`

A Typed Superset of JavaScript

그렇다면 타입스크립트는 자바스크립트와 어떻게 관련될까요? 타입스크립트는 자바스크립트의 상위집합(superset)인 언어입니다. 따라서 JS 구문은 문법적으로 TS 구문입니다. 구문이란 프로그램을 형성하기 위해 텍스트를 작성하는 방식을 의미합니다. 예를 들어, 다음 코드는 )가 빠져 있어 구문 오류가 있습니다.

let a = (4

`')' expected.`

타입스크립트는 어떤 자바스크립트 코드도 구문 때문에 오류로 간주하지 않습니다. 즉, 작동하는 모든 자바스크립트 코드를 어떻게 작성되었는지 걱정 없이 타입스크립트 파일에 넣을 수 있습니다.

Types

그러나 타입스크립트는 타입이 있는(typed) 상위집합으로, 다른 종류의 값들이 어떻게 사용될 수 있는지에 대한 규칙을 추가한다는 의미입니다. 앞서 obj.heigth에 대한 오류는 구문 오류가 아니었습니다. 어떤 종류의 값(타입)을 잘못된 방식으로 사용한 오류였습니다.

또 다른 예로, 다음은 브라우저에서 실행할 수 있는 자바스크립트 코드로, 값을 콘솔에 출력합니다.

console.log(4 / []);

이 구문적으로 유효한 프로그램은 Infinity를 출력합니다. 하지만 타입스크립트는 숫자를 배열로 나누는 것을 의미 없는 연산으로 간주해 다음과 같은 오류를 발생시킵니다.

`The right-hand side of an arithmetic operation must be of type 'any', 'number', 'bigint' or an enum type.`

물론 무슨 일이 일어나는지 보려고 의도적으로 숫자를 배열로 나누려 했을 수도 있지만, 대부분의 경우 이는 프로그래밍 실수입니다. 타입스크립트의 타입 검사기는 올바른 프로그램은 통과시키면서도 가능한 한 많은 일반적인 오류를 잡아내도록 설계되었습니다(나중에 타입스크립트가 코드를 얼마나 엄격하게 검사할지 구성하는 설정에 대해 배우게 됩니다).

자바스크립트 파일의 코드를 타입스크립트 파일로 옮기면, 코드가 작성된 방식에 따라 타입 오류가 나타날 수 있습니다. 이는 코드의 실질적인 문제일 수도 있고, 타입스크립트가 지나치게 보수적으로 판단한 것일 수도 있습니다. 이 안내서 전반에 걸쳐 이러한 오류를 제거하기 위해 다양한 타입스크립트 구문을 추가하는 방법을 보여드립니다.

Runtime Behavior

타입스크립트는 자바스크립트의 런타임 동작을 보존하는 프로그래밍 언어이기도 합니다. 예를 들어, 자바스크립트에서 0으로 나누면 런타임 예외(exception)를 발생시키는 대신 Infinity가 됩니다. 원칙적으로 타입스크립트는 자바스크립트 코드의 런타임 동작을 절대 변경하지 않습니다.

이는 자바스크립트에서 타입스크립트로 코드를 옮기더라도, 타입스크립트가 해당 코드에 타입 오류가 있다고 판단하더라도 코드가 동일한 방식으로 실행될 것이 보장된다는 의미입니다. 자바스크립트와 동일한 런타임 동작을 유지하는 것은 타입스크립트의 근본적인 약속입니다. 프로그램의 작동을 멈출 수 있는 미묘한 차이에 대해 걱정하지 않고 두 언어 사이를 쉽게 전환할 수 있기 때문입니다.

Erased Types

대략적으로 말하면, 타입스크립트 컴파일러가 코드 검사를 마치면 타입을 제거(erases)하여 결과물인 “컴파일된” 코드를 생성합니다. 즉, 코드가 컴파일되고 나면 타입 시스템 자체가 프로그램이 실행될 때의 동작 방식에 아무런 영향을 미치지 않습니다. 마지막으로 타입스크립트는 어떠한 추가적인 런타임 라이브러리도 제공하지 않습니다. 프로그램은 자바스크립트 프로그램과 동일한 표준 라이브러리(또는 외부 라이브러리)를 사용하므로, 추가로 배워야 할 타입스크립트 전용 프레임워크는 없습니다.

Learning JavaScript and TypeScript

“자바스크립트를 배워야 하나요, 아니면 타입스크립트를 배워야 하나요?”라는 질문을 자주 봅니다. 정답은 자바스크립트를 배우지 않고는 타입스크립트를 배울 수 없다는 것입니다! 타입스크립트는 자바스크립트와 구문 및 런타임 동작을 공유하므로, 자바스크립트를 배우는 모든 것이 동시에 타입스크립트를 배우는 데 도움이 됩니다.

프로그래머가 자바스크립트를 배울 수 있는 자료는 매우 많습니다. 타입스크립트를 작성하더라도 이러한 자료들을 무시해서는 안 됩니다. 예를 들어, 스택오버플로우(StackOverflow)에는 typescript로 태그된 질문보다 javascript로 태그된 질문이 약 20배 더 많지만, javascript에 대한 모든 질문은 타입스크립트에도 적용됩니다.

“타입스크립트에서 리스트를 정렬하는 방법”과 같은 것을 검색할 때는 다음을 기억하세요. 타입스크립트는 컴파일 타임 타입 검사기를 갖춘 자바스크립트의 런타임입니다. 타입스크립트에서 리스트를 정렬하는 방법은 자바스크립트에서 하는 방법과 동일합니다. 타입스크립트를 직접 사용하는 자료를 찾는 것도 좋지만, 런타임 작업을 수행하는 방법에 대한 일상적인 질문에 대해 타입스크립트 전용 답변이 필요하다고 생각해 스스로를 제한하지 마세요.

프로그래밍 커뮤니티에 소개된 지 20년이 넘은 지금, JS는 이제까지 만들어진 가장 널리 퍼진 크로스플랫폼 언어 중 하나가 되었습니다. 웹페이지에 간단한 상호작용을 추가하는 작은 스크립팅 언어로 시작한 JS는, 이제 모든 규모의 프런트엔드와 백엔드 애플리케이션 모두에서 선택받는 언어로 성장했습니다.

JS로 작성되는 프로그램의 규모, 범위, 복잡성은 기하급수적으로 증가했지만, 코드의 여러 단위 간의 관계를 표현하는 JS 언어의 능력은 그에 미치지 못했습니다. 이는 JS의 다소 독특한 런타임 의미론과 결합해 대규모 JS 개발을 관리하기 어려운 작업으로 만들었습니다.

프로그래머가 작성하는 가장 흔한 종류의 오류는 타입 오류(type errors)로 설명할 수 있습니다. 이는 특정 종류의 값이 예상되는 곳에 다른 종류의 값이 사용된 경우입니다. 단순한 오타, 라이브러리의 API 명세에 대한 이해 부족, 런타임 동작에 대한 잘못된 가정 또는 기타 다른 오류들로 인해 발생할 수 있습니다. TS의 목표는 JS 프로그램을 위한 정적 타입 검사기(static typechecker)가 되는 것입니다. 다시 말해, 코드가 실행되기 전(정적)에 실행되어 프로그램의 타입이 올바른지 확인(타입 검사)하는 도구입니다.

JS 배경 지식 없이 TS를 첫 언어로 배우려는 의도라면, 먼저 관련 문서 링크(JS language overview)를 읽어보시기를 권장합니다.

마지막으로, 이 핸드북은 필요한 경우를 제외하고 TS가 다른 도구와 어떻게 상호작용하는지에 대해 다루지 않습니다. webpack, rollup, parcel, react, babel, closure, lerna, rush, bazel, preact, vue, angular, svelte, jquery, yarn, 또는 npm과 함께 TS를 설정하는 방법 같은 주제는 이 문서의 범위를 벗어납니다. 이러한 자료는 웹의 다른 곳에서 찾을 수 있습니다.

2. The Basic Types

JS의 모든 값은 다양한 연산을 실행함으로써 관찰할 수 있는 일련의 동작(behavior)을 가집니다. 추상적으로 들릴 수 있지만, 간단한 예시로 message라는 변수에 실행할 수 있는 몇 가지 연산을 살펴보겠습니다.

// 'message'의 'toLowerCase' 프로퍼티에 접근한 후 호출합니다.
message.toLowerCase(); 

// 'message'를 호출합니다.
message(); 

이를 분석해보면, 실행 가능한 첫 번째 코드는 toLowerCase라는 프로퍼티에 접근한 후 이를 호출합니다. 두 번째 코드는 message를 호출하려고 시도합니다.

Static type-checking

각 연산의 동작은 전적으로 처음에 어떤 값을 가졌는지에 따라 달라집니다.

  • message는 호출이 가능한가?
  • toLowerCase라는 프로퍼티를 가지고 있는가?
  • 만약 가지고 있다면, toLowerCase는 호출이 가능한가?
  • 만약 두 값 모두 호출 가능하다면, 무엇을 반환하는가?

이 질문들에 대한 답은 보통 JS를 작성할 때 머릿속에 담아두는 것들이며, 모든 세부 사항을 정확히 기억해야 합니다. message가 다음과 같이 정의되었다고 가정해 봅시다.

const message = "Hello World!"; 

아마 짐작하시겠지만, message.toLowerCase()를 실행하면 동일한 문자열을 소문자로 얻습니다. 그럼 두 번째 코드 줄은 어떨까요? JS에 익숙하다면 다음과 같은 예외와 함께 실패한다는 것을 알 수 있습니다.

TypeError: message is not a function 

이런 실수를 피할 수 있다면 좋겠습니다. 코드를 실행할 때 JS 런타임이 무엇을 할지 결정하는 방식은 값의 타입(type), 즉 어떤 종류의 동작과 능력을 가졌는지를 파악하는 것입니다. TypeError가 암시하는 바도 바로 그것입니다. “Hello World!”라는 문자열은 함수로서 호출될 수 없다는 의미입니다.

string이나 number와 같은 원시(primitive) 값의 경우, typeof 연산자를 사용해 런타임에 타입을 식별할 수 있습니다. 하지만 함수와 같은 다른 것들에 대해서는 타입을 식별할 수 있는 런타임 메커니즘이 없습니다. 예를 들어 다음 함수를 생각해 봅시다.

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

코드를 읽어보면 이 함수는 호출 가능한 flip 프로퍼티를 가진 객체가 주어져야만 작동할 것이라는 점을 알 수 있지만, JS는 코드가 실행되는 동안 우리가 확인할 수 있는 방식으로 이 정보를 드러내지 않습니다. 순수 JS에서 특정 값으로 fn이 무엇을 하는지 알 수 있는 유일한 방법은 직접 호출해보고 무슨 일이 일어나는지 보는 것입니다. 이러한 종류의 동작은 코드가 실행되기 전에 무엇을 할지 예측하기 어렵게 만들며, 코드를 작성하는 동안 코드가 어떻게 될지 알기 더 어렵게 만듭니다.

이런 관점에서 볼 때, 타입이란 어떤 값이 fn에 전달될 수 있고 어떤 값이 충돌을 일으킬지를 설명하는 개념입니다. JS는 코드를 실행해 무슨 일이 일어나는지 보는 동적 타이핑(dynamic typing)만을 제공합니다. 대안은 정적 타입 시스템(static type system)을 사용해 코드가 실행되기 전에 무엇을 할 것으로 예상되는지 예측하는 것입니다.

앞서 문자열을 함수로 호출하려다 발생했던 TypeError를 다시 생각해 봅시다. 대부분의 사람들은 코드를 실행할 때 어떤 종류의 오류도 보고 싶어 하지 않습니다. 그런 것들은 버그로 간주됩니다! 새로운 코드를 작성할 때, 우리는 새로운 버그를 만들지 않기 위해 최선을 다합니다.

코드를 조금 추가하고, 파일을 저장한 후, 코드를 다시 실행해 즉시 오류를 본다면 문제를 빨리 찾아낼 수 있습니다. 하지만 항상 그런 것은 아닙니다. 기능을 충분히 테스트하지 않아서 발생할 수 있는 잠재적 오류를 마주치지 못할 수도 있습니다! 또는 운 좋게 오류를 목격했다 하더라도, 이미 대규모 리팩토링을 수행하고 수많은 코드를 추가한 뒤라 그 속을 파헤쳐야 할 수도 있습니다.

이상적으로는 코드가 실행되기 전에 이러한 버그를 찾는 데 도움이 되는 도구가 있으면 좋겠습니다. 이것이 바로 TS와 같은 정적 타입 검사기(static type-checker)가 하는 일입니다. 정적 타입 시스템은 프로그램을 실행할 때 값의 형태(shape)와 동작(behavior)이 어떠할지를 설명합니다. TS와 같은 타입 검사기는 그 정보를 사용해 무언가 잘못될 가능성이 있을 때 우리에게 알려줍니다.

const message = "hello!"; 
message(); 

This expression is not callable. 

위 예제를 TS로 실행하면 코드를 실행하기 전에 오류 메시지를 받습니다.

Non-exception Failures

지금까지 우리는 런타임 오류, 즉 JS 런타임이 무언가 비정상적이라고 판단하는 경우에 대해 논의했습니다. 이러한 경우는 ECMAScript 명세에 예기치 않은 상황을 마주했을 때 언어가 어떻게 동작해야 하는지에 대한 명시적인 지침이 있기 때문에 발생합니다. 예를 들어, 명세에 따르면 호출할 수 없는 것을 호출하려고 시도하면 오류를 발생시켜야 합니다.

이것이 ’당연한 동작’처럼 들릴 수도 있지만, 객체에 존재하지 않는 프로퍼티에 접근하는 것 또한 오류를 발생시켜야 한다고 생각할 수 있습니다. 하지만 JS는 다른 동작을 보여주며 undefined 값을 반환합니다.

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

user.location; // undefined를 반환합니다

궁극적으로 정적 타입 시스템은, 비록 즉시 오류를 발생시키지 않는 ‘유효한’ JS 코드일지라도, 자신의 시스템 내에서 어떤 코드를 오류로 표시해야 할지 결정해야 합니다. TS에서는 다음 코드가 location이 정의되지 않았다는 오류를 발생시킵니다.

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

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

TS는 때때로 합법적인 JS 코드를 오류로 표시함으로써 버그를 잡는 데 도움을 줄 수 있습니다. 예를 들면 다음과 같습니다.

  • 오타 (typos)
const announcement = "Hello World!";

// 오타를 얼마나 빨리 찾을 수 있나요?
announcement.toLocaleLowercase();
announcement.toLocalLowerCase();

// 아마 이렇게 작성하려고 했을 겁니다...
announcement.toLocaleLowerCase(); 
  • 호출되지 않은 함수 (uncalled functions)
function flipCoin() {
  // Math.random()을 의도했습니다. 
  return Math.random < 0.5; 
}

// Operator '<' cannot be applied to types '() => number' and 'number'.
  • 또는 기본적인 논리 오류 (or basic 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.

Types for Tooling

타입 검사기는 변수나 다른 객체의 올바른 프로퍼티에 접근하고 있는지 등을 확인할 정보를 가집니다. 이 정보가 있으면, 사용자가 사용할 만한 프로퍼티를 제안하기 시작할 수도 있습니다. 이는 TS가 코드 편집에도 활용될 수 있음을 의미하며, 핵심 타입 검사기는 편집기에서 입력하는 동안 오류 메시지와 코드 자동 완성을 제공할 수 있습니다. 이것이 사람들이 TS의 도구(tooling)에 대해 이야기할 때 흔히 언급하는 부분입니다.

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

app.get("/", function (req, res) {
  res.sen // 여기서 편집기는 'send', 'sendDate', 'sendFile' 등을 제안합니다.
});

app.liste // 여기서 편집기는 'listen'을 제안합니다.

TS는 도구를 매우 중요하게 생각하며, 이는 입력 중의 자동 완성이나 오류 표시를 넘어섭니다. TS를 지원하는 편집기는 오류를 자동으로 수정하는 ‘빠른 수정(quick fixes)’, 코드를 쉽게 재구성하는 리팩토링, 변수의 정의로 이동하거나 주어진 변수의 모든 참조를 찾는 유용한 탐색 기능 등을 제공할 수 있습니다. 이 모든 것은 타입 검사기 위에 구축되어 있으며 완벽하게 크로스플랫폼을 지원하므로, 여러분이 즐겨 사용하는 편집기에서도 TS 지원을 받을 수 있습니다.

tsc (the TS compiler)

지금까지 타입 검사에 대해 이야기했지만, 아직 타입 검사기를 사용해보지는 않았습니다. 우리의 새 친구인 tsc, 즉 TS 컴파일러와 친해져 봅시다. 먼저 npm을 통해 설치해야 합니다.

npm install -g typescript

위 명령어는 TS 컴파일러 tsc를 전역으로 설치합니다. 로컬 node_modules 패키지에서 tsc를 실행하고 싶다면, npx나 이와 유사한 도구를 사용할 수 있습니다. 이제 hello.ts라는 이름의 새 파일을 만들어 봅시다.

// 문자열을 출력합니다.
console.log("Hello world!");

여기에는 어떤 기교도 없습니다. 이 “hello world” 프로그램은 JS로 작성하는 “hello world” 프로그램과 동일하게 보입니다. 이제 typescript 패키지를 통해 설치된 tsc 명령어를 실행해 타입 검사를 해보겠습니다.

tsc hello.ts

tsc를 실행했지만 아무 일도 일어나지 않았습니다! 타입 오류가 없었기 때문에 보고할 내용이 없어 콘솔에는 아무런 출력도 나오지 않았습니다. 하지만 다시 확인해보세요. 대신 파일 출력이 생겼습니다. 현재 디렉토리를 보면, hello.ts 옆에 hello.js 파일이 보일 것입니다. 이것이 tschello.ts 파일을 컴파일하거나 일반 JS 파일로 변환한 결과물입니다. 그 내용을 확인하면, TS가 .ts 파일을 처리한 후 무엇을 내놓는지 볼 수 있습니다.

// 문자열을 출력합니다.
console.log("Hello world!");

이 경우, TS가 변환할 내용이 거의 없었기 때문에 우리가 작성한 것과 동일하게 보입니다. 컴파일러는 사람이 작성한 것처럼 깔끔하고 읽기 쉬운 코드를 생성하려고 노력합니다. TS는 일관되게 들여쓰기를 하고, 코드가 여러 줄에 걸쳐 있을 때를 고려하며, 주석을 유지하려고 노력합니다.

타입 검사 오류를 일부러 만들면 어떻게 될까요? hello.ts를 다음과 같이 다시 작성해 봅시다.

// 함수를 활용해서 문자열을 출력합니다.
function greet(person, date) {
  console.log(`Hello ${person}, today is ${date}!`);
}

greet("Jonh Doe");

tsc hello.ts를 다시 실행하면, 커맨드 라인에 오류가 표시됩니다!

Expected 2 arguments, but got 1.

TS는 우리가 greet 함수에 인수를 전달하는 것을 잊었다고 올바르게 지적하고 있습니다. 지금까지 우리는 표준 JS만을 작성했지만, 타입 검사는 여전히 우리 코드의 문제점을 찾아낼 수 있었습니다.

Emitting with Errors

지난 예제에서 눈치채지 못했을 수도 있는 한 가지는 hello.js 파일이 다시 변경되었다는 점입니다. 해당 파일을 열어보면 내용이 여전히 입력 파일과 거의 동일하다는 것을 알 수 있습니다. tsc가 코드에 대한 오류를 보고했음에도 불구하고 이는 다소 놀라울 수 있지만, 이것은 TS의 핵심 가치 중 하나에 기반합니다. 대부분의 경우, 개발자인 당신이 TS보다 더 잘 알고 있다는 것입니다. 타입 검사는 실행할 수 있는 프로그램의 종류를 제한하므로, 타입 검사기가 어떤 종류의 것들을 수용 가능한 것으로 여길지에 대한 트레이드오프가 존재합니다. 대부분의 경우 이는 괜찮지만, 이러한 검사가 방해가 되는 시나리오도 있습니다. 예를 들어, 기존 JS 코드를 TS로 마이그레이션하면서 타입 검사 오류를 마주하는 자신을 상상해 보세요. 결국에는 타입 검사기를 위해 코드를 정리하겠지만, 원래의 JS 코드는 이미 잘 작동하고 있었습니다! TS로 변환한다는 이유만으로 이미 작동하던 코드를 실행하지 못하게 할 이유가 있을까요?

그래서 TS는 당신의 작업을 방해하지 않습니다. 물론 시간이 지나면서 실수에 대해 좀 더 방어적으로 대처하고 싶을 수 있고, TS가 좀 더 엄격하게 동작하도록 만들고 싶을 수 있습니다. 그럴 경우, noEmitOnError 컴파일러 옵션을 사용할 수 있습니다. hello.ts 파일을 변경하고 다음 플래그와 함께 tsc를 실행해 보세요.

tsc --noEmitOnError hello.ts

hello.js가 더 이상 업데이트되지 않는 것을 확인할 수 있습니다.

Explicit Types

지금까지 우리는 TS가 코드의 문제점을 발견하도록 의도적으로 오류가 있는 코드를 작성했습니다. 이제 greet 함수를 수정하고 명시적으로 greet가 기대하는 바를 알려줍시다.

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

우리가 한 일은 persondate에 타입 애노테이션(type annotation)을 추가해 greet 함수가 어떤 타입의 값으로 호출될 수 있는지 명시한 것입니다. 이 시그니처는 “greet 함수는 string 타입의 personDate 타입의 date를 받는다”라고 읽을 수 있습니다. 이를 통해 TS는 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'.

TS가 두 번째 인수에 대해 오류를 보고했는데, 왜일까요? 놀랍게도 JS에서 Date()를 호출하면 문자열을 반환합니다. 반면에, new Date()Date를 생성하면 우리가 기대했던 것을 실제로 얻습니다. 아무튼, 우리는 이 오류를 빠르게 수정할 수 있습니다.

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

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

명시적인 타입 애노테이션을 항상 작성해야 하는 것은 아니라는 점을 명심하세요. 많은 경우, TS는 타입을 추론할 수 있습니다. 비록 우리가 msgstring 타입을 가졌다고 TS에 알려주지 않았지만, 타입 추론(inference)을 통해 그것을 알아낼 수 있었습니다. 이것은 타입스크립트의 유용한 기능이며, 타입 시스템이 어차피 동일한 타입을 추론할 경우에는 애노테이션을 추가하지 않는 것이 가장 좋습니다.

Erased Types

tsc를 사용해 위에서 작성한 greet 함수를 JS로 컴파일하면 어떻게 되는지 살펴봅시다.

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

여기서 두 가지를 주목하세요.

  1. persondate 매개변수에는 더 이상 타입 애노테이션이 없습니다.
  2. 백틱( ` )을 사용했던 ’템플릿 문자열’이 문자열 연결(concatenation)을 사용하는 일반 문자열로 변환되었습니다.

두 번째 포인트는 나중에 더 다루고, 우선 첫 번째 포인트에 집중해 봅시다. 타입 애노테이션은 JS(엄밀히 말해 ECMAScript)의 일부가 아니므로, TS를 수정 없이 바로 실행할 수 있는 브라우저나 다른 런타임은 실제로 없습니다. 이것이 바로 TS에 컴파일러가 필요한 이유입니다. 실행할 수 있도록 TS 전용 코드를 제거하거나 변환할 방법이 필요하기 때문입니다. 대부분의 TS 전용 코드는 이처럼 제거되며, 기억하세요. 타입 애노테이션은 프로그램의 런타임 동작을 절대 변경하지 않습니다.

Downleveling

위의 또 다른 차이점은 템플릿 문자열이 다음과 같이 재작성된 것입니다.

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

이것이 아래와 같이 변경되었습니다.

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

왜 이런 일이 발생했을까요?

템플릿 문자열은 ECMAScript 2015(ES2015, ES6 등으로도 알려짐)라는 ECMAScript 버전의 기능입니다. TS는 최신 버전의 ECMAScript 코드를 ECMAScript 3나 ECMAScript 5 (ES5)와 같은 이전 버전으로 다시 작성하는 기능이 있습니다. 이렇게 최신 또는 ‘더 높은’ 버전의 ECMAScript에서 이전 또는 ‘더 낮은’ 버전으로 이동하는 과정을 때때로 다운레벨링(downleveling)이라고 합니다.

기본적으로 TS는 매우 오래된 버전인 ES5를 대상으로 합니다. target 옵션을 사용해 조금 더 최신 버전을 선택할 수도 있습니다. --target es2015로 실행하면 TS가 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());

Strictness

사용자마다 타입 검사기에서 기대하는 바가 다릅니다. 어떤 사람들은 프로그램의 일부만 검증하고 적절한 도구 지원을 받을 수 있는 더 느슨한 옵트인(opt-in) 경험을 원합니다. 이것이 TS의 기본 경험으로, 타입은 선택적이고, 타입 추론은 가장 관대한 타입을 사용하며, 잠재적인 null 또는 undefined 값에 대한 검사가 없습니다. tsc가 오류 발생 시에도 파일을 출력하는 것처럼, 이러한 기본값은 사용자의 작업을 방해하지 않기 위해 설정되어 있습니다. 기존 JS를 마이그레이션하는 경우, 이것이 바람직한 첫 단계일 수 있습니다.

반면에, 많은 사용자는 TS가 가능한 한 많은 것을 즉시 검증하기를 선호하며, 이것이 언어가 엄격성(strictness) 설정을 제공하는 이유입니다. 이러한 엄격성 설정은 정적 타입 검사를 스위치(켜고 끄는 방식)에서 다이얼에 더 가까운 것으로 바꿉니다. 이 다이얼을 높일수록 TS가 더 많은 것을 검사해 줄 것입니다. 약간의 추가 작업이 필요할 수 있지만, 장기적으로는 그만한 가치가 있으며 더 철저한 검사와 정확한 도구 사용을 가능하게 합니다. 가능하다면, 새로운 코드베이스는 항상 이러한 엄격성 검사를 켜야 합니다.

TS에는 켜고 끌 수 있는 여러 엄격성 플래그가 있으며, 별도의 언급이 없는 한 모든 예제는 이 플래그들이 모두 활성화된 상태에서 작성됩니다. CLI에서 strict 플래그를 사용하거나 tsconfig.json 파일에 "strict": true를 설정하면 모든 엄격성 옵션이 동시에 켜지지만, 개별적으로 끌 수도 있습니다. 가장 중요한 두 가지는 noImplicitAnystrictNullChecks입니다.

  • noImplicitAny

어떤 곳에서는 TS가 타입을 추론하지 않고 가장 관대한 타입인 any로 대체된다는 것을 기억하세요. any를 사용하는 것은 TS를 사용하는 목적을 무색하게 만드는 경우가 많습니다. 프로그램에 타입이 더 많이 적용될수록 더 많은 유효성 검사와 도구 지원을 받게 되어 코딩 중 버그를 줄일 수 있습니다. noImplicitAny 플래그를 켜면 타입이 암시적으로 any로 추론되는 모든 변수에 대해 오류가 발생합니다.

  • strictNullChecks

기본적으로 nullundefined 같은 값은 다른 모든 타입에 할당할 수 있습니다. strictNullChecks 플래그는 nullundefined 처리를 더 명시적으로 만들어, 우리가 nullundefined를 처리하는 것을 잊었는지에 대한 걱정을 덜어줍니다.

3. Everyday Types

이는 모든 타입을 총망라한 목록은 아니며, 앞으로 이어질 장들에서 타입을 조합해 새로운 구조를 만드는 더 많은 방법을 설명합니다.

The Basic Types: string, number, boolean

자바스크립트나 타입스크립트 코드를 작성할 때 마주칠 수 있는 가장 기본적이고 일반적인 타입들을 검토하는 것으로 시작하겠습니다. 이들은 나중에 더 복잡한 타입의 핵심 구성 요소가 됩니다.

자바스크립트에는 매우 흔하게 사용되는 세 가지 기본 타입(primitive)이 있습니다. string, number, boolean입니다. 각각은 타입스크립트에 상응하는 타입을 가집니다. 예상하시겠지만, 이 이름들은 해당 타입의 값에 자바스크립트의 typeof 연산자를 사용했을 때 보게 될 이름과 동일합니다.

  • string은 “Hello, world”와 같은 문자열 값을 나타냅니다.
  • number는 42와 같은 숫자들을 위한 타입입니다. 자바스크립트는 정수를 위한 특별한 런타임 값을 가지지 않으므로, intfloat에 해당하는 타입은 없으며 모든 것이 그저 number입니다.
  • booleantruefalse 두 가지 값을 위한 타입입니다.

타입 이름 String, Number, Boolean(대문자로 시작)도 유효하지만, 이는 코드에 거의 나타나지 않을 특별한 내장 타입을 참조합니다. 타입을 지정할 때는 항상 string, number, 또는 boolean을 사용하십시오.

Arrays

[1, 2, 3]과 같은 배열의 타입을 지정하려면, number[] 구문을 사용할 수 있습니다. 이 구문은 모든 타입에 적용됩니다 (예: string[]은 문자열의 배열입니다). 이것이 Array<number>로 작성된 것을 볼 수도 있는데, 이는 같은 의미입니다. T<U> 구문에 대해서는 제네릭(generics)을 다룰 때 더 자세히 배웁니다.

[number]는 다른 것이라는 점에 유의하십시오.

any

타입스크립트에는 특정 값이 타입 검사 오류를 일으키는 것을 원하지 않을 때 사용할 수 있는 특별한 타입인 any가 있습니다.

let obj: any = { x: 0 };

// 다음 코드 줄은 모두 컴파일러 오류를 발생시키지 않습니다.
// any를 사용하면 모든 추가적인 타입 검사를 비활성화하며,
// 타입스크립트보다 개발자가 환경을 더 잘 알고 있다고 가정합니다.
obj.foo();
obj();
obj.bar = 100;
obj = "hello";
const n: number = obj;

any 타입은 특정 코드 라인이 괜찮다는 것을 타입스크립트에 납득시키기 위해 긴 타입을 작성하고 싶지 않을 때 유용합니다.

  • noImplicitAny

타입을 지정하지 않고 타입스크립트가 문맥에서 타입을 추론할 수 없는 경우, 컴파일러는 일반적으로 any로 기본 설정합니다. 하지만 any는 타입 검사가 되지 않기 때문에 보통은 이를 피하는 것이 좋습니다. noImplicitAny 컴파일러 플래그를 사용해 암시적인 any를 오류로 표시하도록 하세요.

Type Annotations on Variables

const, var, 또는 let을 사용해 변수를 선언할 때, 변수의 타입을 명시적으로 지정하기 위해 선택적으로 타입 주석(type annotation)을 추가할 수 있습니다.

let myName: string = "Alice";

타입스크립트는 int x = 0;과 같이 “왼쪽에 타입을 두는” 스타일의 선언을 사용하지 않습니다. 타입 주석은 항상 타입을 지정할 대상 뒤에 옵니다.

하지만 대부분의 경우, 이는 필요하지 않습니다. 타입스크립트는 가능한 한 코드의 타입을 자동으로 추론하려고 시도합니다. 예를 들어, 변수의 타입은 초기화 값의 타입에 기반해 추론됩니다. 대부분의 경우 추론 규칙을 명시적으로 배울 필요는 없습니다. 이제 막 시작하는 단계라면, 생각보다 적은 타입 주석을 사용해 보세요. 타입스크립트가 상황을 완전히 이해하는 데 필요한 주석이 얼마나 적은지에 놀랄 수도 있습니다.

4. Functions

함수는 자바스크립트에서 데이터를 전달하는 주요 수단입니다. 타입스크립트는 함수의 입력값과 출력값 모두의 타입을 지정할 수 있도록 허용합니다.

Parameter Type Annotations

함수를 선언할 때, 각 매개변수 뒤에 타입 주석을 추가해 함수가 어떤 타입의 매개변수를 받는지 선언할 수 있습니다. 매개변수 타입 주석은 매개변수 이름 뒤에 옵니다.

// 매개변수 타입 주석
function greet(name: string) {
  console.log("Hello, " + name.toUpperCase() + "!!");
}

매개변수에 타입 주석이 있으면, 해당 함수로 전달되는 인자(argument)들이 검사됩니다.

// 실행되면 런타임 오류가 발생할 것입니다!
greet(42);

'number' 타입의 인자는 'string' 타입의 매개변수에 할당할 수 없습니다.

매개변수에 타입 주석이 없더라도, 타입스크립트는 여전히 올바른 수의 인자가 전달되었는지 확인합니다.

Return Type Annotations

변수의 타입 주석과 마찬가지로, 타입스크립트는 함수의 return 문을 기반으로 반환 타입을 추론하기 때문에 보통은 반환 타입 주석이 필요하지 않습니다. 위 예제의 타입 주석은 아무것도 변경하지 않습니다. 일부 코드베이스에서는 문서화 목적으로, 의도하지 않은 변경을 방지하기 위해, 또는 단지 개인적인 선호도에 따라 반환 타입을 명시적으로 지정하기도 합니다.

Functions Which Return Promises

Promise를 반환하는 함수의 반환 타입을 주석으로 달고 싶다면, Promise 타입을 사용해야 합니다.

async function getFavoriteNumber(): Promise<number> {
  return 26;
}

Anonymous Functions

익명 함수는 함수 선언과 약간 다릅니다. 타입스크립트가 함수가 어떻게 호출될지 결정할 수 있는 위치에 함수가 나타나면, 해당 함수의 매개변수들은 자동으로 타입을 부여받습니다.

const names = ["Alice", "Bob", "Eve"];

// 함수에 대한 문맥적 타이핑(Contextual typing)
// 매개변수 s는 string 타입을 가진 것으로 추론됨
names.forEach(function (s) {
  console.log(s.toUpperCase());
});

// 문맥적 타이핑은 화살표 함수에도 적용됨
names.forEach((s) => {
  console.log(s.toUpperCase());
});

매개변수 s에 타입 주석이 없었음에도 불구하고, 타입스크립트는 forEach 함수의 타입과 배열의 추론된 타입을 함께 사용하여 s가 가질 타입을 결정했습니다.

이 과정은 함수가 나타나는 문맥(context)이 해당 함수가 가져야 할 타입을 알려주기 때문에 문맥적 타이핑(contextual typing)이라고 불립니다. 추론 규칙과 마찬가지로, 이 과정이 어떻게 일어나는지 명시적으로 배울 필요는 없지만, 이것이 일어난다는 것을 이해하면 타입 주석이 필요 없는 경우를 파악하는 데 도움이 됩니다. 나중에 값이 발생하는 문맥이 해당 값의 타입에 어떻게 영향을 미칠 수 있는지에 대한 더 많은 예를 보게 됩니다.

5. Object Types

기본 타입을 제외하고 가장 흔하게 마주치는 타입은 객체 타입(object type)입니다. 이는 프로퍼티를 가진 모든 자바스크립트 값을 지칭하며, 이는 거의 모든 값에 해당합니다! 객체 타입을 정의하려면, 단순히 프로퍼티와 그 타입을 나열하면 됩니다. 예를 들어, 점(point)과 유사한 객체를 받는 함수는 다음과 같습니다.

// 매개변수의 타입 주석은 객체 타입입니다
function printCoord(pt: { x: number; y: number }) {
  console.log("The coordinate's x value is " + pt.x);
  console.log("The coordinate's y value is " + pt.y);
}

printCoord({ x: 3, y: 7 });

여기서 우리는 매개변수에 xy라는 두 개의 프로퍼티를 가진 타입으로 주석을 달았고, 두 프로퍼티 모두 number 타입입니다. 프로퍼티를 구분하기 위해 , 또는 ;를 사용할 수 있으며, 마지막 구분자는 어느 쪽이든 선택 사항입니다. 각 프로퍼티의 타입 부분 또한 선택 사항입니다. 만약 타입을 지정하지 않으면, any로 간주됩니다.

Optional Properties

객체 타입은 일부 또는 모든 프로퍼티가 선택적(optional)이라고 지정할 수도 있습니다. 이를 위해서는 프로퍼티 이름 뒤에 ?를 추가하면 됩니다.

function printName(obj: { first: string; last?: string }) {
  // ...
}

// 둘 다 OK
printName({ first: "Bob" });
printName({ first: "Alice", last: "Alisson" });

자바스크립트에서는 존재하지 않는 프로퍼티에 접근하면 런타임 오류 대신 undefined 값을 얻게 됩니다. 이 때문에 선택적 프로퍼티에서 값을 읽을 때는, 사용하기 전에 undefined인지 확인해야 합니다.

function printName(obj: { first: string; last?: string }) {
  // 오류 - 'obj.last'가 제공되지 않으면 충돌할 수 있습니다!
  console.log(obj.last.toUpperCase());
  // 'obj.last' is possibly 'undefined'.

  if (obj.last !== undefined) {
    // OK
    console.log(obj.last.toUpperCase());
  }

  // 최신 자바스크립트 구문을 사용한 안전한 대안:
  console.log(obj.last?.toUpperCase());
}

Union Types

타입스크립트의 타입 시스템은 다양한 연산자를 사용해 기존 타입으로부터 새로운 타입을 만들 수 있게 해줍니다. 이제 몇 가지 타입을 작성하는 방법을 알았으니, 이들을 흥미로운 방식으로 결합해 볼 시간입니다.

Defining a Union Type

타입을 결합하는 첫 번째 방법은 유니언 타입(union type)입니다. 유니언 타입은 두 개 이상의 다른 타입으로 형성된 타입으로, 이들 타입 중 어느 하나일 수 있는 값을 나타냅니다. 우리는 이들 각 타입을 유니언의 멤버(members)라고 부릅니다.

문자열이나 숫자에 대해 작동할 수 있는 함수를 작성해 보겠습니다.

function printId(id: number | string) {
  // ...
}

// OK
printId(101);

// OK
printId("202");

// Error
printId({ myID: 22342 });
// '{ myID: number; }' 타입의 인자는 'string | number' 타입의 매개변수에 할당할 수 없습니다.

Working with Union Types

유니언 타입과 일치하는 값을 제공하는 것은 쉽습니다. 단순히 유니언의 멤버 중 하나와 일치하는 타입을 제공하면 됩니다. 유니언 타입의 값을 가지고 있다면, 어떻게 다루어야 할까요?

타입스크립트는 유니언의 모든 멤버에게 유효한 연산만 허용합니다. 예를 들어, string | number 유니언이 있다면, string에서만 사용할 수 있는 메서드는 사용할 수 없습니다.

function printId(id: number | string) {
  console.log(id.toUpperCase());
  // Property 'toUpperCase' does not exist on type 'string | number'.
  // Property 'toUpperCase' does not exist on type 'number'.
}

해결책은 타입 주석 없이 자바스크립트에서 하던 것과 똑같이, 코드로 유니언을 좁히는(narrow) 것입니다. 내로잉(Narrowing)은 타입스크립트가 코드 구조에 기반해 값에 대한 더 구체적인 타입을 추론할 수 있을 때 발생합니다. 예를 들어, 타입스크립트는 string 값만이 "string"이라는 typeof 값을 가질 것이라는 것을 압니다.

function printId(id: number | string) {
  if (typeof id === "string") {
    // 이 분기 안에서 id는 'string' 타입입니다
    console.log(id.toUpperCase());
  } else {
    // 여기서 id는 'number' 타입입니다
    console.log(id);
  }
}

또 다른 예는 Array.isArray와 같은 함수를 사용하는 것입니다.

function welcomePeople(x: string[] | string) {
  if (Array.isArray(x)) {
    // 여기서 'x'는 'string[]' 입니다
    console.log("Hello, " + x.join(" and "));
  } else {
    // 여기서 'x'는 'string' 입니다
    console.log("Welcome lone traveler " + x);
  }
}

else 분기에서는 특별한 작업을 할 필요가 없다는 점에 주목하세요. xstring[]이 아니었다면, 그것은 반드시 string이었을 것입니다. 때로는 모든 멤버가 공통점을 가진 유니언을 다룰 때가 있습니다. 예를 들어, 배열과 문자열은 모두 slice 메서드를 가집니다. 유니언의 모든 멤버가 공통된 프로퍼티를 가지고 있다면, 내로잉 없이 그 프로퍼티를 사용할 수 있습니다.

타입의 유니언(union)이 해당 타입들의 프로퍼티의 교집합(intersection)을 갖는 것처럼 보이는 것이 혼란스러울 수 있습니다. 이것은 우연이 아닙니다 - 유니언이라는 이름은 타입 이론에서 유래했습니다. 유니언 number | string은 각 타입의 값들의 합집합(union)을 취함으로써 구성됩니다. 두 집합과 각 집합에 대한 사실들이 주어졌을 때, 그 사실들의 교집합만이 집합들 자체의 합집합에 적용된다는 점을 주목하세요. 예를 들어, 모자를 쓴 키 큰 사람들이 있는 방과 모자를 쓴 스페인어 구사자들이 있는 방이 있다면, 이 방들을 합친 후에 우리가 모든 사람에 대해 아는 유일한 사실은 그들이 모자를 쓰고 있어야 한다는 것입니다.

6. Type Aliases

우리는 지금까지 객체 타입과 유니언 타입을 타입 주석에 직접 작성하여 사용해왔습니다. 이것은 편리하지만, 같은 타입을 두 번 이상 사용하고 단일 이름으로 참조하고 싶은 경우가 많습니다. 타입 별칭(type alias)은 바로 그것, 즉 모든 타입에 대한 이름입니다. 타입 별칭의 구문은 다음과 같습니다.

type Point = {
  x: number;
  y: number;
};

// 이전 예제와 정확히 동일합니다
function printCoord(pt: Point) {
  console.log("The coordinate's x value is " + pt.x);
  console.log("The coordinate's y value is " + pt.y);
}

printCoord({ x: 100, y: 100 });

실제로 타입 별칭을 사용해 객체 타입뿐만 아니라 어떤 타입이든 이름을 부여할 수 있습니다. 예를 들어, 타입 별칭으로 유니언 타입에 이름을 붙일 수 있습니다.

type ID = number | string;

타입 별칭은 단지 별칭일 뿐이라는 점에 유의하세요. 다음과 같은 코드를 작성할 때, string 타입의 특별한 버전을 만든 것이 아니라, string 타입의 다른 이름을 만든 것뿐입니다.

type UserInputSanitizedString = string;

function sanitizeInput(str: string): UserInputSanitizedString {
  return sanitize(str);
}

// 정제된 입력을 생성합니다
let userInput = sanitizeInput(getInput());

// 여전히 문자열로 재할당될 수 있습니다
userInput = "new input";

Interfaces

인터페이스 선언(interface declaration)은 객체 타입에 이름을 붙이는 또 다른 방법입니다.

interface Point {
  x: number;
  y: number;
}

function printCoord(pt: Point) {
  console.log("The coordinate's x value is " + pt.x);
  console.log("The coordinate's y value is " + pt.y);
}

printCoord({ x: 100, y: 100 });

위에서 타입 별칭을 사용했을 때처럼, 이 예제는 우리가 익명 객체 타입을 사용한 것과 똑같이 작동합니다. 타입스크립트는 우리가 printCoord에 전달한 값의 구조에만 관심이 있습니다. 즉, 필요한 프로퍼티를 가지고 있는지만 신경 씁니다. 이처럼 타입의 구조와 기능에만 관심을 갖기 때문에 우리는 타입스크립트를 구조적 타이핑(structurally typed) 타입 시스템이라고 부릅니다.

Differences Between Type Aliases and Interfaces

타입 별칭과 인터페이스는 매우 유사하며, 많은 경우 자유롭게 선택해 사용할 수 있습니다. interface의 거의 모든 기능은 type에서도 사용할 수 있으며, 주된 차이점은 타입은 생성된 후에 변경될 수 없지만 인터페이스는 항상 확장 가능하다는 것입니다.

기능 인터페이스 (Interface) 타입 별칭 (Type Alias)
확장 인터페이스 확장하기<br>typescript <br>interface Animal { <br> name: string; <br>} <br><br>interface Bear extends Animal { <br> honey: boolean; <br>}<br><br>const bear = getBear(); <br>bear.name; <br>bear.honey; <br> 교차점(&)을 통한 타입 확장하기<br>typescript <br>type Animal = { <br> name: string; <br>} <br><br>type Bear = Animal & { <br> honey: boolean; <br>} <br><br>const bear = getBear(); <br>bear.name; <br>bear.honey; <br>
기존 타입에 필드 추가 기존 인터페이스에 새 필드 추가하기<br>typescript <br>interface Window { <br> title: string; <br>} <br><br>interface Window { <br> ts: TSAPI; <br>} <br><br>const src = 'const a = "Hello World"'; <br>window.ts.transpileModule(src, {}); <br> 타입은 생성된 후 변경될 수 없음 <br>typescript<br>type Window = { <br> title: string; <br>} <br><br>type Window = { <br> ts: TSAPI; <br>} <br><br>// 오류: 식별자가 중복되었습니다. <br>

이 개념들에 대해서는 이후 더 자세히 배울 것이므로, 지금 당장 이해되지 않더라도 걱정하지 마십시오. 대부분의 경우, 개인적인 선호에 따라 선택할 수 있으며, 타입스크립트는 다른 종류의 선언이 필요할 경우 알려줄 것입니다. 경험적인 규칙을 원한다면, type의 기능이 필요해지기 전까지는 interface를 사용하십시오.

7. Type Assertions

때때로 여러분은 타입스크립트가 알 수 없는 값의 타입에 대한 정보를 가지고 있을 것입니다. 예를 들어, document.getElementById를 사용하는 경우, 타입스크립트는 이것이 어떤 종류의 HTMLElement를 반환할 것이라는 것만 알지만, 여러분은 페이지에 특정 ID를 가진 HTMLCanvasElement가 항상 존재한다는 것을 알고 있을 수 있습니다. 이러한 상황에서는, 타입 단언(type assertion)을 사용해 더 구체적인 타입을 지정할 수 있습니다.

const myCanvas = document.getElementById("main_canvas") as HTMLCanvasElement;

타입 주석처럼, 타입 단언은 컴파일러에 의해 제거되며 코드의 런타임 동작에 영향을 미치지 않습니다. 앵글 브래킷(<>) 구문을 사용할 수도 있으며, 이는 동일한 역할을 합니다 (단, 코드가 .tsx 파일 안에 있는 경우는 예외):

const myCanvas = <HTMLCanvasElement>document.getElementById("main_canvas");

타입 단언은 타입 검사기가 강제로 여러분의 말을 믿게 만드는 것이므로, 단언이 잘못되었을 경우 예외나 예기치 않은 버그가 발생할 수 있습니다.

타입스크립트는 타입을 더 구체적인 버전이나 덜 구체적인 버전으로 변환하는 타입 단언만 허용합니다. 이 규칙은 다음과 같은 “불가능한” 강제 변환을 방지합니다.

const x = "hello" as number;
// 'string' 타입을 'number' 타입으로 변환하는 것은 실수일 수 있습니다.
// 왜냐하면 어느 타입도 다른 타입과 충분히 겹치지 않기 때문입니다.
// 이것이 의도적인 것이었다면, 표현식을 먼저 'unknown'으로 변환하십시오.

때때로 이 규칙이 너무 보수적이어서 유효할 수 있는 더 복잡한 강제 변환을 허용하지 않을 수 있습니다. 이런 경우, 먼저 any (또는 나중에 소개할 unknown)로, 그 다음 원하는 타입으로 두 번의 단언을 사용할 수 있습니다.

const a = expr as any as T;

Literal Types

일반적인 타입인 stringnumber 외에도, 타입 위치에서 특정 문자열과 숫자를 참조할 수 있습니다. 이를 생각하는 한 가지 방법은 자바스크립트가 변수를 선언하는 다양한 방법을 고려하는 것입니다. varlet은 변수 안에 담긴 것을 변경할 수 있게 허용하고, const는 그렇지 않습니다. 이것은 타입스크립트가 리터럴에 대한 타입을 생성하는 방식에 반영됩니다.

let changingString = "Hello World";
changingString = "Olá Mundo";
// changingString은 어떤 문자열이든 나타낼 수 있음

반면, const로 선언해 보도록 하겠습니다.

const constantString = "Hello World";

그 자체만으로 리터럴 타입은 그다지 가치가 없습니다.

let x: "hello" = "hello";
// OK
x = "hello";

// Error
x = "howdy";
// Type '"howdy"' is not assignable to type '"hello"'.

하나의 값만 가질 수 있는 변수는 별로 유용하지 않습니다! 하지만 리터럴을 유니언으로 결합함으로써, 훨씬 더 유용한 개념을 표현할 수 있습니다. 예를 들어, 특정 집합의 알려진 값만 받는 함수 같은 것입니다.

function printText(s: string, alignment: "left" | "right" | "center") {
  // ...
}
printText("Hello, world", "left");
printText("G'day, mate", "centre");
// Argument of type '"centre"' is not assignable to parameter of type '"left" | "right" | "center"'.

숫자 리터럴 타입도 같은 방식으로 작동합니다.

function compare(a: string, b: string): -1 | 0 | 1 {
  return a === b ? 0 : a > b ? 1 : -1;
}

리터럴 타입의 또 다른 종류는 불리언 리터럴입니다. 두 개의 불리언 리터럴 타입만이 있으며, 짐작할 수 있듯이 truefalse 타입입니다. boolean 타입 자체는 사실상 true | false 유니언의 별칭입니다.

Literal Inference

객체로 변수를 초기화할 때, 타입스크립트는 해당 객체의 프로퍼티 값이 나중에 변경될 수 있다고 가정합니다.

const obj = { counter: 0 };
if (someCondition) {
  obj.counter = 1;
}

타입스크립트는 이전에 0을 가졌던 필드에 1을 할당하는 것을 오류로 가정하지 않습니다. 다른 말로 하면, obj.counter0이 아닌 number 타입을 가져야 합니다. 왜냐하면 타입은 읽기 및 쓰기 동작을 모두 결정하는 데 사용되기 때문입니다. 다음은 req.method"GET"이 아닌 string으로 추론되는 예입니다. req 생성과 handleRequest 호출 사이에 코드가 실행되어 req.method"GUESS"와 같은 새 문자열을 할당할 수 있기 때문에 타입스크립트는 이 코드를 오류로 간주합니다.

declare function handleRequest(url: string, method: "GET" | "POST"): void;
const req = { url: "https://example.com", method: "GET" };
handleRequest(req.url, req.method);
// Argument of type 'string' is not assignable to parameter of type '"GET" | "POST"'.

이를 해결하는 두 가지 방법이 있습니다.

  1. 어느 위치에서든 타입 단언을 추가해 추론을 변경할 수 있습니다.
// 변경 1:
const req = { url: "https://example.com", method: "GET" as "GET" };

// 변경 2:
handleRequest(req.url, req.method as "GET");
  • 변경 1은 “나는 req.method가 항상 리터럴 타입 "GET"을 갖기를 의도한다”는 의미로, 이후에 해당 필드에 "GUESS"가 할당될 가능성을 막습니다.
  • 변경 2는 “나는 다른 이유로 req.method"GET" 값을 가지고 있음을 안다”는 의미입니다.
  1. as const를 사용해 전체 객체를 타입 리터럴로 변환할 수 있습니다.
const req = { url: "https://example.com", method: "GET" } as const;
handleRequest(req.url, req.method);
  • as const 접미사는 타입 시스템에 대한 const처럼 작동하여, 모든 프로퍼티가 string이나 number와 같은 더 일반적인 버전 대신 리터럴 타입을 할당받도록 보장합니다.

null and undefined

자바스크립트에는 값이 없거나 초기화되지 않았음을 나타내는 두 가지 기본 값인 nullundefined가 있습니다.

  • strictNullChecks 해제 (off)

strictNullChecks가 해제된 상태에서는, null이나 undefined일 수 있는 값에 여전히 정상적으로 접근할 수 있으며, nullundefined 값은 어떤 타입의 프로퍼티에도 할당할 수 있습니다. 이것은 null 검사가 없는 언어(예: C#, Java)가 동작하는 방식과 유사합니다. 이러한 값에 대한 검사 부족은 버그의 주요 원인이 되는 경향이 있습니다. 코드베이스에서 실용적이라면 strictNullChecks를 켜는 것을 항상 권장합니다.

  • strictNullChecks 설정 (on)

strictNullChecks가 설정된 상태에서는, 값이 null 또는 undefined일 때 해당 값의 메서드나 프로퍼티를 사용하기 전에 해당 값들을 테스트해야 합니다. 선택적 프로퍼티를 사용하기 전에 undefined를 확인하는 것처럼, 내로잉을 사용해 null일 수 있는 값을 확인할 수 있습니다.

function doSomething(x: string | null) {
  if (x === null) {
    // 아무것도 하지 않음
  } else {
    console.log("Hello, " + x.toUpperCase());
  }
}
  • Non-null 단언 연산자 (Postfix !)

타입스크립트에는 명시적인 확인 없이 타입에서 nullundefined를 제거하는 특별한 구문도 있습니다. 표현식 뒤에 !를 작성하는 것은 값이 null이나 undefined가 아니라는 타입 단언과 같습니다.

function liveDangerously(x?: number | null) {
  // 오류 없음
  console.log(x!.toFixed());
}

!는 런타임에 코드를 변경하지 않으므로, 코드가 충돌할 수 있는 버그를 숨길 수 있기 때문에 신중하게 사용해야 합니다.

8. Enum

Enum은 타입스크립트에 의해 자바스크립트에 추가된 기능으로, 가능한 명명된 상수 집합 중 하나일 수 있는 값을 설명할 수 있게 해줍니다. 대부분의 타입스크립트 기능과 달리, 이것은 자바스크립트에 대한 타입 수준의 추가가 아니라 언어와 런타임에 추가된 것입니다. 이 때문에, 존재한다는 것은 알아야 하지만, 확실하지 않다면 사용을 보류해야 할 기능입니다.

9. Less Common Primitives

여기서 깊이 다루지는 않겠지만, 타입 시스템에 표현되는 자바스크립트의 나머지 기본 타입들을 언급할 가치가 있습니다.

bigint

ES2020부터, 자바스크립트에는 매우 큰 정수를 위한 기본 타입인 BigInt가 있습니다.

// BigInt 함수를 통해 bigint 생성
const oneHundred: bigint = BigInt(100);

// 리터럴 구문을 통해 BigInt 생성
const anotherHundred: bigint = 100n;

symbol

자바스크립트에는 Symbol() 함수를 통해 전역적으로 고유한 참조를 생성하는 데 사용되는 기본 타입이 있습니다.

const firstName = Symbol("name");
const secondName = Symbol("name");

if (firstName === secondName) { // 이 줄은 항상 false를 반환합니다
  // 절대 일어날 수 없음
}

10. Narrowing

padLeft라는 함수가 있다고 가정해 봅시다.

function padLeft(padding: number | string, input: string): string {
  throw new Error("Not implemented yet!");
}

이 함수는 paddingnumber이면, 그 숫자만큼의 공백을 input 앞에 추가합니다. paddingstring이면, padding 문자열을 input 앞에 추가합니다. padding 값으로 number가 전달되었을 때의 로직을 구현해 보겠습니다.

function padLeft(padding: number | string, input: string): string {
  return " ".repeat(padding) + input;
  //     ~~~~~~~~~~~~~~~~~~~
  // Argument of type 'string | number' is not assignable to parameter of type 'number'.
  //   Type 'string' is not assignable to type 'number'.
}

padding 부분에 오류가 발생했습니다. TS는 number | string 타입의 값을 number 타입만 허용하는 repeat 함수에 전달하고 있다고 경고하며, 이는 올바른 지적입니다. 즉, 우리는 paddingnumber인지 명시적으로 확인하지 않았고, string인 경우도 처리하지 않았습니다. 이제 이 문제를 해결해 보겠습니다.

function padLeft(padding: number | string, input: string): string {
  if (typeof padding === "number") {
    return " ".repeat(padding) + input;
  }
  return padding + input;
}

이 코드가 대부분 평범한 JS 코드처럼 보인다면, 그것이 바로 핵심입니다. 우리가 추가한 타입 표기(annotation)를 제외하면, 이 TS 코드는 JS와 똑같아 보입니다. 이는 TS의 타입 시스템이 타입 안전성을 얻기 위해 억지로 코드를 바꾸지 않고도, 일반적인 JS 코드를 최대한 쉽게 작성할 수 있도록 하는 것을 목표로 하기 때문입니다.

별것 아닌 것처럼 보일 수 있지만, 내부적으로는 많은 일이 일어나고 있습니다. TS는 정적 타입을 사용해 런타임 값을 분석하는 것과 매우 유사하게, if/else, 조건부 삼항 연산자, 루프, “truthiness” 확인 등 타입에 영향을 줄 수 있는 JS의 런타임 제어 흐름 구문에 타입 분석을 적용합니다.

if문 안에서 TS는 typeof padding === "number"를 보고 이를 타입 가드(type guard)라는 특별한 형태의 코드로 이해합니다. TS는 프로그램이 실행될 수 있는 모든 가능한 경로를 따라가며 특정 위치에서 값이 가질 수 있는 가장 구체적인 타입을 분석합니다. 이러한 특별한 검사(타입 가드)와 할당을 살펴보고, 선언된 타입보다 더 구체적인 타입으로 정제하는 과정을 내로잉(narrowing)이라고 부릅니다. 많은 에디터에서 이러한 타입 변화를 관찰할 수 있으며, 우리 예제에서도 이를 확인해 볼 수 있습니다.

function padLeft(padding: number | string, input: string): string {
  if (typeof padding === "number") {
    return " ".repeat(padding) + input;
    // (parameter) padding: number
  }
  // (parameter) padding: string
  return padding + input;
}

####typeof 타입 가드

앞서 보았듯이, JS는 런타임에 값이 어떤 타입인지에 대한 매우 기본적인 정보를 제공하는 typeof 연산자를 지원합니다. TS는 typeof가 다음과 같은 특정 문자열 중 하나를 반환할 것으로 예상합니다.

  • "string"
  • "number"
  • "bigint"
  • "boolean"
  • "symbol"
  • "undefined"
  • "object"
  • "function"

padLeft 예제에서 본 것처럼, 이 연산자는 수많은 JS 라이브러리에서 매우 자주 등장하며, TS는 이를 이해해 서로 다른 분기점에서 타입을 좁힐 수 있습니다. TS에서 typeof가 반환하는 값과 비교해 확인하는 것을 타입 가드(type guard)라고 합니다. TS는 typeof가 다른 값에 대해 어떻게 작동하는지 알고 있기 때문에 JS에서의 몇 가지 기묘한 동작에 대해서도 파악하고 있습니다. 예를 들어, 위 목록에서 typeof가 문자열 null을 반환하지 않는다는 점에 주목하세요. 다음 예제를 확인해 봅시다.

function printAll(strs: string | string[] | null) {
  if (typeof strs === "object") {
    for (const s of strs) {
      // 'strs' is possibly 'null'.
      console.log(s);
    }
  }
}

printAll 함수에서 우리는 strs가 배열 타입인지 확인하기 위해 object인지 검사하려고 합니다 (JS에서 배열은 object 타입이라는 점을 다시 한번 상기하기 좋은 시점입니다). 하지만 JS에서 typeof null의 결과는 실제로 "object"입니다! 이는 안타까운 역사의 산물 중 하나입니다.

경험 많은 사용자들은 놀라지 않을 수도 있지만, JS에서 이 문제를 겪어보지 못한 사람도 많습니다. 다행히 TS는 strs의 타입이 단지 string[]가 아니라 string[] | null로 좁혀졌다는 것을 알려줍니다.

Truthiness Narrowing

Truthiness라는 단어는 사전에서 찾을 수 없을지 모르지만, JS 개발에서는 매우 흔하게 들을 수 있는 용어입니다. JS에서는 조건문, &&|| 연산, if문, 불리언 부정(!) 등에 어떤 표현식이든 사용할 수 있습니다. 예를 들어, if문은 조건식이 항상 boolean 타입일 것으로 기대하지 않습니다.

function getUsersOnlineMessage(numUsersOnline: number) {
  if (numUsersOnline) {
    return `There are ${numUsersOnline} online now!`;
  }
  return "Nobody's here. :(";
}

JS에서 if와 같은 구문은 먼저 조건식을 boolean으로 “강제 변환(coerce)”하여 의미를 파악한 후, 그 결과가 true인지 false인지에 따라 분기를 선택합니다. 다음과 같은 값들은 false로 강제 변환됩니다.

  • 0
  • NaN
  • "" (빈 문자열)
  • undefined
  • null

이 외의 다른 값들은 모두 true로 강제 변환됩니다. Boolean 함수를 사용하거나 더 짧은 이중 부정 연산자(!!)를 사용해 값을 boolean으로 변환할 수 있습니다. (후자의 경우 TS가 boolean이 아닌 더 좁은 리터럴 타입 true로 추론하는 이점이 있습니다.)

// 둘 다 'true'가 됩니다.
Boolean("hello"); // 타입: boolean, 값: true
!!"world";      // 타입: true,    값: true

이러한 동작은 null이나 undefined 같은 값으로부터 코드를 보호하는 데 매우 유용하게 사용됩니다. printAll 함수에 이 방법을 적용해 보겠습니다.

function printAll(strs: string | string[] | null) {
  if (strs && typeof strs === "object") {
    for (const s of strs) {
      console.log(s);
    }
  } else if (typeof strs === "string") {
    console.log(strs);
  }
}

strs가 truthy한 값인지 확인해 위에서 발생했던 오류를 제거한 것을 볼 수 있습니다. 이 방법은 최소한 코드를 실행할 때 TypeError: null is not iterable과 같은 심각한 오류를 방지해 줍니다.

하지만 원시(primitive) 타입에 대한 truthiness 검사는 오류를 발생시키기 쉽다는 점을 명심해야 합니다. printAll을 다르게 작성한 다음 예제를 살펴보세요.

function printAll(strs: string | string[] | null) {
  // !!!!
  if (strs) {
    if (typeof strs === "object") {
      for (const s of strs) {
        console.log(s);
      }
    } else if (typeof strs === "string") {
      console.log(strs);
    }
  }
}

함수 본문 전체를 truthy 검사로 감쌌지만, 여기에는 미묘한 단점이 있습니다. 바로 빈 문자열("") 케이스를 더 이상 올바르게 처리하지 못할 수 있다는 것입니다. TS가 여기서 문제를 일으키지는 않지만, JS에 익숙하지 않다면 이러한 동작은 주목할 가치가 있습니다.

Truthiness를 이용한 내로잉에 대해 마지막으로 한 가지 더 언급하자면, 불리언 부정 ! 연산자는 부정된 분기에서 타입을 제외시킨다는 점입니다.

function multiplyAll(
  values: number[] | undefined,
  factor: number
): number[] | undefined {
  if (!values) {
    return values; // 여기서 'values'는 'undefined'입니다.
  } else {
    // 여기서 'values'는 'number[]'입니다.
    return values.map((x) => x * factor);
  }
}

Equality Narrowing

TS는 switch문과 ===, !==, ==, != 와 같은 동등성 검사를 사용해서도 타입을 좁힐 수 있습니다.

function example(x: string | number, y: string | boolean) {
  if (x === y) {
    // 이제 'x'와 'y' 모두에 대해 모든 'string' 메서드를 호출할 수 있습니다.
    x.toUpperCase();
    // (parameter) x: string
    y.toLowerCase();
    // (parameter) y: string
  } else {
    console.log(x);
    // (parameter) x: string | number
    console.log(y);
    // (parameter) y: string | boolean
  }
}

위 예제에서 xy가 서로 같다고 확인했을 때, TS는 두 값의 타입 또한 같아야 한다는 것을 압니다. xy가 공통으로 가질 수 있는 유일한 타입은 string이므로, TS는 첫 번째 분기에서 xy가 반드시 string이어야 함을 압니다.

(변수가 아닌) 특정 리터럴 값과 비교하는 것도 마찬가지로 동작합니다. “Truthiness 내로잉” 섹션에서 작성했던 printAll 함수는 빈 문자열을 제대로 처리하지 못해 오류가 발생하기 쉬웠습니다. 대신, null을 명시적으로 제외하는 검사를 수행할 수 있으며, TS는 여전히 strs의 타입에서 null을 정확하게 제거합니다.

function printAll(strs: string | string[] | null) {
  if (strs !== null) {
    if (typeof strs === "object") {
      for (const s of strs) {
        // (parameter) strs: string[]
        console.log(s);
      }
    } else if (typeof strs === "string") {
      console.log(strs);
      // (parameter) strs: string
    }
  }
}

JS의 느슨한 동등성 검사인 ==!=도 올바르게 타입을 좁힙니다. 어떤 값이 == null인지 확인하는 것은 실제로는 그 값이 null인지 뿐만 아니라 undefined일 가능성도 함께 확인합니다. == undefined도 마찬가지로, 값이 null 또는 undefined인지 검사합니다.

interface Container {
  value: number | null | undefined;
}

function multiplyValue(container: Container, factor: number) {
  // 타입에서 'null'과 'undefined'를 모두 제거합니다.
  if (container.value != null) {
    console.log(container.value);
    // (property) Container.value: number

    // 이제 'container.value'를 안전하게 곱할 수 있습니다.
    container.value *= factor;
  }
}

in Operator Narrowing

JS에는 객체 또는 객체의 프로토타입 체인에 특정 이름을 가진 속성이 있는지 확인하는 in 연산자가 있습니다. TS는 이를 잠재적 타입을 좁히는 방법으로 활용합니다.

예를 들어, "value" in x라는 코드에서 "value"가 문자열 리터럴이고 x가 유니온 타입일 때, true 분기에서는 x의 타입이 "value" 속성을 가진 것으로 좁혀지고, false 분기에서는 해당 속성이 없는 것으로 좁혀집니다.

type Fish = { swim: () => void };
type Bird = { fly: () => void };
type Human = { swim?: () => void; fly?: () => void };

function move(animal: Fish | Bird | Human) {
  if ("swim" in animal) {
    animal;
    // (parameter) animal: Fish | Human
  } else {
    animal;
    // (parameter) animal: Bird | Human
  }
}

위 예제에서처럼, 옵셔널(?) 속성은 내로잉을 위해 양쪽 분기에 모두 존재하게 됩니다. 예를 들어, 사람은 (적절한 장비와 함께) 수영도 할 수 있고 날 수도 있으므로 in 검사의 양쪽 분기에 모두 나타나야 합니다.

instanceof 내로잉

JS에는 어떤 값이 다른 값의 “인스턴스”인지 확인하는 instanceof 연산자가 있습니다. 더 구체적으로 x instanceof Foox의 프로토타입 체인에 Foo.prototype이 포함되어 있는지 확인합니다. instanceof 또한 타입 가드이며, TS는 instanceof로 보호되는 분기에서 타입을 좁힙니다.

function logValue(x: Date | string) {
  if (x instanceof Date) {
    console.log(x.toUTCString());
    // (parameter) x: Date
  } else {
    console.log(x.toUpperCase());
    // (parameter) x: string
  }
}

Assignments

앞서 언급했듯이, 변수에 값을 할당할 때 TS는 할당문의 오른편을 보고 왼편의 타입을 적절하게 좁힙니다.

let x = Math.random() < 0.5 ? 10 : "hello world!";
//  let x: string | number

x = 1;
console.log(x);
//  let x: number

x = "goodbye!";
console.log(x);
//  let x: string

xboolean 값을 할당하면 오류가 발생하는 것을 볼 수 있는데, 이는 boolean이 선언된 타입(string | number)에 포함되지 않기 때문입니다. 할당 가능성은 항상 변수의 선언된 타입을 기준으로 검사됩니다.

let x = Math.random() < 0.5 ? 10 : "hello world!";
//  let x: string | number

x = true;
// Type 'boolean' is not assignable to type 'string | number'.

Control Flow Analysis

지금까지 특정 분기 내에서 TS가 어떻게 타입을 좁히는지에 대한 기본적인 예제를 살펴보았습니다. 하지만 실제로는 단순히 if, while문 등에서 변수를 찾아 타입 가드를 확인하는 것 이상의 일이 일어납니다.

function padLeft(padding: number | string, input: string) {
  if (typeof padding === "number") {
    return " ".repeat(padding) + input;
  }
  return padding + input; // padding의 타입은 'string'으로 좁혀집니다.
}

padLeft 함수의 첫 번째 if 블록 안에는 return 문이 있습니다. TS는 이 코드를 분석해 if 블록이 실행되면 함수가 종료된다는 것을 이해합니다. 따라서 paddingnumber인 경우는 이 if 블록 안에서 모두 처리되었습니다. 그 결과, TS는 함수의 나머지 부분에 대해 padding 타입에서 number를 제거할 수 있었습니다 (즉, string | number에서 string으로 좁혀졌습니다).

이처럼 도달 가능성(reachability)에 기반한 코드 분석을 제어 흐름 분석(control flow analysis)이라고 하며, TS는 이 흐름 분석을 사용해 타입 가드와 할당을 만날 때마다 타입을 좁힙니다. 변수가 분석될 때 제어 흐름은 계속해서 분기하고 다시 합쳐질 수 있으며, 그 변수는 각 지점에서 다른 타입을 갖는 것으로 관찰될 수 있습니다.

function example() {
  let x: string | number | boolean;

  x = Math.random() < 0.5;
  console.log(x);
  //  let x: boolean

  if (Math.random() < 0.5) {
    x = "hello";
    console.log(x);
    //  let x: string
  } else {
    x = 100;
    //  let x: number
  }

  return x;
  // let x: string | number
}

Using type predicates

지금까지 우리는 typeof와 같은 JS 연산자를 사용해 타입을 좁혔습니다. 하지만 때로는 TS가 이미 이해하고 있는 방법 외에 더 직접적으로 제어 흐름을 제어하고 싶을 때가 있습니다. 사용자 정의 타입 가드를 정의하려면, 반환 타입이 타입 서술어(type predicate)인 함수를 정의하기만 하면 됩니다.

// 'pet as Fish'는 타입 단언입니다. 우리는 이 단언이 안전하다고 가정합니다.
function isFish(pet: Fish | Bird): pet is Fish {
  return (pet as Fish).swim !== undefined;
}

여기서 pet is Fish가 바로 타입 서술어입니다. 서술어는 parameterName is Type 형태를 가지며, parameterName은 반드시 현재 함수 시그니처에 있는 매개변수의 이름이어야 합니다.

어떤 변수와 함께 isFish 함수가 호출될 때마다, TS는 원본 타입이 호환된다면 해당 변수의 타입을 특정 타입으로 좁힙니다.

// 'swim'과 'fly' 호출 모두 이제 괜찮습니다.
let pet = getSmallPet();

if (isFish(pet)) {
  pet.swim();
} else {
  pet.fly();
}

TS는 if 분기에서 petFish라는 것을 알 뿐만 아니라, else 분기에서는 Fish가 아니므로 반드시 Bird여야 한다는 것도 압니다.

isFish 타입 가드를 사용해 Fish | Bird 배열을 필터링하고 Fish 배열을 얻을 수도 있습니다.

const zoo: (Fish | Bird)[] = [getSmallPet(), getSmallPet(), getSmallPet()];
const underwater1: Fish[] = zoo.filter(isFish);

// 또는 다음과 동일하게 작성할 수 있습니다.
const underwater2: Fish[] = zoo.filter(isFish) as Fish[];

또한, 클래스에서도 this is Type을 사용해 타입을 좁힐 수 있습니다.

Assertion functions

타입은 단언 함수(Assertion functions)를 사용해 좁힐 수도 있습니다. 단언 함수(Assertion Functions)는 예상치 못한 상황이 발생했을 때 오류를 발생시키는 특정 함수 그룹을 말합니다. 예를 들어, Node.js에는 assert라는 전용 함수가 있는데, 제공된 값이 42가 아니면 AssertionError를 발생시킵니다.

assert(someValue === 42);

이러한 패턴을 더 잘 지원하기 위해 타입스크립트 3.7에서는 “단언 시그니처(assertion signatures)”를 도입했습니다. 이를 통해 개발자는 특정 함수가 타입 단언을 수행할 것임을 명시할 수 있습니다. 단언 시그니처에는 두 가지 주요 유형이 있습니다. 이 시그니처는 함수에 전달된 조건(condition)이 참이 아니라면 함수가 오류를 발생시키므로, 해당 함수가 오류 없이 반환되었다면 현재 스코프의 나머지 부분에서는 그 조건이 반드시 참이어야 함을 명시합니다. 이를 통해 타입스크립트는 스코프 내에서 타입을 더 정확하게 좁힐 수 있습니다.

  • 구문: function assert(condition: any, msg?: string): asserts condition { ... }
  • 효과: 이 함수가 호출된 후, 타입스크립트는 이 함수에 전달된 condition이 참(truthy)이라고 인식합니다.

예를 들어, 이 기능을 사용하면 단언문 뒤에서 오타를 잡아낼 수 있습니다.

function yell(str) {
  // 이 스코프의 나머지 부분에서 str이 문자열임을 단언
  assert(typeof str === "string");

  return str.toUppercase();
  //         ~~~~~~~~~~~
  // error: Property 'toUppercase' does not exist on type 'string'.
  //        Did you mean 'toUpperCase'?
}

asserts val is type

이 시그니처는 함수가 호출된 후 특정 변수나 프로퍼티가 새롭고 더 구체적인 타입을 갖게 됨을 타입스크립트에 알립니다.

  • 구문: function assertIsString(val: any): asserts val is string { ... }
  • 효과: assertIsString(str) 호출 후, 타입스크립트는 str 변수가 이제 string 타입임을 알게 됩니다.

이는 타입 가드 시그니처(예: function isString(val: any): val is string)와 매우 유사합니다. 이 시그니처들은 표현력이 매우 뛰어나며, 제네릭과 함께 사용하면 정교한 단언을 만들 수 있습니다.

function assertIsDefined<T>(val: T): asserts val is NonNullable<T> {
  if (val === undefined || val === null) {
    throw new AssertionError(
      `'val'은 정의된 값이어야 하지만, ${val}을 받았습니다.`
    );
  }
}

Discriminated Unions

지금까지 살펴본 대부분의 예제는 string, boolean, number와 같은 단순한 타입을 가진 단일 변수를 좁히는 데 중점을 두었습니다. 이것도 일반적인 경우지만, JS에서는 대부분 조금 더 복잡한 구조를 다루게 됩니다.

동기 부여를 위해, 원과 사각형 같은 도형을 인코딩한다고 상상해 봅시다. 원은 반지름을, 사각형은 변의 길이를 추적합니다. 우리는 kind라는 필드를 사용해 어떤 도형을 다루고 있는지 구별할 것입니다. 다음은 Shape를 정의하는 첫 번째 시도입니다.

interface Shape {
  kind: "circle" | "square";
  radius?: number;
  sideLength?: number;
}

string 대신 문자열 리터럴 타입의 유니온("circle" | "square")을 사용해 오타 문제를 피할 수 있습니다.

function handleShape(shape: Shape) {
  // 이런!
  if (shape.kind === "rect") {
    // This comparison appears to be unintentional because the types
    // '"circle" | "square"' and '"rect"' have no overlap.
  }
}

이제 도형이 원인지 사각형인지에 따라 올바른 로직을 적용하는 getArea 함수를 작성해 봅시다. 먼저 원을 처리해 보겠습니다.

function getArea(shape: Shape) {
  return Math.PI * shape.radius ** 2;
  // 'shape.radius' is possibly 'undefined'.
}

strictNullChecks 설정 하에서는 radius가 정의되지 않았을 수 있으므로 오류가 발생하며, 이는 적절합니다. 하지만 kind 속성에 대해 적절한 검사를 수행하면 어떻게 될까요?

function getArea(shape: Shape) {
  if (shape.kind === "circle") {
    return Math.PI * shape.radius ** 2;
    // 'shape.radius' is possibly 'undefined'.
  }
}

흠, TS는 여전히 무엇을 해야 할지 모릅니다. 타입 검사기보다 우리가 값에 대해 더 많이 아는 지점에 도달했습니다. radius가 확실히 존재한다고 말하기 위해 non-null 단언(shape.radius 뒤에 !)을 사용할 수 있습니다.

하지만 이는 이상적이지 않습니다. 코드를 옮기기 시작하면 이런 단언은 오류를 발생시키기 쉽습니다. 이 문제를 더 잘 해결할 수 있습니다. 이 Shape 인코딩의 문제는 타입 검사기가 kind 속성을 기반으로 radiussideLength가 존재하는지 알 방법이 없다는 것입니다. 우리가 아는 것을 타입 검사기에게 전달해야 합니다. 이를 염두에 두고 Shape를 다시 정의해 보겠습니다.

interface Circle {
  kind: "circle";
  radius: number;
}

interface Square {
  kind: "square";
  sideLength: number;
}

type Shape = Circle | Square;

여기서는 Shapekind 속성에 다른 값을 가진 두 개의 타입으로 명확히 분리했고, radiussideLength는 각 타입에서 필수 속성으로 선언했습니다. 이제 Shaperadius에 접근하려고 하면 어떻게 되는지 봅시다.

function getArea(shape: Shape) {
  return Math.PI * shape.radius ** 2;
  // Property 'radius' does not exist on type 'Shape'.
  //   Property 'radius' does not exist on type 'Square'.
}

shapeSquare일 수 있고, Square에는 radius가 정의되어 있지 않기 때문에 오류가 발생합니다!

하지만 kind 속성을 다시 확인하면 어떻게 될까요?

function getArea(shape: Shape) {
  if (shape.kind === "circle") {
    return Math.PI * shape.radius ** 2;
    // (parameter) shape: Circle
  }
}

오류가 사라졌습니다! 유니온의 모든 타입이 리터럴 타입을 가진 공통 속성을 포함할 때, TS는 이를 판별된 유니온(discriminated union)으로 간주하고 유니온의 멤버를 좁힐 수 있습니다. 이 경우, kind가 그 공통 속성(즉, Shape의 판별 속성(discriminant property))이었습니다. kind 속성이 "circle"인지 확인하자 kind 타입이 "circle"이 아닌 Shape의 모든 타입이 제거되었습니다. 그 결과 shapeCircle 타입으로 좁혀졌습니다.

switch 문에서도 동일한 검사가 작동합니다. 이제 성가신 ! non-null 단언 없이 완전한 getArea 함수를 작성할 수 있습니다.

function getArea(shape: Shape) {
  switch (shape.kind) {
    case "circle":
      return Math.PI * shape.radius ** 2;
      // (parameter) shape: Circle
    case "square":
      return shape.sideLength ** 2;
      // (parameter) shape: Square
  }
}

여기서 중요한 것은 Shape의 인코딩 방식이었습니다. CircleSquare가 특정 kind 필드를 가진 두 개의 개별 타입이라는 올바른 정보를 TS에 전달하는 것이 중요했습니다. 그렇게 함으로써 우리는 다른 JS 코드와 다르지 않게 보이면서도 타입-안전한 TS 코드를 작성할 수 있습니다.

판별된 유니온은 원과 사각형에 대해 이야기하는 것 이상으로 유용합니다. 네트워크를 통해 메시지를 보내거나(클라이언트/서버 통신) 상태 관리 프레임워크에서 변화를 인코딩하는 등 JS의 모든 종류의 메시징 스킴을 표현하는 데 좋습니다.

타입스크립트에서 never 타입은 존재해서는 안 되는 상태를 나타냅니다. 이 타입은 유니언 타입의 모든 가능성을 타입 좁히기(narrowing)를 통해 제거했을 때 사용됩니다. never 타입은 switch 문에서 모든 케이스가 처리되었는지 확인하는 철저성 검사(Exhaustiveness Checking)에 특히 유용합니다.

이 기법은 다음 두 가지 할당 규칙 덕분에 가능합니다.

  • never 타입은 다른 모든 타입에 할당할 수 있습니다.
  • 어떤 타입도 never에 할당할 수 없습니다 (never 자신은 제외).

철저성 검사를 구현하려면, 확인 대상 변수를 never 타입에 할당하는 default 케이스를 switch 문에 추가하면 됩니다. 만약 모든 케이스가 이미 처리되었다면, 변수의 타입은 never로 좁혀지므로 할당이 유효하게 됩니다. 예를 들어, 다음 함수는 Shape 유니언(CircleSquare)의 모든 멤버를 올바르게 처리합니다. default 블록에서 shape의 타입은 never로 좁혀졌으므로, _exhaustiveCheck에 할당해도 오류가 발생하지 않습니다.

type Shape = Circle | Square;
 
function getArea(shape: Shape) {
  switch (shape.kind) {
    case "circle":
      return Math.PI * shape.radius ** 2;
    case "square":
      return shape.sideLength ** 2;
    default:
      const _exhaustiveCheck: never = shape;
      return _exhaustiveCheck;
  }
}

하지만 Shape 유니언에 Triangle과 같은 새로운 타입을 추가하고 case를 추가하지 않으면, 타입스크립트는 오류를 발생시킵니다.

type Shape = Circle | Square | Triangle;
 
function getArea(shape: Shape) {
  switch (shape.kind) {
    case "circle":
      // ...
    case "square":
      // ...
    default:
      // 이 블록에서 'shape'의 타입은 'Triangle'입니다.
      const _exhaustiveCheck: never = shape;
      // Type 'Triangle' is not assignable to type 'never'.
      return _exhaustiveCheck;
  }
}

이 오류는 default 블록에서 shape 변수의 타입이 Triangle이므로 발생하며, Trianglenever 타입에 할당될 수 없습니다. 이 오류는 개발자가 triangle 케이스를 처리하도록 강제해, 함수가 모든 경우를 빠짐없이 다루도록(exhaustive) 보장합니다.

11. More on Functions

함수는 로컬 함수, 다른 모듈에서 가져온 함수, 또는 클래스의 메서드 등 모든 애플리케이션의 기본적인 구성 요소입니다. 또한 함수는 값(value)이기도 합니다.

Function Type Expressions

함수를 가장 간단하게 표현하는 방법은 함수 타입 표현식(function type expression)을 사용하는 것입니다. 이 타입들은 구문적으로 화살표 함수와 유사합니다.

function greeter(fn: (a: string) => void) {
  fn("Hello, World");
}

function printToConsole(s: string) {
  console.log(s);
}

greeter(printToConsole);

(a: string) => void 구문은 “문자열(string) 타입의 a라는 이름의 매개변수 하나를 가지며, 반환 값이 없는 함수”를 의미합니다. 함수 선언과 마찬가지로, 매개변수 타입이 명시되지 않으면 암묵적으로 any가 됩니다.

매개변수 이름은 필수라는 점에 유의해야 합니다. 함수 타입 (string) => void는 “any 타입의 string이라는 이름을 가진 매개변수를 갖는 함수”를 의미합니다.

물론, 타입 별칭(type alias)을 사용해 함수 타입의 이름을 지정할 수도 있습니다.

type GreetFunction = (a: string) => void;
function greeter(fn: GreetFunction) {
  // ...
}

Call Signatures

자바스크립트에서 함수는 호출이 가능할 뿐만 아니라 프로퍼티를 가질 수도 있습니다. 하지만 함수 타입 표현식 구문으로는 프로퍼티를 선언할 수 없습니다. 프로퍼티를 가지면서 호출도 가능한 것을 표현하고 싶다면, 객체 타입에 호출 시그니처(call signature)를 작성할 수 있습니다.

type DescribableFunction = {
  description: string;
  (someArg: number): boolean;
};

function doSomething(fn: DescribableFunction) {
  console.log(fn.description + " returned " + fn(6));
}

function myFunc(someArg: number) {
  return someArg > 3;
}
myFunc.description = "default description";

doSomething(myFunc);

함수 타입 표현식과 구문이 약간 다르다는 점에 유의하세요. 매개변수 목록과 반환 타입 사이에 => 대신 :를 사용합니다.

Construct Signatures

자바스크립트 함수는 new 연산자로도 호출될 수 있습니다. 타입스크립트는 보통 새로운 객체를 생성하기 때문에 이를 생성자(constructor)라고 부릅니다. 호출 시그니처 앞에 new 키워드를 추가해 생성자 시그니처(construct signature)를 작성할 수 있습니다.

type SomeConstructor = {
  new (s: string): SomeObject;
};
function fn(ctor: SomeConstructor) {
  return new ctor("hello");
}

자바스크립트의 Date 객체와 같은 일부 객체는 new를 사용하거나 사용하지 않고 호출할 수 있습니다. 호출 시그니처와 생성자 시그니처를 같은 타입 안에 자유롭게 조합할 수 있습니다.

interface CallOrConstruct {
  new (s: string): Date;
  (n?: number): string;
}

function fn(ctor: CallOrConstruct) {
    // `ctor`를 `string` 타입의 인자와 함께 new로 호출하는 것은 
    // `CallOrConstruct` 인터페이스의 두 번째 정의와 일치합니다.
    console.log(new ctor("10"));

    // (코드 이미지에는 명시적으로 없지만, 인터페이스 정의에 따라)
    // `ctor`를 `number` 타입의 인자와 함께 호출하는 것은
    // `CallOrConstruct` 인터페이스의 첫 번째 정의와 일치합니다.
}

fn(Date);

Generic Functions

입력의 타입이 출력의 타입과 관련이 있거나, 두 입력의 타입이 어떤 식으로든 관련된 함수를 작성하는 것은 흔한 일입니다. 배열의 첫 번째 요소를 반환하는 함수를 잠시 생각해 봅시다.

function firstElement(arr: any[]) {
  return arr[0];
}

이 함수는 제 역할을 하지만, 안타깝게도 반환 타입이 any가 됩니다. 함수가 배열 요소의 타입을 반환한다면 더 좋겠습니다.

타입스크립트에서는 두 값 사이의 관계를 표현하고 싶을 때 제네릭(generics)을 사용합니다. 함수 시그니처에 타입 매개변수(type parameter)를 선언해 이를 수행합니다.

function firstElement<Type>(arr: Type[]): Type | undefined {
  return arr[0];
}

이제 우리가 이 함수를 호출하면, 더 구체적인 타입이 나옵니다.

// s의 타입은 'string'
const s = firstElement(["a", "b", "c"]);
// n의 타입은 'number'
const n = firstElement([1, 2, 3]);
// u의 타입은 undefined
const u = firstElement([]);

Inference

이 예제에서 우리가 Type을 명시적으로 지정하지 않았다는 점에 주목하세요. 타입은 타입스크립트에 의해 자동으로 선택되어 추론되었습니다.

여러 개의 타입 매개변수를 사용할 수도 있습니다. 예를 들어, map 함수의 독립적인 버전은 다음과 같을 것입니다.

function map<Input, Output>(arr: Input[], func: (arg: Input) => Output): Output[] {
  return arr.map(func);
}

// 매개변수 'n'의 타입은 'string'
// 'parsed'의 타입은 'number[]'
const parsed = map(["1", "2", "3"], (n) => parseInt(n));

이 예제에서 타입스크립트는 주어진 문자열 배열로부터 Input 타입 매개변수의 타입을 추론하고, 함수 표현식 (n) => parseInt(n)의 반환 값(number)을 기반으로 Output 타입 매개변수도 추론할 수 있었습니다.

Constraints

우리는 지금까지 모든 종류의 값에 대해 동작할 수 있는 몇 가지 제네릭 함수를 작성했습니다. 때로는 두 값을 연관시키고 싶지만, 특정 값의 하위 집합에 대해서만 작업을 수행해야 할 때가 있습니다. 이 경우 제약 조건(constraint)을 사용해 타입 매개변수가 받을 수 있는 타입의 종류를 제한할 수 있습니다.

function longest<Type extends { length: number }>(a: Type, b: Type) {
  if (a.length >= b.length) {
    return a;
  } else {
    return b;
  }
}

// longerArray의 타입은 'number[]'
const longerArray = longest([1, 2], [1, 2, 3]);
// longerString의 타입은 'alice' | 'bob'
const longerString = longest("alice", "bob");
// 오류! 숫자는 'length' 프로퍼티를 가지지 않음
const notOK = longest(10, 100);
// Argument of type 'number' is not assignable to parameter of type '{ length: number; }'.

이 예제에는 몇 가지 흥미로운 점이 있습니다. 우리는 타입스크립트가 longest의 반환 타입을 추론하도록 했습니다. 반환 타입 추론은 제네릭 함수에서도 작동합니다.

Type{ length: number }로 제약했기 때문에, ab 매개변수의 length 프로퍼티에 접근할 수 있었습니다. 타입 제약 조건이 없었다면 length 프로퍼티가 없는 다른 타입의 값일 수 있으므로 해당 프로퍼티에 접근할 수 없었을 것입니다.

longerArraylongerString의 타입은 인자를 기반으로 추론되었습니다. 제네릭은 본질적으로 둘 이상의 값을 동일한 타입으로 연관 짓는 것임을 기억하세요!

마지막으로, 우리가 원했던 대로 longest(10, 100) 호출은 number 타입에 length 프로퍼티가 없기 때문에 거부됩니다.

Working with Constrained Values

제네릭 제약 조건을 사용할 때 흔히 발생하는 오류는 다음과 같습니다.

function minimumLength<Type extends { length: number }>(
  obj: Type,
  minimum: number
): Type {
  if (obj.length >= minimum) {
    return obj;
  } else {
    // Type '{ length: number; }' is not assignable to type 'Type'.
    // '{ length: number; }' is assignable to the constraint of type 'Type', 
    // but 'Type' could be instantiated with a different subtype of constraint '{ length: number; }'.
    return { length: minimum };
  }
}

이 함수는 괜찮아 보일 수 있습니다. Type{ length: number }로 제약되어 있고, 함수는 Type 또는 그 제약 조건과 일치하는 값을 반환합니다. 문제는 이 함수가 단지 제약 조건과 일치하는 어떤 객체가 아니라, 전달된 것과 동일한 종류의 객체를 반환하겠다고 약속한다는 점입니다. 이 코드가 허용된다면, 다음과 같이 명백히 작동하지 않을 코드를 작성할 수 있습니다.

// 'arr'는 값으로 { length: 6 }를 얻음
const arr = minimumLength([1, 2, 3], 6);
// 그리고 여기서 충돌 발생! 배열은 'slice' 메서드를 가지고 있지만,
// 반환된 객체는 그렇지 않기 때문!
console.log(arr.slice(0));

Specifying Type Arguments

타입스크립트는 보통 제네릭 호출에서 의도된 타입 인자를 추론할 수 있지만, 항상 그런 것은 아닙니다. 예를 들어, 두 배열을 합치는 함수를 작성했다고 가정해 봅시다.

function combine<Type>(arr1: Type[], arr2: Type[]): Type[] {
  return arr1.concat(arr2);
}

일치하지 않는 배열로 이 함수를 호출하면 보통 오류가 발생합니다. 이것이 의도한 것이라면, Type을 수동으로 명시할 수 있습니다.

const arr = combine<string | number>([1, 2, 3], ["hello"]);

12. Good Generic Function Writing Guidelines

제네릭 함수를 작성하는 것은 재미있고, 타입 매개변수에 심취하기 쉽습니다. 타입 매개변수가 너무 많거나 필요하지 않은 곳에 제약 조건을 사용하면 추론이 덜 성공적이 되어 함수 호출자를 좌절시킬 수 있습니다.

Push Type Parameters Down

비슷해 보이는 두 가지 함수 작성 방법이 있습니다.

function firstElement1<Type>(arr: Type[]) {
  return arr[0];
}

function firstElement2<Type extends any[]>(arr: Type) {
  return arr[0];
}

// a: number (좋음)
const a = firstElement1([1, 2, 3]);
// b: any (나쁨)
const b = firstElement2([1, 2, 3]);

언뜻 보기에는 동일해 보일 수 있지만, firstElement1이 이 함수를 작성하는 훨씬 좋은 방법입니다. firstElement1의 추론된 반환 타입은 Type이지만, firstElement2의 추론된 반환 타입은 any입니다. 이는 타입스크립트가 arr[0] 표현식을 “나중에” 호출 시점에서 요소를 확인하는 대신, 제약 조건 타입(any[])을 사용해 해석해야 하기 때문입니다.

가능하면 타입을 제약하는 대신 타입 매개변수 자체를 사용하세요.

Use Fewer Type Parameters

다음은 또 다른 비슷한 함수 쌍입니다.

// 좋은 예시
function filter1<Type>(arr: Type[], func: (arg: Type) => boolean): Type[] {
  return arr.filter(func);
}

// 나쁜 예시
function filter2<Type, Func extends (arg: Type) => boolean>(
  arr: Type[],
  func: Func
): Type[] {
  return arr.filter(func);
}

filter2에서는 두 값을 관련시키지 않는 타입 매개변수 Func를 만들었습니다. 이는 항상 위험 신호인데, 타입 인자를 지정하려는 호출자가 아무 이유 없이 추가적인 타입 인자를 수동으로 지정해야 함을 의미하기 때문입니다. Func는 함수를 더 읽고 이해하기 어렵게 만들 뿐 아무런 역할도 하지 않습니다.

항상 가능한 한 적은 수의 타입 매개변수를 사용하세요.

Type Parameters Should Appear Twice

때로는 함수가 제네릭일 필요가 없다는 사실을 잊곤 합니다.

function greet<Str extends string>(s: Str) {
  console.log("Hello, " + s);
}

greet("world");

우리는 더 간단한 버전을 쉽게 작성할 수 있습니다.

function greet(s: string) {
  console.log("Hello, " + s);
}

타입 매개변수가 오직 한 곳에서만 사용된다면, 그것이 정말로 필요한지 다시 한번 고려해야 합니다. 타입 매개변수는 여러 값의 타입을 서로 연관 짓기 위한 것입니다.

타입 매개변수가 한 위치에만 나타난다면, 그것이 정말로 필요한지 강력히 재고려하세요.

13. Optional Parameters

자바스크립트 함수는 종종 가변적인 수의 인자를 받습니다. 예를 들어, numbertoFixed 메서드는 선택적으로 자릿수(digit count)를 인자로 받습니다.

function f(n: number) {
  console.log(n.toFixed());    // 인자 0개
  console.log(n.toFixed(3));   // 인자 1개
}

타입스크립트에서는 매개변수에 ?를 붙여 선택적으로 만들 수 있습니다.

function f(x?: number) {
  // ...
}
f();    // OK
f(10);  // OK

매개변수가 number 타입으로 지정되었지만, x 매개변수는 실제로는 number | undefined 타입을 갖게 됩니다. 이는 자바스크립트에서 명시되지 않은 매개변수는 undefined 값을 갖기 때문입니다.

매개변수 기본값을 제공할 수도 있습니다.

function f(x = 10) {
  // ...
}

이제 f 함수 호출 시 x의 타입은 항상 number가 됩니다.

// 모두 OK
f();
f(10);
f(undefined);

Optional Parameters in Callbacks

선택적 매개변수와 함수 타입 표현식을 배운 후에 콜백을 호출하는 함수를 작성할 때 다음과 같은 실수를 하기 쉽습니다.

function myForEach(arr: any[], callback: (arg: any, index?: number) => void) {
  for (let i = 0; i < arr.length; i++) {
    callback(arr[i], i);
  }
}

사람들이 index?를 선택적 매개변수로 작성할 때 보통 의도하는 바는 다음 두 호출이 모두 유효하기를 원하는 것입니다.

myForEach([1, 2, 3], (a) => console.log(a));
myForEach([1, 2, 3], (a, i) => console.log(a, i));

하지만 이것이 실제로 의미하는 바는 callback이 하나의 인자만으로 호출될 수 있다는 것입니다. 다시 말해, 함수 정의는 구현이 다음과 같을 수 있다고 말하는 것입니다.

function myForEach(arr: any[], callback: (arg: any, index?: number) => void) {
  for (let i = 0; i < arr.length; i++) {
    // 오늘은 인덱스를 제공하고 싶지 않군
    callback(arr[i]);
  }
}

결과적으로 타입스크립트는 iundefined일 수 있다고 경고하게 됩니다.

myForEach([1, 2, 3], (a, i) => {
  // 'i' is possibly 'undefined'.
  console.log(i.toFixed());
});

자바스크립트에서는 매개변수보다 더 많은 인자로 함수를 호출하면, 추가된 인자는 단순히 무시됩니다. 타입스크립트도 동일하게 동작합니다. 더 적은 수의 매개변수를 가진 함수(같은 타입의)는 항상 더 많은 수의 매개변수를 가진 함수의 자리를 대신할 수 있습니다.

콜백 함수의 타입을 작성할 때, 해당 인자를 전달하지 않고 함수를 호출할 의도가 아니라면 절대 선택적 매개변수를 작성하지 마세요.

Function Overloads

일부 자바스크립트 함수는 다양한 개수와 타입의 인자로 호출될 수 있습니다. 예를 들어, 타임스탬프(인자 1개) 또는 월/일/연도(인자 3개)를 받아 Date 객체를 생성하는 함수를 작성할 수 있습니다.

타입스크립트에서는 오버로드 시그니처(overload signatures)를 작성해 여러 방식으로 호출될 수 있는 함수를 명시할 수 있습니다. 이를 위해 여러 개의 함수 시그니처(보통 2개 이상)를 작성한 후, 함수 본문을 작성합니다.

function makeDate(timestamp: number): Date;
function makeDate(m: number, d: number, y: number): Date;
function makeDate(mOrTimestamp: number, d?: number, y?: number): Date {
  if (d !== undefined && y !== undefined) {
    return new Date(y, mOrTimestamp, d);
  } else {
    return new Date(mOrTimestamp);
  }
}
const d1 = makeDate(12345678);
const d2 = makeDate(5, 5, 5);
const d3 = makeDate(1, 3); // No overload expects 2 arguments, but overloads do exist that expect either 1 or 3 arguments.

이 예제에서는 인자 1개를 받는 오버로드와 인자 3개를 받는 오버로드를 작성했습니다. 이 처음 두 시그니처를 오버로드 시그니처라고 합니다.

그런 다음, 호환되는 시그니처를 가진 함수 구현부를 작성했습니다. 함수는 구현 시그니처를 가지지만, 이 시그니처는 외부에서 직접 호출할 수 없습니다. 필수 매개변수 뒤에 두 개의 선택적 매개변수를 가진 함수를 작성했음에도 불구하고, 두 개의 매개변수로는 호출할 수 없습니다.

Overload Signatures and Implementation Signatures

이 부분은 흔히 혼동을 일으키는 원인입니다. 종종 사람들은 다음과 같은 코드를 작성하고 왜 오류가 나는지 이해하지 못합니다.

function fn(x: string): void;
function fn() {
  // ...
}
// 0개의 인자로 호출할 수 있을 것으로 예상
fn();
// Expected 1 arguments, but got 0.

다시 말하지만, 함수 본문을 작성하는 데 사용된 시그니처는 외부에서 “보여지지” 않습니다. 구현 시그니처는 외부에서 보이지 않습니다. 오버로드된 함수를 작성할 때는 항상 함수 구현부 위에 둘 이상의 시그니처가 있어야 합니다.

구현 시그니처는 또한 오버로드 시그니처와 호환되어야 합니다. 예를 들어, 다음 함수들은 구현 시그니처가 오버로드와 올바르게 일치하지 않기 때문에 오류가 발생합니다.

function fn(x: boolean): void;
// 인자 타입이 올바르지 않음
function fn(x: string): void;
// This overload signature is not compatible with its implementation signature.
function fn(x: boolean) {}
function fn(x: string): string;
// 반환 타입이 올바르지 않음
function fn(x: number): boolean;
// This overload signature is not compatible with its implementation signature.
function fn(x: string | number) {
  return "oops";
}

Writing Good Overloads

제네릭과 마찬가지로, 함수 오버로드를 사용할 때 따라야 할 몇 가지 가이드라인이 있습니다. 이 원칙들을 따르면 함수를 더 쉽게 호출하고, 이해하고, 구현할 수 있습니다.

문자열이나 배열의 길이를 반환하는 함수를 생각해 봅시다.

function len(s: string): number;
function len(arr: any[]): number;
function len(x: any) {
  return x.length;
}

이 함수는 괜찮습니다. 문자열이나 배열로 호출할 수 있습니다. 하지만 문자열일 수도 있고 배열일 수도 있는 값으로는 호출할 수 없습니다. 타입스크립트는 함수 호출을 단일 오버로드로만 해석할 수 있기 때문입니다.

len(""); // OK
len([0]); // OK
len(Math.random() > 0.5 ? "hello" : [0]);
// No overload matches this call.
// Overload 1 of 2, '(s: string): number', gave the following error.
//   Argument of type 'string | number[]' is not assignable to parameter of type 'string'.
// Overload 2 of 2, '(arr: any[]): number', gave the following error.
//   Argument of type 'string | number[]' is not assignable to parameter of type 'any[]'.

두 오버로드는 모두 같은 수의 인자와 같은 반환 타입을 가지므로, 대신 오버로드가 아닌 버전의 함수를 작성할 수 있습니다.

function len(x: any[] | string) {
  return x.length;
}

이것이 훨씬 낫습니다! 호출자는 어느 종류의 값이든 이 함수를 호출할 수 있으며, 추가로 우리는 올바른 구현 시그니처를 고민할 필요도 없습니다.

가능하다면 오버로드 대신 유니언 타입을 가진 매개변수를 항상 선호하세요.

Declaring this in a Function

타입스크립트는 코드 흐름 분석을 통해 함수 안에서 this가 무엇이어야 하는지 추론합니다. 예를 들면 다음과 같습니다.

const user = {
  id: 123,
  admin: false,
  becomeAdmin: function () {
    this.admin = true;
  },
};

타입스크립트는 user.becomeAdmin 함수의 this가 외부 객체 user라는 것을 이해합니다. 이것만으로도 많은 경우에 충분하지만, this가 어떤 객체를 나타내는지 더 세밀하게 제어해야 하는 경우도 많습니다. 자바스크립트 명세는 this라는 이름의 매개변수를 가질 수 없다고 명시하고 있으므로, 타입스크립트는 이 구문 공간을 사용해 함수 본문에서 this의 타입을 선언하도록 허용합니다.

interface User {
    id: number;
    admin: boolean;
}

interface DB {
  filterUsers(filter: (this: User) => boolean): User[];
}

const db = getDB();
const admins = db.filterUsers(function (this: User) {
  return this.admin;
});

이 패턴은 다른 객체가 여러분의 함수가 언제 호출될지를 제어하는 콜백 스타일 API에서 흔히 볼 수 있습니다. 이 동작을 위해서는 화살표 함수가 아닌 function 키워드를 사용해야 한다는 점에 유의하세요.

interface DB {
  filterUsers(filter: (this: User) => boolean): User[];
}

const db = getDB();
const admins = db.filterUsers(() => this.admin);
// The containing arrow function captures the global value of 'this'.
// Element implicitly has an 'any' type because type 'typeof globalThis' has no index signature.

14. Other Types to Know About

함수 타입을 다룰 때 자주 나타나는 몇 가지 추가적인 타입을 알아두면 좋습니다. 모든 타입과 마찬가지로 어디에서나 사용할 수 있지만, 이 타입들은 특히 함수 문맥에서 관련성이 높습니다.

void

void는 값을 반환하지 않는 함수의 반환 값을 나타냅니다. 함수에 return 문이 없거나 return 문에서 명시적인 값을 반환하지 않을 때마다 추론되는 타입입니다.

// 추론된 반환 타입은 void
function noop() {
  return;
}

자바스크립트에서 아무 값도 반환하지 않는 함수는 암묵적으로 undefined 값을 반환합니다. 하지만 타입스크립트에서 voidundefined는 같은 것이 아닙니다. (이 장의 끝에 더 자세한 내용이 있습니다.)

voidundefined와 다릅니다.

object

특수 타입 object는 원시 타입(primitive: string, number, bigint, boolean, symbol, null, 또는 undefined)이 아닌 모든 값을 가리킵니다. 이는 빈 객체 타입 {}와 다르며, 전역 타입 Object와도 다릅니다. Object는 아마 거의 사용하지 않게 될 것입니다.

objectObject가 아닙니다. 항상 소문자 object를 사용하세요!

자바스크립트에서 함수 값은 객체라는 점에 유의하세요. 함수는 프로퍼티를 가지고, 프로토타입 체인에 Object.prototype을 가지며, instanceof Object이고, Object.keys를 호출할 수 있는 등 객체의 특징을 모두 가집니다. 이러한 이유로 타입스크립트에서 함수 타입은 object로 간주됩니다.

unknown

unknown 타입은 모든 값을 나타냅니다. 이는 any 타입과 비슷하지만, unknown 값으로는 어떤 작업도 수행하는 것이 허용되지 않으므로 더 안전합니다.

function f1(a: any) {
  a.b(); // OK
}
function f2(a: unknown) {
  // 'a' is of type 'unknown'.
  a.b();
}

이는 함수 타입을 기술할 때 유용합니다. 함수 본문에서 any 값에 대한 작업을 허용하지 않으면서 모든 값을 받는 함수를 표현할 수 있기 때문입니다. 반대로, unknown 타입의 값을 반환하는 함수를 기술할 수도 있습니다.

function safeParse(s: string): unknown {
  return JSON.parse(s);
}

// 'obj'를 다룰 때 조심해야 합니다!
const obj = safeParse(someRandomString);

never

어떤 함수는 절대 값을 반환하지 않습니다.

function fail(msg: string): never {
  throw new Error(msg);
}

never 타입은 절대 관찰되지 않는 값을 나타냅니다. 반환 타입에서 이는 함수가 예외를 던지거나 프로그램의 실행을 종료함을 의미합니다.

never는 또한 타입스크립트가 유니언(union)에 남은 것이 없다고 판단할 때 나타납니다.

function fn(x: string | number) {
  if (typeof x === "string") {
    // do something
  } else if (typeof x === "number") {
    // do something else
  } else {
    x; // x의 타입은 'never'
  }
}

Function

전역 타입 Function은 자바스크립트의 모든 함수 값에 존재하는 bind, call, apply와 같은 프로퍼티를 기술합니다. 또한 Function 타입의 값은 항상 호출될 수 있으며, 이 호출은 any를 반환한다는 특별한 속성이 있습니다.

function doSomething(f: Function) {
  return f(1, 2, 3);
}

이는 타입이 지정되지 않은 함수 호출이며, 안전하지 않은 any 반환 타입 때문에 일반적으로 피하는 것이 가장 좋습니다. 임의의 함수를 받되 호출할 의도가 없다면, () => void 타입이 일반적으로 더 안전합니다.

15. Rest Parameters and Arguments

Rest Parameters

선택적 매개변수나 오버로드를 사용해 다양한 고정된 수의 인자를 받는 함수를 만드는 것 외에도, 나머지 매개변수(rest parameters)를 사용해 무한한 수의 인자를 받는 함수를 정의할 수 있습니다.

나머지 매개변수는 다른 모든 매개변수 뒤에 오며, ... 구문을 사용합니다.

function multiply(n: number, ...m: number[]) {
  return m.map((x) => n * x);
}
// 'a'는 [10, 20, 30, 40] 값을 얻음
const a = multiply(10, 1, 2, 3, 4);

타입스크립트에서 이 매개변수에 대한 타입 어노테이션은 암묵적으로 any 대신 any[]가 되며, 주어진 타입 어노테이션은 반드시 Array<T> 또는 T[] 형태여야 합니다.

Rest Arguments

반대로, 전개 구문(spread syntax)을 사용해 이터러블 객체(예: 배열)로부터 가변적인 수의 인수를 제공할 수 있습니다. 예를 들어, 배열의 push 메서드는 임의의 개수의 인수를 받습니다.

const arr1 = [1, 2, 3];
const arr2 = [4, 5, 6];
arr1.push(...arr2);

일반적으로 타입스크립트는 배열이 불변(immutable)이라고 가정하지 않습니다. 이는 다음과 같은 놀라운 동작으로 이어질 수 있습니다.

// 추론된 타입은 number[]이지, 특별히 두 개의 숫자가 아님
const args = [8, 5];
// "0개 이상의 숫자를 가진 배열"
const angle = Math.atan2(...args);
// A spread argument must either have a tuple type or be passed to a rest parameter.

이 상황에 대한 최상의 해결책은 코드에 따라 다르지만, 일반적으로 const 단언(assertion)이 가장 간단한 해결책입니다.

// 길이가 2인 튜플로 추론됨
const args = [8, 5] as const;
// OK
const angle = Math.atan2(...args);

오래된 런타임을 대상으로 할 때 나머지 인수를 사용하려면 downlevelIteration 옵션을 켜야 할 수 있습니다.

Parameter Destructuring

매개변수 구조 분해(parameter destructuring)를 사용해 편리하게 인자로 전달된 객체의 프로퍼티를 개별 변수로 분해할 수 있습니다.

function sum({ a, b, c }) {
  console.log(a + b + c);
}
sum({ a: 10, b: 3, c: 9 });

객체에 대한 타입 어노테이션은 구조 분해 구문 뒤에 작성합니다.

function sum({ a, b, c }: { a: number; b: number; c: number }) {
  console.log(a + b + c);
}

이는 약간 장황해 보일 수 있지만, 여기서 이름 있는 타입을 사용할 수도 있습니다.

// 이전 예제와 동일
type ABC = { a: number; b: number; c: number };
function sum({ a, b, c }: ABC) {
  console.log(a + b + c);
}

Assignability of Functions

Return Type void

함수의 void 반환 타입은 다소 특이하지만 예상된 동작을 만들어낼 수 있습니다. void 반환 타입을 갖는 문맥적 타이핑(contextual typing)은 함수가 무언가를 반환하지 않도록 강제하지 않습니다. 다른 말로 하면, void 반환 타입을 가진 문맥적 함수 타입(예: type voidFunc = () => void;)을 구현할 때, 다른 어떤 값이든 반환할 수 있지만 그 값은 무시됩니다. 따라서 () => void 타입에 대한 다음 구현들은 모두 유효합니다.

type voidFunc = () => void;

const f1: voidFunc = () => {
  return true;
};

const f2: voidFunc = () => true;

const f3: voidFunc = function () {
  return true;
};

그리고 이 함수들 중 하나의 반환 값이 다른 변수에 할당될 때, 그 변수는 void 타입을 유지합니다.

const v1 = f1();
const v2 = f2();
const v3 = f3();

이 동작은 Array.prototype.push가 숫자를 반환하고 Array.prototype.forEach 메서드는 void 반환 타입을 가진 함수를 기대함에도 불구하고 다음 코드가 유효하도록 하기 위해 존재합니다.

const src = [1, 2, 3];
const dst = [0];

src.forEach((el) => dst.push(el));

알아두어야 할 또 다른 특별한 경우가 하나 있습니다. 리터럴 함수 정의가 void 반환 타입을 가질 때, 그 함수는 아무것도 반환해서는 안 됩니다.

function f2(): void {
  // @ts-expect-error
  return true;
}

const f3 = function (): void {
  // @ts-expect-error
  return true;
};

16. Object Types

자바스크립트에서 데이터를 그룹화하고 전달하는 기본적인 방법은 객체(object)를 사용하는 것입니다. 타입스크립트에서는 이러한 객체를 객체 타입(object types)으로 표현합니다.

지금까지 살펴본 것처럼, 객체 타입은 익명(anonymous)으로 사용할 수 있습니다.

function greet(person: { name: string; age: number }) {
  return "Hello " + person.name;
}

또는 인터페이스(interface)를 사용하여 이름을 붙일 수도 있습니다.

interface Person {
  name: string;
  age: number;
}

function greet(person: Person) {
  return "Hello " + person.name;
}

혹은 타입 별칭(type alias)을 사용할 수도 있습니다.

type Person = {
  name: string;
  age: number;
};

function greet(person: Person) {
  return "Hello " + person.name;
}

위 세 가지 예시 모두, name(문자열이어야 함)과 age(숫자여야 함) 속성을 포함하는 객체를 받는 함수를 작성한 것입니다.

Property Modifiers

객체 타입의 각 속성은 타입, 속성의 선택적 여부, 그리고 속성에 쓰기 가능 여부 등 몇 가지를 지정할 수 있습니다.

Optional Properties

객체를 다루다 보면, 특정 속성이 설정되어 있을 수도 있고 그렇지 않을 수도 있는 경우를 자주 접하게 됩니다. 이런 경우, 속성 이름 뒤에 물음표(?)를 붙여 해당 속성을 선택적으로 만들 수 있습니다.

type Shape = { /* ... */ };

interface PaintOptions {
  shape: Shape;
  xPos?: number;
  yPos?: number;
}

function paintShape(opts: PaintOptions) {
  // ...
}

const shape = getShape();
paintShape({ shape });
paintShape({ shape, xPos: 100 });
paintShape({ shape, yPos: 100 });
paintShape({ shape, xPos: 100, yPos: 100 });

이 예제에서 xPosyPos는 모두 선택적(optional)으로 간주됩니다. 둘 중 하나만 제공하거나 둘 다 제공할 수 있으므로, 위 paintShape 함수 호출은 모두 유효합니다. 선택적 속성이란, 만약 속성이 존재한다면 특정 타입을 가져야 한다는 것을 의미합니다.

또한 이 속성들을 읽을 수도 있습니다. 하지만 strictNullChecks 옵션이 활성화된 상태에서는, 타입스크립트가 해당 속성들이 잠재적으로 undefined일 수 있다고 알려줍니다.

function paintShape(opts: PaintOptions) {
  let xPos = opts.xPos; // (property) PaintOptions.xPos?: number | undefined
  let yPos = opts.yPos; // (property) PaintOptions.yPos?: number | undefined
  // ...
}

자바스크립트에서는 속성이 한 번도 설정되지 않았더라도 접근할 수 있으며, 이 경우 undefined 값을 얻습니다. 우리는 undefined인지 확인해 특별히 처리할 수 있습니다.

function paintShape(opts: PaintOptions) {
  let xPos = opts.xPos === undefined ? 0 : opts.xPos;
  let yPos = opts.yPos === undefined ? 0 : opts.yPos;
  // ...
}

지정되지 않은 값에 대해 기본값을 설정하는 이 패턴은 매우 흔하기 때문에, 자바스크립트는 이를 지원하는 구문을 가지고 있습니다.

function paintShape({ shape, xPos = 0, yPos = 0 }: PaintOptions) {
  console.log("x coordinate at", xPos); // (parameter) xPos: number
  console.log("y coordinate at", yPos); // (parameter) yPos: number
  // ...
}

여기서는 paintShape 함수의 매개변수에 구조 분해(destructuring) 패턴을 사용하고 xPosyPos에 기본값을 제공했습니다. 이제 paintShape 함수의 본문 내에서는 xPosyPos가 항상 존재하지만, 함수 호출자에게는 선택 사항이 됩니다.

참고로, 현재 구조 분해 패턴 안에는 타입 애너테이션(type annotation)을 배치할 방법이 없습니다. 이는 다음 구문이 자바스크립트에서 이미 다른 의미를 가지기 때문입니다.

function draw({ shape: Shape, xPos: number = 100 /* ... */ }) {
  render(shape); // 'shape' 이름을 찾을 수 없습니다. 'Shape'을 의도하셨나요?
  render(xPos);  // 'xPos' 이름을 찾을 수 없습니다.
}

readonly Properties

타입스크립트에서는 속성을 readonly로 표시할 수도 있습니다. 이는 런타임 동작을 변경하지는 않지만, readonly로 표시된 속성은 타입 검사 중에 값을 쓸 수 없게 됩니다.

interface SomeType {
  readonly prop: string;
}

function doSomething(obj: SomeType) {
  // 'obj.prop'에서 값을 읽을 수 있습니다.
  console.log(`prop has the value '${obj.prop}'.`);

  // 하지만 재할당은 할 수 없습니다.
  obj.prop = "hello"; // 'prop'은 읽기 전용 속성이므로 할당할 수 없습니다.
}

readonly 변경자를 사용한다고 해서 값이 완전히 불변(immutable)하다는 것을 의미하지는 않습니다. 즉, 내부 콘텐츠는 변경될 수 있습니다. 단지 속성 자체에 재할당할 수 없다는 의미입니다.

interface Home {
  readonly resident: { name: string; age: number };
}

function visitForBirthday(home: Home) {
  // 'home.resident'의 속성을 읽고 수정할 수 있습니다.
  console.log(`Happy birthday ${home.resident.name}!`);
  home.resident.age++;
}

function evict(home: Home) {
  // 하지만 'home.resident' 프로퍼티 자체에 쓸 수는 없습니다.
  home.resident = { // 'resident'은 읽기 전용 속성이므로 할당할 수 없습니다.
    name: "Victor the Evictor",
    age: 42,
  };
}

readonly가 무엇을 의미하는지에 대한 기대를 관리하는 것이 중요합니다. 개발 중에 타입스크립트에게 객체를 어떻게 사용해야 하는지에 대한 의도를 알리는 데 유용합니다.

타입스크립트는 두 타입의 호환성을 검사할 때 속성이 readonly인지 여부를 고려하지 않으므로, readonly 속성도 별칭(aliasing)을 통해 변경될 수 있습니다.

interface Person {
  name: string;
  age: number;
}

interface ReadonlyPerson {
  readonly name: string;
  readonly age: number;
}

let writablePerson: Person = {
  name: "Person McPersonface",
  age: 42,
};

// 동작합니다.
let readonlyPerson: ReadonlyPerson = writablePerson;

console.log(readonlyPerson.age); // '42'를 출력합니다.
writablePerson.age++;
console.log(readonlyPerson.age); // '43'을 출력합니다.

Index Signatures

가끔은 객체 타입의 속성 이름을 미리 알지 못하지만, 값의 형태는 알고 있는 경우가 있습니다. 이런 경우 인덱스 시그니처(index signature)를 사용해 가능한 값들의 타입을 기술할 수 있습니다.

interface StringArray {
  [index: number]: string;
}

const myArray: StringArray = getStringArray();
const secondItem = myArray[1]; // const secondItem: string

위 예제에서 StringArray 인터페이스는 인덱스 시그니처를 가집니다. 이 인덱스 시그니처는 StringArray 타입의 객체를 number 타입으로 인덱싱하면 string을 반환한다고 명시합니다.

인덱스 시그니처의 속성으로는 string, number, symbol, 템플릿 리터럴 패턴, 그리고 이들의 유니언 타입만 허용됩니다.

문자열 인덱스 시그니처는 “사전(dictionary)” 패턴을 기술하는 강력한 방법이지만, 모든 속성이 시그니처의 반환 타입과 일치하도록 강제합니다. 문자열 인덱스는 obj.propertyobj["property"]로도 접근 가능하다고 선언하기 때문입니다. 다음 예제에서 name의 타입은 문자열 인덱스의 타입과 일치하지 않으므로 타입 검사기가 오류를 발생시킵니다.

interface NumberDictionary {
  [index: string]: number;
  length: number;    // ok
  name: string;      // 오류: 'name' 속성의 'string' 타입은
                     // 문자열 인덱스 타입 'number'에 할당할 수 없습니다.
}

하지만 인덱스 시그니처가 유니언 타입을 포함한다면 다른 타입의 속성도 가질 수 있습니다.

interface NumberOrStringDictionary {
  [index: string]: number | string;
  length: number; // ok, length는 number 타입입니다.
  name: string;   // ok, name은 string 타입입니다.
}

마지막으로, 인덱스 시그니처를 readonly로 만들어 인덱스에 대한 할당을 막을 수 있습니다.

interface ReadonlyStringArray {
  readonly [index: number]: string;
}

let myArray: ReadonlyStringArray = getReadOnlyStringArray();
myArray[2] = "Mallory"; // 오류: 'ReadonlyStringArray' 타입의 인덱스 시그니처는
                        // 읽기만 허용합니다.

Excess Property Checks

객체에 타입을 할당하는 위치와 방식은 타입 시스템에 영향을 미칠 수 있습니다. 이에 대한 핵심 예제 중 하나가 초과 속성 검사(excess property checking)입니다. 이 검사는 객체가 생성되어 객체 타입에 할당될 때 해당 객체를 더 철저하게 검증합니다.

interface SquareConfig {
  color?: string;
  width?: number;
}

function createSquare(config: SquareConfig): { color: string; area: number } {
  return {
    color: config.color || "red",
    area: config.width ? config.width * config.width : 20,
  };
}

let mySquare = createSquare({ colour: "red", width: 100 });
// 오류: 객체 리터럴은 알려진 속성만 지정할 수 있지만, 'SquareConfig' 타입에
// 'colour'가 존재하지 않습니다. 'color'를 쓰려고 했나요?

createSquare에 전달된 인자의 철자가 color가 아닌 colour로 잘못 쓰인 것을 주목하세요. 일반 자바스크립트에서는 이런 종류의 오류가 조용히 실패로 처리됩니다. width 속성은 호환되고, color 속성은 존재하지 않으며, 추가된 colour 속성은 중요하지 않으므로 이 프로그램이 올바르게 타입 지정되었다고 주장할 수도 있습니다.

하지만 타입스크립트는 이 코드에 버그가 있을 가능성이 높다고 판단합니다. 객체 리터럴은 특별한 취급을 받으며, 다른 변수에 할당되거나 인수로 전달될 때 초과 속성 검사를 받습니다. 만약 객체 리터럴이 “대상 타입”에 없는 속성을 가지고 있다면 오류가 발생합니다.

이러한 검사를 피하는 방법은 사실 매우 간단합니다. 가장 쉬운 방법은 타입 단언(type assertion)을 사용하는 것입니다.

let mySquare = createSquare({ width: 100, opacity: 0.5 } as SquareConfig);

그러나 더 나은 접근 방식은, 객체가 어떤 특별한 방식으로 사용되는 추가 속성을 가질 수 있다고 확신한다면 문자열 인덱스 시그니처를 추가하는 것일 수 있습니다. 만약 SquareConfig가 위에서 정의된 타입의 colorwidth 속성을 가지면서 다른 여러 속성도 가질 수 있다면, 다음과 같이 정의할 수 있습니다.

interface SquareConfig {
  color?: string;
  width?: number;
  [propName: string]: any;
}

여기서 SquareConfig는 어떤 수의 속성이든 가질 수 있으며, 그것이 colorwidth가 아닌 한 타입은 중요하지 않다고 말하는 것입니다.

이러한 검사를 피하는 마지막 방법은 조금 놀라울 수 있는데, 객체를 다른 변수에 할당하는 것입니다. squareOptions를 할당하는 것은 초과 속성 검사를 거치지 않으므로 컴파일러는 오류를 발생시키지 않습니다.

let squareOptions = { colour: "red", width: 100 };
let mySquare = createSquare(squareOptions);

위 해결 방법은 squareOptionsSquareConfig 사이에 공통 속성이 하나 이상 있는 한 작동합니다. 이 예제에서는 width 속성이 공통 속성이었습니다. 하지만 변수가 공통 객체 속성을 전혀 가지고 있지 않으면 실패합니다.

대부분의 초과 속성 오류는 실제 버그인 경우가 많다는 점을 명심하세요. 옵션 백(option bags)과 같은 것에서 초과 속성 검사 문제를 겪고 있다면, 타입 선언 일부를 수정해야 할 수도 있습니다.

17. Extending Types

기존 타입을 확장하여 새로운 타입을 만드는 것은 매우 일반적입니다. 예를 들어, BasicAddressAddressWithUnit 인터페이스가 있습니다.

interface BasicAddress {
  name?: string;
  street: string;
  city: string;
  country: string;
  postalCode: string;
}

interface AddressWithUnit extends BasicAddress {
  unit: string;
}

인터페이스의 extends 키워드를 사용하면 다른 명명된 타입의 멤버를 효과적으로 복사하고 원하는 새 멤버를 추가할 수 있습니다. 이를 통해 작성해야 하는 타입 선언 상용구(boilerplate)의 양을 줄이고, 여러 다른 선언에서 동일한 속성이 서로 관련이 있음을 나타낼 수 있습니다.

인터페이스는 여러 타입을 확장할 수도 있습니다.

interface Colorful {
  color: string;
}

interface Circle {
  radius: number;
}

interface ColorfulCircle extends Colorful, Circle {}

const cc: ColorfulCircle = {
  color: "red",
  radius: 42,
};

Intersection Types

인터페이스는 extends를 통해 타입을 조합할 수 있게 해줬습니다. 타입스크립트는 기존 객체 타입을 결합하는 데 주로 사용되는 교차 타입(intersection type)이라는 또 다른 구조를 제공합니다.

교차 타입은 & 연산자를 사용해 정의됩니다.

interface Colorful {
  color: string;
}

interface Circle {
  radius: number;
}

type ColorfulCircle = Colorful & Circle;

여기서 ColorfulCircle을 교차시켜 ColorfulCircle의 모든 멤버를 가진 새로운 타입을 만들었습니다.

function draw(circle: Colorful & Circle) {
  console.log(`Color was ${circle.color}`);
  console.log(`Radius was ${circle.radius}`);
}

// okay
draw({ color: "blue", radius: 42 });

// oops
draw({ color: "red", raidus: 42 });
// 오류: ... 'raidus'는 'Colorful & Circle' 타입에 존재하지 않습니다.
// 'radius'를 쓰려고 했나요?

Interface Extension and Intersection Type Comparison

두 가지 타입 조합 방법은 비슷해 보이지만 미묘한 차이가 있습니다. 둘 사이의 주된 차이점은 충돌 처리 방식이며, 이 차이점이 보통 인터페이스와 교차 타입의 타입 별칭 중 하나를 선택하는 주된 이유가 됩니다.

  • 인터페이스: 동일한 이름으로 인터페이스를 여러 번 선언하면, 타입스크립트는 속성들이 호환될 경우 이들을 병합하려고 시도합니다. 만약 속성이 호환되지 않으면(즉, 속성 이름은 같지만 타입이 다르면) 타입스크립트는 오류를 발생시킵니다.

  • 교차 타입: 서로 다른 타입을 가진 속성들이 자동으로 병합됩니다. 나중에 이 타입을 사용하면, 타입스크립트는 해당 속성이 두 타입을 동시에 만족하기를 기대하며, 이는 예기치 않은 결과를 낳을 수 있습니다. 예를 들어, name 속성이 string이면서 동시에 number여야 하므로, 결과적으로 never 타입이 됩니다.

// 인터페이스 예시 (오류 발생)
interface Box {
    name: string;
}
interface Box {
    name: number; // 오류: 후속 선언에서 'name' 속성의 타입이 달라야 합니다.
}

// 교차 타입 예시 (never 타입이 됨)
interface Person1 {
  name: string;
}

interface Person2 {
  name: number;
}

type Staff = Person1 & Person2;

declare const staffer: Staff;
staffer.name; // (property) name: never

Generic Object Types

어떤 값이든 담을 수 있는 Box 타입을 상상해 봅시다. 문자열, 숫자, 기린 등 무엇이든 담을 수 있습니다. contents 속성을 any로 지정하면 동작은 하지만, 타입 안전성이 떨어집니다. unknown을 사용하면 더 안전하지만, 값을 사용하기 전에 타입 확인이나 타입 단언이 필요합니다.

interface Box {
  contents: unknown;
}

let x: Box = {
  contents: "hello world",
};

// x.contents를 확인해야 합니다.
if (typeof x.contents === "string") {
  console.log(x.contents.toLowerCase());
}

// 또는 타입 단언을 사용할 수 있습니다.
console.log((x.contents as string).toLowerCase());

한 가지 타입-안전한 접근 방식은 모든 콘텐츠 타입에 대해 다른 Box 타입을 만드는 것입니다.

interface NumberBox {
  contents: number;
}

interface StringBox {
  contents: string;
}

interface BooleanBox {
  contents: boolean;
}

하지만 이는 많은 상용구 코드를 만듭니다. 게다가 나중에 새로운 타입을 추가해야 할 때마다 새로운 Box 타입과 관련 함수 오버로드를 만들어야 합니다. 이는 비효율적입니다.

대신, 타입 매개변수(type parameter)를 선언하는 제네릭(generic) Box 타입을 만들 수 있습니다.

interface Box<Type> {
  contents: Type;
}

이것은 “TypeBoxcontentsType을 가지는 것”이라고 읽을 수 있습니다. 나중에 Box를 참조할 때, Type 자리에 타입 인수(type argument)를 제공해야 합니다.

let box: Box<string>;

Box를 실제 타입의 템플릿으로 생각할 수 있으며, Type은 다른 타입으로 대체될 플레이스홀더입니다. 타입스크립트가 Box<string>을 보면, Box<Type>의 모든 Type 인스턴스를 string으로 바꾸어 { contents: string }과 같은 타입을 다루게 됩니다. 즉, Box<string>은 이전에 만들었던 StringBox와 동일하게 작동합니다.

interface Box<Type> {
  contents: Type;
}

let boxA: Box<string> = { contents: "hello" };
boxA.contents; // (property) contents: string

BoxType이 어떤 것이든 대체될 수 있으므로 재사용이 가능합니다. 이는 새로운 타입을 위한 box가 필요할 때 새로운 Box 타입을 선언할 필요가 없다는 의미입니다. 또한, 함수 오버로드 대신 제네릭 함수를 사용해 코드를 간결하게 만들 수 있습니다.

function setContents<Type>(box: Box<Type>, newContents: Type) {
  box.contents = newContents;
}

타입 별칭(type aliases) 또한 제네릭이 될 수 있다는 점은 주목할 가치가 있습니다.

18. The Array Type

제네릭 객체 타입은 종종 포함하는 요소의 타입과 독립적으로 작동하는 컨테이너 타입입니다. 우리가 이 핸드북에서 계속 사용해온 Array 타입이 바로 그런 타입입니다. number[]string[] 같은 타입을 작성할 때, 이는 사실 Array<number>Array<string>의 단축 표기법입니다.

function doSomething(value: Array<string>) {
  // ...
}

let myArray: string[] = ["hello", "world"];

// 위 두 코드는 동일하게 동작합니다.
doSomething(myArray);

Box 타입과 마찬가지로 Array 자체도 제네릭 타입입니다.

interface Array<Type> {
  /**
   * 배열의 길이를 가져오거나 설정합니다.
   */
  length: number;

  /**
   * 배열에서 마지막 요소를 제거하고 그 요소를 반환합니다.
   */
  pop(): Type | undefined;

  /**
   * 배열에 새 요소를 추가하고 배열의 새 길이를 반환합니다.
   */
  push(...items: Type[]): number;

  // ...
}

현대 자바스크립트는 Map<K, V>, Set<T>, Promise<T>와 같이 제네릭인 다른 데이터 구조도 제공합니다.

The ReadonlyArray Type

ReadonlyArray는 변경해서는 안 되는 배열을 설명하는 특별한 타입입니다.

function doStuff(values: ReadonlyArray<string>) {
  // 'values'에서 읽을 수는 있지만...
  const copy = values.slice();
  console.log(`The first value is ${values[0]}`);

  // ...'values'를 변경할 수는 없습니다.
  values.push("hello!"); // 오류: 'push' 속성은 'ReadonlyArray<string>' 타입에
                        // 존재하지 않습니다.
}

속성의 readonly 변경자와 마찬가지로, 이는 주로 개발자의 의도를 나타내는 도구입니다. ReadonlyArray를 반환하는 함수는 우리가 그 내용을 변경해서는 안 된다는 것을 알려주고, ReadonlyArray를 사용하는 함수는 내용이 변경될 걱정 없이 어떤 배열이든 전달할 수 있음을 알려줍니다.

Array와 달리 사용할 수 있는 ReadonlyArray 생성자는 없습니다. 대신 일반 ArrayReadonlyArray에 할당할 수 있습니다.

const roArray: ReadonlyArray<string> = ["red", "green", "blue"];

타입스크립트는 Array<Type>에 대한 단축 문법 Type[]을 제공하는 것처럼, ReadonlyArray<Type>에 대한 단축 문법으로 readonly Type[]을 제공합니다.

한 가지 마지막으로 주목할 점은, 일반 ArrayReadonlyArray 사이의 할당성은 양방향이 아니라는 것입니다.

let x: readonly string[] = [];
let y: string[] = [];

x = y; // OK
y = x; // 오류: 'readonly string[]' 타입은 'readonly'이므로 변경 가능한
       // 'string[]' 타입에 할당할 수 없습니다.

19. Tuple Types

튜플 타입(tuple type)은 정확히 몇 개의 요소를 포함하는지, 그리고 특정 위치에 어떤 타입이 있는지를 아는 또 다른 종류의 Array 타입입니다.

type StringNumberPair = [string, number];

여기서 StringNumberPairstringnumber의 튜플 타입입니다. 타입 시스템에게 StringNumberPair는 0번 인덱스에 string을, 1번 인덱스에 number를 포함하는 배열을 의미합니다.

function doSomething(pair: [string, number]) {
  const a = pair[0]; // const a: string
  const b = pair[1]; // const b: number
  // ...
}

doSomething(["hello", 42]);

요소의 개수를 넘어 인덱싱하려고 하면 오류가 발생합니다. 튜플은 자바스크립트의 배열 구조 분해를 사용해 분해할 수도 있습니다.

function doSomething(stringHash: [string, number]) {
  const [inputString, hash] = stringHash;
  console.log(inputString); // const inputString: string
  console.log(hash);      // const hash: number
}

튜플은 각 요소의 의미가 “명백한” 관례 기반의 API에서 유용합니다. 또한 선택적(?) 및 나머지(...) 요소를 가질 수 있어, 함수의 매개변수 목록과 튜플을 일치시킬 수 있습니다.

  • type Either2dOr3d = [number, number, number?];
  • type StringNumberBooleans = [string, number, ...boolean[]];

readonly Tuple Types

마지막으로 튜플 타입은 readonly 변형을 가지며, 배열 단축 문법과 마찬가지로 앞에 readonly 변경자를 붙여 지정할 수 있습니다.

function doSomething(pair: readonly [string, number]) {
  pair[0] = "hello!"; // 오류: '0'에 할당할 수 없습니다. 읽기 전용 속성이기 때문입니다.
}

대부분의 코드에서 튜플은 생성된 후 수정되지 않는 경향이 있으므로, 가능한 경우 타입을 readonly 튜플로 주석 처리하는 것이 좋은 기본값입니다. 이는 const 단언이 있는 배열 리터럴이 readonly 튜플 타입으로 추론된다는 점에서도 중요합니다.

let point = [3, 4] as const;

function distanceFromOrigin([x, y]: [number, number]) {
  return Math.sqrt(x ** 2 + y ** 2);
}

distanceFromOrigin(point);
// 오류: 'readonly [3, 4]' 타입의 인수는 '[number, number]' 타입의 매개변수에
// 할당할 수 없습니다.

여기서 distanceFromOrigin은 요소를 수정하지 않지만, 변경 가능한 튜플을 기대합니다. point의 타입은 readonly [3, 4]로 추론되었으므로, point의 요소가 변경되지 않을 것이라고 보장할 수 없는 [number, number] 타입과는 호환되지 않습니다.

20. Creating Types from Types

TypeScript의 타입 시스템은 다른 타입의 관점에서 타입을 표현할 수 있기 때문에 매우 강력합니다.

이 아이디어의 가장 간단한 형태는 제네릭(Generics)입니다. 또한, 우리는 사용할 수 있는 다양한 타입 연산자들을 가집니다. 이미 가지고 있는 값의 관점에서 타입을 표현하는 것 또한 가능합니다.

다양한 타입 연산자들을 결합함으로써, 우리는 복잡한 연산과 값들을 간결하고 유지보수하기 쉬운 방식으로 표현할 수 있습니다. 이 섹션에서는 기존 타입이나 값의 관점에서 새로운 타입을 표현하는 방법들을 다룹니다.

  • 제네릭 (Generics) - 파라미터를 받는 타입
  • keyof 타입 연산자 (Keyof Type Operator) - keyof 연산자를 사용하여 새로운 타입을 생성
  • typeof 타입 연산자 (Typeof Type Operator) - typeof 연산자를 사용하여 새로운 타입을 생성
  • 인덱싱된 접근 타입 (Indexed Access Types) - Type['a'] 구문을 사용하여 타입의 서브셋에 접근
  • 조건부 타입 (Conditional Types) - 타입 시스템에서 if 문처럼 동작하는 타입
  • 맵드 타입 (Mapped Types) - 기존 타입의 각 속성을 매핑하여 타입을 생성
  • 템플릿 리터럴 타입 (Template Literal Types) - 템플릿 리터럴 문자열을 통해 속성을 변경하는 맵드 타입

Generics

소프트웨어 엔지니어링의 주요 부분 중 하나는 잘 정의되고 일관된 API를 가질 뿐만 아니라 재사용이 가능한 컴포넌트를 구축하는 것입니다. 현재의 데이터는 물론 미래의 데이터까지 처리할 수 있는 컴포넌트는 대규모 소프트웨어 시스템을 구축하는 데 가장 유연한 기능을 제공합니다.

C#이나 Java와 같은 언어에서 재사용 가능한 컴포넌트를 만드는 주요 도구 중 하나는 제네릭(generics)입니다. 즉, 단일 타입이 아닌 다양한 타입에 대해 작동할 수 있는 컴포넌트를 생성하는 기능입니다. 이를 통해 사용자는 이러한 컴포넌트를 가져와 자신만의 타입을 사용할 수 있습니다.

“Hello World” of Generics

먼저, 제네릭의 “hello world”인 항등 함수(identity function)를 살펴보겠습니다. 항등 함수는 전달된 인수를 그대로 반환하는 함수입니다. echo 명령어와 유사하게 생각할 수 있습니다.

제네릭이 없다면, 항등 함수에 특정 타입을 지정해야 할 것입니다.

혹은 any 타입을 사용하여 항등 함수를 기술할 수도 있습니다.

function identity(arg: any): any {
  return arg;
}

any를 사용하는 것은 arg의 타입으로 모든 타입을 허용한다는 점에서 분명히 제네릭이지만, 함수가 반환될 때 해당 타입이 무엇이었는지에 대한 정보를 잃게 됩니다. 만약 숫자를 전달했다면, 우리가 아는 정보는 어떤 타입이든 반환될 수 있다는 것뿐입니다.

대신, 인수의 타입을 캡처해 반환되는 타입을 나타내는 데에도 사용할 수 있는 방법이 필요합니다. 여기서는 값 대신 타입에 작용하는 특별한 종류의 변수인 타입 변수(type variable)를 사용하겠습니다.

function identity<Type>(arg: Type): Type {
  return arg;
}

이제 identity 함수에 타입 변수 Type을 추가했습니다. 이 Type은 사용자가 제공한 타입(예: number)을 캡처해 나중에 해당 정보를 사용할 수 있게 해줍니다. 여기서 우리는 Type을 반환 타입으로 다시 사용합니다. 이제 살펴보면, 인수와 반환 타입에 동일한 타입이 사용된 것을 볼 수 있습니다. 이를 통해 함수 한쪽에서 타입 정보를 받아 다른 쪽으로 내보낼 수 있습니다.

이 버전의 identity 함수는 다양한 타입에 걸쳐 작동하므로 제네릭이라고 말합니다. any를 사용하는 것과 달리, 인수와 반환 타입에 number를 사용했던 첫 번째 항등 함수만큼이나 정확합니다(즉, 어떠한 정보도 잃지 않습니다).

제네릭 identity 함수를 작성했다면, 두 가지 방법 중 하나로 호출할 수 있습니다. 첫 번째 방법은 타입 인수를 포함한 모든 인수를 함수에 전달하는 것입니다.

let output = identity<string>("myString");
// let output: string

두 번째 방법이 아마도 가장 일반적인 방법일 것입니다. 여기서는 타입 인수 추론(type argument inference)을 사용합니다. 즉, 우리가 전달하는 인수의 타입에 기반해 컴파일러가 Type의 값을 자동으로 설정하도록 하는 것입니다.

let output = identity("myString");
// let output: string

꺾쇠괄호(< >) 안에 타입을 명시적으로 전달하지 않아도 되었음을 주목하세요. 컴파일러는 값 "myString"을 보고 Type을 해당 값의 타입으로 설정했습니다. 타입 인수 추론은 코드를 더 짧고 가독성 있게 유지하는 데 유용한 도구일 수 있지만, 더 복잡한 예제에서 컴파일러가 타입을 추론하지 못하는 경우에는 이전 예제에서처럼 타입 인수를 명시적으로 전달해야 할 수도 있습니다.

Handling Generic Type Variables

제네릭을 사용하기 시작하면, identity와 같은 제네릭 함수를 만들 때 컴파일러가 함수 본문에서 제네릭 타입 매개변수를 올바르게 사용하도록 강제한다는 것을 알게 될 것입니다. 즉, 이러한 매개변수들을 실제로 모든 타입이 될 수 있는 것처럼 다루어야 합니다.

앞서 작성한 identity 함수를 다시 살펴보겠습니다.

function identity<Type>(arg: Type): Type {
  return arg;
}

만약 매 호출마다 인수 arglength를 콘솔에 기록하고 싶다면 어떨까요? 다음과 같이 작성하고 싶을 수 있습니다.

function loggingIdentity<Type>(arg: Type): Type {
  console.log(arg.length); // 오류: 'Type' 타입에 'length' 속성이 존재하지 않습니다.
  return arg;
}

이렇게 하면 컴파일러는 arglength 멤버를 사용하고 있지만, arg가 이 멤버를 가지고 있다고 어디에도 명시하지 않았다는 오류를 발생시킵니다. 앞서 말했듯이, 이러한 타입 변수들은 모든 타입을 대신하므로, 이 함수를 사용하는 누군가가 length 멤버가 없는 숫자를 전달할 수도 있습니다.

실제로 이 함수가 Type 자체가 아니라 Type의 배열에 대해 작동하도록 의도했다고 가정해 봅시다. 배열로 작업하므로 length 멤버를 사용할 수 있어야 합니다. 다른 타입의 배열을 만드는 것과 똑같이 기술할 수 있습니다.

function loggingIdentity<Type>(arg: Type[]): Type[] {
  console.log(arg.length); // 이제 오류가 없습니다.
  return arg;
}

loggingIdentity의 타입은 “제네릭 함수 loggingIdentity는 타입 매개변수 TypeType의 배열인 인수 arg를 받아 Type의 배열을 반환한다”라고 읽을 수 있습니다. 만약 숫자의 배열을 전달하면, Typenumber에 바인딩되므로 숫자의 배열을 다시 받게 될 것입니다. 이를 통해 제네릭 타입 변수 Type을 전체 타입이 아닌, 우리가 작업하는 타입의 일부로 사용해 더 큰 유연성을 얻을 수 있습니다.

이 예제를 다음과 같이 작성할 수도 있습니다.

function loggingIdentity<Type>(arg: Array<Type>): Array<Type> {
  console.log(arg.length); // Array에는 length가 있으므로 더 이상 오류가 없습니다.
  return arg;
}

다른 언어에서 이런 스타일의 타입을 이미 본 적이 있을 것입니다. 다음 섹션에서는 Array<Type>과 같은 자신만의 제네릭 타입을 만드는 방법을 다룹니다.

Generic Types

우리는 다양한 범위의 타입에 대해 작동하는 제네릭 항등 함수를 만들었습니다. 이 섹션에서는 함수 자체의 타입과 제네릭 인터페이스를 만드는 방법을 살펴봅니다.

제네릭 함수의 타입은 비-제네릭 함수의 타입과 유사하며, 함수 선언과 비슷하게 타입 매개변수가 먼저 나열됩니다.

function identity<Type>(arg: Type): Type {
  return arg;
}

let myIdentity: <Type>(arg: Type) => Type = identity;

타입 변수의 수와 사용 방식이 일치하는 한, 타입에서 제네릭 타입 매개변수에 다른 이름을 사용할 수도 있습니다.

function identity<Type>(arg: Type): Type {
  return arg;
}

let myIdentity: <Input>(arg: Input) => Input = identity;

또한, 객체 리터럴 타입의 호출 시그니처(call signature)로 제네릭 타입을 작성할 수도 있습니다.

function identity<Type>(arg: Type): Type {
  return arg;
}

let myIdentity: { <Type>(arg: Type): Type } = identity;

이를 통해 첫 번째 제네릭 인터페이스를 작성해 보겠습니다. 이전 예제의 객체 리터럴을 인터페이스로 옮겨 봅시다.

interface GenericIdentityFn {
  <Type>(arg: Type): Type;
}

function identity<Type>(arg: Type): Type {
  return arg;
}

let myIdentity: GenericIdentityFn = identity;

비슷한 예로, 제네릭 매개변수를 인터페이스 전체의 매개변수로 옮기고 싶을 수 있습니다. 이렇게 하면 우리가 어떤 타입에 대해 제네릭인지(예: 단지 Dictionary가 아닌 Dictionary<string>) 알 수 있습니다. 이는 타입 매개변수를 인터페이스의 다른 모든 멤버에게 보이도록 만듭니다.

interface GenericIdentityFn<Type> {
  (arg: Type): Type;
}

function identity<Type>(arg: Type): Type {
  return arg;
}

let myIdentity: GenericIdentityFn<number> = identity;

예제가 약간 달라졌음을 주목하세요. 제네릭 함수를 기술하는 대신, 이제는 제네릭 타입의 일부인 비-제네릭 함수 시그니처를 갖게 되었습니다. GenericIdentityFn을 사용할 때, 이제는 해당 타입 인수(여기서는 number)를 지정해야 하며, 이는 내부 호출 시그니처가 사용할 타입을 효과적으로 고정시킵니다. 타입 매개변수를 호출 시그니처에 직접 둘 때와 인터페이스 자체에 둘 때를 이해하는 것은 타입의 어떤 측면이 제네릭인지를 기술하는 데 도움이 됩니다.

제네릭 인터페이스 외에도 제네릭 클래스를 만들 수 있습니다. 제네릭 열거형(enum)과 네임스페이스(namespace)는 만들 수 없다는 점에 유의하세요.

Generic Classes

제네릭 클래스는 제네릭 인터페이스와 유사한 형태를 가집니다. 제네릭 클래스는 클래스 이름 뒤에 꺾쇠괄호(< >) 안에 제네릭 타입 매개변수 목록을 가집니다.

class GenericNumber<NumType> {
  zeroValue: NumType;
  add: (x: NumType, y: NumType) => NumType;
}

let myGenericNumber = new GenericNumber<number>();
myGenericNumber.zeroValue = 0;
myGenericNumber.add = function (x, y) {
  return x + y;
};

이것은 GenericNumber 클래스의 매우 문자 그대로의 사용법이지만, number 타입만 사용하도록 제한하는 것이 아무것도 없다는 것을 눈치채셨을 겁니다. 대신 string이나 훨씬 더 복잡한 객체를 사용할 수도 있었습니다.

let stringNumeric = new GenericNumber<string>();
stringNumeric.zeroValue = "";
stringNumeric.add = function (x, y) {
  return x + y;
};

console.log(stringNumeric.add(stringNumeric.zeroValue, "test"));

인터페이스와 마찬가지로, 클래스 자체에 타입 매개변수를 두면 클래스의 모든 속성이 동일한 타입으로 작동하도록 할 수 있습니다.

클래스에 대한 섹션에서 다루듯이, 클래스는 타입에 두 가지 측면, 즉 정적(static) 측면과 인스턴스(instance) 측면이 있습니다. 제네릭 클래스는 정적 측면이 아닌 인스턴스 측면에 대해서만 제네릭이므로, 클래스로 작업할 때 정적 멤버는 클래스의 타입 매개변수를 사용할 수 없습니다.

Generic Constraints

이전 예제에서 기억하시겠지만, 때로는 해당 타입 집합이 어떤 기능을 가질지에 대해 어느 정도 알고 있는 타입 집합에 대해 작동하는 제네릭 함수를 작성하고 싶을 수 있습니다. loggingIdentity 예제에서는 arglength 속성에 접근하고 싶었지만, 컴파일러는 모든 타입이 length 속성을 가지고 있다고 증명할 수 없었기 때문에 이러한 가정을 할 수 없다고 경고했습니다.

function loggingIdentity<Type>(arg: Type): Type {
  console.log(arg.length); // 오류: 'Type' 타입에 'length' 속성이 존재하지 않습니다.
  return arg;
}

이 멤버를 요구하는 대신, 우리는 Type이 이 멤버를 가지고 있는 모든 타입에 대해 작동하도록 함수를 제약하고 싶습니다. 그렇게 하려면 Type이 될 수 있는 것에 대한 요구사항을 제약 조건으로 나열해야 합니다.

이를 위해, 우리의 제약 조건을 설명하는 인터페이스를 만들 것입니다. 여기서는 단일 length 속성을 가진 인터페이스를 만들고, 이 인터페이스와 extends 키워드를 사용해 제약 조건을 나타낼 것입니다.

interface Lengthwise {
  length: number;
}

function loggingIdentity<Type extends Lengthwise>(arg: Type): Type {
  console.log(arg.length); // 이제 length 속성이 있다는 것을 알므로 오류가 없습니다.
  return arg;
}

이제 제네릭 함수가 제약되었기 때문에 더 이상 모든 타입에 대해 작동하지 않습니다.

loggingIdentity(3); // 오류: 'number' 타입의 인수는 'Lengthwise' 타입의 매개변수에 할당할 수 없습니다.

대신, 필요한 모든 속성을 가진 타입의 값을 전달해야 합니다.

loggingIdentity({ length: 10, value: 3 });

Using Type Parameters in Generic Constraints

다른 타입 매개변수에 의해 제약되는 타입 매개변수를 선언할 수 있습니다. 예를 들어, 여기서는 객체의 이름(key)을 지정해 해당 객체로부터 속성(property)을 가져오고 싶습니다. 이때 obj에 존재하지 않는 속성을 실수로 가져오는 일이 없도록 하고 싶습니다. 따라서 두 타입 사이에 제약 조건을 둡니다:

function getProperty<Type, Key extends keyof Type>(obj: Type, key: Key) {
  return obj[key];
}

let x = { a: 1, b: 2, c: 3, d: 4 };

getProperty(x, "a");
getProperty(x, "m"); // 오류: '"m"' 타입의 인수는 '"a" | "b" | "c" | "d"' 타입의 매개변수에 할당할 수 없습니다.

Using Class Types in Generics

TypeScript에서 제네릭을 사용해 팩토리(factories)를 만들 때, 클래스 타입을 해당 생성자 함수로 참조해야 합니다. 예를 들면 다음과 같습니다.

function create<Type>(c: { new (): Type }): Type {
  return new c();
}

더 고급 예제는 prototype 속성을 사용해 생성자 함수와 클래스 타입의 인스턴스 측면 간의 관계를 추론하고 제약하는 것입니다.

class Beekeeper {
  hasMask: boolean = true;
}

class Zookeeper {
  nametag: string = "Mikle";
}

class Animal {
  numLegs: number = 4;
}

class Bee extends Animal {
  keeper: Beekeeper = new Beekeeper();
}

class Lion extends Animal {
  keeper: Zookeeper = new Zookeeper();
}

function createInstance<A extends Animal>(c: new () => A): A {
  return new c();
}

createInstance(Lion).keeper.nametag; // 타입 추론이 올바르게 작동함
createInstance(Bee).keeper.hasMask; // 타입 추론이 올바르게 작동함

이 패턴은 믹스인(mixins) 디자인 패턴을 구현하는 데 사용됩니다.

Generic Parameter Defaults

제네릭 타입 매개변수에 대한 기본값을 선언함으로써, 해당 타입 인수를 선택적으로 지정할 수 있게 됩니다. 예를 들어, 새로운 HTMLElement를 생성하는 함수가 있습니다. 인자 없이 함수를 호출하면 HTMLDivElement가 생성됩니다. 첫 번째 인자로 엘리먼트를 전달해 함수를 호출하면 해당 인자 타입의 엘리먼트가 생성됩니다. 선택적으로 자식 목록을 전달할 수도 있습니다. 이전에는 이 함수를 다음과 같이 정의해야 했습니다.

declare function create(): Container<HTMLDivElement, HTMLDivElement[]>;
declare function create<T extends HTMLElement>(element: T): Container<T, T[]>;
declare function create<T extends HTMLElement, U extends HTMLElement>(
  element: T,
  children: U[]
): Container<T, U[]>;

제네릭 매개변수 기본값을 사용하면 이를 다음과 같이 줄일 수 있습니다.

declare function create<T extends HTMLElement = HTMLDivElement, U extends T[] = T[]>(
  element?: T,
  children?: U
): Container<T, U>;

const p = create(new HTMLParagraphElement());
// const p: Container<HTMLParagraphElement, HTMLParagraphElement[]>

제네릭 매개변수 기본값은 다음 규칙을 따릅니다.

  • 타입 매개변수는 기본값이 있으면 선택 사항으로 간주됩니다.
  • 필수 타입 매개변수는 선택적 타입 매개변수 뒤에 올 수 없습니다.
  • 타입 매개변수의 기본 타입은 해당 타입 매개변수의 제약 조건을 만족해야 합니다.
  • 타입 인수를 지정할 때, 필수 타입 매개변수에 대한 타입 인수만 지정하면 됩니다. 지정되지 않은 타입 매개변수는 기본 타입으로 결정됩니다.
  • 기본 타입이 지정되고 타입 추론이 후보를 선택할 수 없는 경우, 기본 타입이 추론됩니다.
  • 기존 클래스 또는 인터페이스 선언과 병합되는 클래스 또는 인터페이스 선언은 기존 타입 매개변수에 대한 기본값을 도입할 수 있습니다.
  • 기존 클래스 또는 인터페이스 선언과 병합되는 클래스 또는 인터페이스 선언은 기본값을 지정하는 한 새로운 타입 매개변수를 도입할 수 있습니다.

Variance Annotations

이것은 매우 특정한 문제를 해결하기 위한 고급 기능이며, 사용해야 할 이유를 명확히 파악한 상황에서만 사용해야 합니다.

공변성(Covariance)과 반공변성(Contravariance)은 두 제네릭 타입 간의 관계를 설명하는 타입 이론 용어입니다. 개념에 대한 간략한 입문은 다음과 같습니다.

예를 들어, 특정 타입을 만들 수 있는 객체를 나타내는 인터페이스가 있다고 가정해 봅시다.

CatAnimal이므로 Producer<Animal>이 예상되는 곳에 Producer<Cat>을 사용할 수 있습니다. 이 관계를 공변성(covariance)이라고 합니다. Producer<T>에서 Producer<U>로의 관계는 T에서 U로의 관계와 동일합니다.

반대로, 특정 타입을 소비할 수 있는 인터페이스가 있다면:

interface Consumer<T> {
  consume: (arg: T) => void;
}

Animal을 받을 수 있는 모든 함수는 Cat도 받을 수 있어야 하므로, Consumer<Cat>이 예상되는 곳에 Consumer<Animal>을 사용할 수 있습니다. 이 관계를 반공변성(contravariance)이라고 합니다. Consumer<T>에서 Consumer<U>로의 관계는 U에서 T로의 관계와 동일합니다. 공변성과 비교해 방향이 반대인 점에 유의하세요!

TypeScript와 같은 구조적 타입 시스템에서 공변성과 반공변성은 타입의 정의에서 자연스럽게 발생하는 동작입니다.

TypeScript는 구조적 타입 시스템이므로, 예를 들어 Producer<Cat>Producer<Animal>이 예상되는 곳에 사용될 수 있는지 확인하기 위해 두 타입을 비교할 때, 일반적인 알고리즘은 두 정의를 구조적으로 확장하고 그 구조를 비교하는 것입니다. 그러나 가변성은 매우 유용한 최적화를 가능하게 합니다. 만약 Producer<T>T에 대해 공변이라면, 우리는 단순히 CatAnimal만 확인하면 됩니다.

가변성은 구조적 타입의 자연스러운 속성이기 때문에, TypeScript는 모든 제네릭 타입의 가변성을 자동으로 추론합니다. 매우 드물게 특정 종류의 순환 타입과 관련된 경우 이 추론이 부정확할 수 있습니다. 이런 경우, 타입 매개변수에 가변성 어노테이션을 추가해 특정 가변성을 강제할 수 있습니다.

// 반공변성(Contravariant) 어노테이션
interface Consumer<in T> {
  consume: (arg: T) => void;
}

// 공변성(Covariant) 어노테이션
interface Producer<out T> {
  make(): T;
}

// 불변성(Invariant) 어노테이션
interface ProducerConsumer<in out T> {
  consume: (arg: T) => void;
  make(): T;
}

구조적 가변성과 일치하지 않는 가변성 어노테이션은 절대 작성하지 마세요! 가변성 어노테이션은 구조적 동작을 변경하지 않으며, 인스턴스화 기반 비교 중에만 효과가 있습니다. 이를 사용해 타입을 “강제로” 불변하게 만들 수는 없습니다.

타입의 구조적 동작과 일치하는 경우에만 가변성 어노테이션을 작성해야 합니다. TypeScript가 인스턴스화 기반 비교를 사용할지 구조적 비교를 사용할지는 지정된 동작이 아니며 버전마다 변경될 수 있으므로, 어노테이션이 타입의 구조적 동작과 일치할 때만 작성해야 합니다.

대부분의 경우 TypeScript가 가변성을 올바르게 추론할 수 있으므로, 일반적인 코드에서 가변성 어노테이션을 작성할 필요는 거의 없습니다.

Keyof Type Operator

keyof 연산자는 객체 타입을 받아 해당 객체 키의 문자열 또는 숫자 리터럴 유니언(union)을 생성합니다.

다음 예시에서 타입 P$type P = "x" | "y"$와 동일한 타입이 됩니다.

type Point = { x: number; y: number };
type P = keyof Point;

타입 Pkeyof Point의 결과입니다.

만약 타입이 문자열이나 숫자 인덱스 시그니처(index signature)를 가지고 있다면, keyof는 해당 타입들을 반환합니다.

type Arrayish = { [n: number]: unknown };
type A = keyof Arrayish;

타입 Anumber 타입이 됩니다.

type Mapish = { [k: string]: boolean };
type M = keyof Mapish;

여기서 주목할 점은 Mstring | number가 된다는 것입니다. 이는 자바스크립트 객체의 키는 항상 문자열로 강제 변환되기 때문입니다. 따라서 obj[0]은 항상 obj["0"]과 동일합니다.

keyof 타입은 나중에 더 자세히 배울 맵드 타입(mapped types)과 결합될 때 특히 유용해집니다.

typeof Type Operator

자바스크립트에는 이미 표현식 컨텍스트에서 사용할 수 있는 typeof 연산자가 있습니다.

// "string"을 출력합니다
console.log(typeof "Hello world");

타입스크립트는 여기에 더해, 타입 컨텍스트에서 변수나 속성의 타입을 참조하기 위해 사용할 수 있는 typeof 연산자를 추가합니다.

let s = "hello";
let n: typeof s;

위 예시에서 n의 타입은 string이 됩니다.

let n: string

이 기능은 기본 타입에 대해서는 그다지 유용하지 않지만, 다른 타입 연산자들과 결합하면 typeof를 사용해 많은 패턴을 편리하게 표현할 수 있습니다. 예를 들어, 미리 정의된 ReturnType<T> 타입을 살펴보겠습니다. 이 타입은 함수 타입을 받아 그 함수의 반환 타입을 결과로 내어줍니다.

type Predicate = (x: unknown) => boolean;
type K = ReturnType<Predicate>;

이 경우 K의 타입은 boolean이 됩니다.

함수 이름에 ReturnType을 직접 사용하려고 하면, 다음과 같이 유용한 에러 메시지를 볼 수 있습니다.

function f() {
  return { x: 10, y: 3 };
}
type P = ReturnType<f>;
// 'f'는 값을 참조하지만, 여기서는 타입으로 사용되고 있습니다. 
// 'typeof f'를 사용하시겠습니까?

값과 타입은 동일하지 않다는 점을 기억해야 합니다. 값 f가 가지는 타입을 참조하려면 typeof를 사용해야 합니다.

function f() {
  return { x: 10, y: 3 };
}
type P = ReturnType<typeof f>;

결과적으로 P의 타입은 다음과 같이 추론됩니다.

type P = {
    x: number;
    y: number;
};

Constraints

타입스크립트는 typeof를 사용할 수 있는 표현식의 종류를 의도적으로 제한합니다.

구체적으로, typeof는 식별자(예: 변수 이름)나 그 속성에만 사용하는 것이 허용됩니다. 이는 개발자가 실행될 것이라고 생각했지만 실제로는 실행되지 않는 혼란스러운 코드를 작성하는 함정을 피하는 데 도움이 됩니다.

Indexed Access Types

다른 타입에서 특정 프로퍼티를 조회하기 위해 인덱싱된 접근 타입(indexed access type)을 사용할 수 있습니다.

type Person = { age: number; name: string; alive: boolean };
type Age = Person["age"];

// type Age = number

인덱싱 타입은 그 자체가 타입이므로, 유니언(union), keyof 또는 다른 타입들을 사용할 수 있습니다.

// "age" 또는 "name" 프로퍼티의 타입을 조회합니다.
type I1 = Person["age" | "name"];

// type I1 = string | number

// Person 타입의 모든 키에 해당하는 값들의 타입을 유니언으로 가져옵니다.
type I2 = Person[keyof Person];

// type I2 = string | number | boolean

// 별도의 타입 별칭(alias)을 인덱스로 사용할 수 있습니다.
type AliveOrName = "alive" | "name";
type I3 = Person[AliveOrName];

// type I3 = string | boolean

존재하지 않는 프로퍼티에 접근하려고 하면 TypeScript는 에러를 발생시킵니다.

type I1 = Person["alve"];
// Error: Property 'alve' does not exist on type 'Person'.
// 에러: 'alve' 프로퍼티는 'Person' 타입에 존재하지 않습니다.

임의의 타입을 사용한 인덱싱의 또 다른 예시는 number를 사용해 배열 요소의 타입을 가져오는 것입니다. 이를 typeof와 결합하면 배열 리터럴(array literal)의 요소 타입을 편리하게 추출할 수 있습니다.

const MyArray = [
    { name: "Alice", age: 15 },
    { name: "Bob", age: 23 },
    { name: "Eve", age: 38 },
];

type Person = typeof MyArray[number];

/*
type Person = {
    name: string;
    age: number;
}
*/

type Age = typeof MyArray[number]["age"]; // type Age = number

// 또는 아래와 같이 이미 정의된 타입을 사용할 수도 있습니다.
type Age2 = Person["age"]; // type Age2 = number

인덱싱에는 타입만 사용할 수 있습니다. 즉, const로 선언한 변수를 참조용으로 사용할 수는 없습니다.

const key = "age";
type Age = Person[key];
// Error: Type 'key' cannot be used as an index type.
// 에러: 'key' 타입을 인덱스 타입으로 사용할 수 없습니다.

Refactoring with Type Aliases

하지만, 타입 별칭(type alias)을 사용하면 앞서 const를 사용하려던 것과 비슷한 스타일로 코드를 리팩토링할 수 있습니다.

type Key = "age";
type Age = Person[Key];

Conditional Types

대부분의 유용한 프로그램 중심에는 입력을 기반으로 한 의사 결정 과정이 있습니다. JavaScript 프로그램도 다르지 않지만, 값의 타입을 쉽게 확인할 수 있다는 사실을 고려하면 이러한 결정은 입력 값의 ’타입’을 기반으로 이루어지기도 합니다. 조건부 타입(Conditional types)은 입력과 출력의 타입 사이의 관계를 기술하는 데 도움을 줍니다.

조건부 타입은 아래와 같이 JavaScript의 삼항 연산자(condition ? trueExpression : falseExpression)와 유사한 형태를 가집니다.

interface Animal {
  live(): void;
}

interface Dog extends Animal {
  woof(): void;
}

// 기본 구문: T extends U ? X : Y
type Example1 = Dog extends Animal ? number : string;

// 결과: type Example1 = number
// Dog는 Animal에 할당 가능하므로 첫 번째(true) 분기인 number가 됩니다.

type Example2 = RegExp extends Animal ? number : string;

// 결과: type Example2 = string
// RegExp는 Animal에 할당 가능하지 않으므로 두 번째(false) 분기인 string이 됩니다.

extends 키워드 왼쪽의 타입이 오른쪽의 타입에 할당 가능할 경우, 첫 번째 분기(“true” 분기)의 타입을 얻습니다. 그렇지 않으면 두 번째 분기(“false” 분기)의 타입을 얻습니다.

위 예제만 보면 조건부 타입이 그다지 유용해 보이지 않을 수 있습니다. DogAnimal을 확장하는지 여부는 우리가 직접 판단해 numberstring을 선택하면 되기 때문입니다. 하지만 조건부 타입의 진정한 강력함은 제네릭(generics)과 함께 사용할 때 발휘됩니다.

예를 들어, 다음과 같은 createLabel 함수가 있다고 가정해 보겠습니다.

interface IdLabel {
  id: number; /* 일부 필드 */
}

interface NameLabel {
  name: string; /* 다른 필드 */
}

// 함수 오버로드를 사용한 구현
function createLabel(id: number): IdLabel;
function createLabel(name: string): NameLabel;
function createLabel(nameOrId: string | number): IdLabel | NameLabel;
function createLabel(nameOrId: string | number): IdLabel | NameLabel {
  throw "unimplemented";
}

createLabel 함수의 오버로드는 입력 값의 타입에 따라 다른 결과를 반환하는 단일 JavaScript 함수를 설명합니다. 하지만 여기에는 몇 가지 문제점이 있습니다.

  1. 라이브러리 API 전반에 걸쳐 이와 같은 타입 선택을 반복해야 한다면 매우 번거로워집니다.
  2. 세 개의 오버로드를 만들어야 합니다. 타입이 확실한 경우(stringnumber) 각각 하나씩, 그리고 가장 일반적인 경우(string | number)를 위한 것까지 총 세 개입니다. createLabel이 처리해야 할 새로운 타입이 추가될 때마다 오버로드의 수는 기하급수적으로 늘어납니다.

대신, 이러한 로직을 조건부 타입으로 인코딩할 수 있습니다.

type NameOrId<T extends number | string> = T extends number ? IdLabel : NameLabel;

Conditional Type Constraints

앞서 만든 조건부 타입을 사용하면, 함수 오버로드 없이 단일 함수로 createLabel을 단순화할 수 있습니다.

function createLabel<T extends number | string>(idOrName: T): NameOrId<T> {
  throw "unimplemented";
}

let a = createLabel("typescript");
// let a: NameLabel

let b = createLabel(2.8);
// let b: IdLabel

let c = createLabel(Math.random() ? "hello" : 42);
// let c: NameLabel | IdLabel

Utilizing Constraints

조건부 타입 내부의 검사는 종종 우리에게 새로운 정보를 제공합니다. 타입 가드(type guard)를 통한 타입 좁히기(narrowing)가 더 구체적인 타입을 제공하는 것처럼, 조건부 타입의 “true” 분기는 우리가 검사하는 타입을 기준으로 제네릭을 더욱 제약합니다.

예를 들어, 다음 코드를 보겠습니다.

type MessageOf<T> = T["message"];
// Error: Type '"message"' cannot be used to index type 'T'.
// 에러: 'T' 타입에서 '"message"' 타입을 인덱스로 사용할 수 없습니다.

이 예제에서 TypeScript는 Tmessage라는 프로퍼티를 가지고 있다고 보장할 수 없기 때문에 에러를 발생시킵니다. T에 제약을 추가하면 TypeScript는 더 이상 에러를 발생시키지 않습니다.

type MessageOf<T extends { message: unknown }> = T["message"];

interface Email {
  message: string;
}

type EmailMessageContents = MessageOf<Email>;
// type EmailMessageContents = string

하지만 만약 MessageOf가 모든 타입을 인자로 받고, message 프로퍼티가 없는 경우에는 never와 같은 기본 타입으로 처리되길 원한다면 어떻게 해야 할까요? 이럴 때는 제약을 밖으로 빼내고 조건부 타입을 도입하여 해결할 수 있습니다.

type MessageOf<T> = T extends { message: unknown } ? T["message"] : never;

interface Email {
  message: string;
}

interface Dog {
  bark(): void;
}

type EmailMessageContents = MessageOf<Email>;
// type EmailMessageContents = string

type DogMessageContents = MessageOf<Dog>;
// type DogMessageContents = never

이제 “true” 분기 안에서 TypeScript는 Tmessage 프로퍼티를 가지고 있음을 알게 됩니다.

또 다른 예로, 배열 타입은 요소 타입으로 평탄화(flatten)하고 다른 타입은 그대로 유지하는 Flatten이라는 타입을 작성할 수 있습니다.

type Flatten<T> = T extends any[] ? T[number] : T;

// 배열의 요소 타입을 추출합니다.
type Str = Flatten<string[]>;
// type Str = string

// 타입이 배열이 아니므로 그대로 둡니다.
type Num = Flatten<number>;
// type Num = number

Flatten 타입은 배열 타입을 받으면 number를 이용한 인덱싱된 접근 타입(T[number])을 사용해 요소 타입을 추출합니다. 그 외의 경우에는 입력된 타입을 그대로 반환합니다.

Inferring Within Conditional Types

우리는 방금 조건부 타입을 사용해 제약을 적용하고 타입을 추출하는 과정을 살펴보았습니다. 이 패턴은 매우 흔하게 사용되기 때문에, 조건부 타입은 이 과정을 더 쉽게 만들어주는 infer 키워드를 제공합니다.

infer 키워드를 사용하면 “true” 분기에서 비교 대상이 되는 타입으로부터 새로운 타입을 ’추론’해낼 수 있습니다. 예를 들어, 앞서 만들었던 Flatten 타입에서 인덱싱된 접근 타입을 사용해 “수동으로” 요소 타입을 가져오는 대신, infer를 사용해 추론할 수 있습니다.

type Flatten<Type> = Type extends Array<infer Item> ? Item : Type;

여기서는 infer 키워드를 사용해 Item이라는 새로운 제네릭 타입 변수를 선언적으로 도입했습니다. 이 덕분에 Type의 요소 타입을 어떻게 꺼내올지 그 구조를 파헤치고 분석하는 고민에서 벗어날 수 있습니다.

infer 키워드를 활용하면 유용한 헬퍼(helper) 타입 별칭들을 만들 수 있습니다. 예를 들어, 간단한 경우에 함수 타입에서 반환 타입을 추출하는 타입을 다음과 같이 작성할 수 있습니다.

type GetReturnType<Type> = Type extends (...args: any[]) => infer Return ? Return : never;

type Num = GetReturnType<() => number>;
// type Num = number

type Str = GetReturnType<(x: string) => string>;
// type Str = string

type Bools = GetReturnType<(a: boolean, b: boolean) => boolean[]>;
// type Bools = boolean[]

Inferring from Overloaded Functions

여러 개의 호출 시그니처(call signature)를 가진 타입(예: 오버로드된 함수)에서 타입을 추론할 때, 추론은 마지막 시그니처를 기준으로 이루어집니다. 일반적으로 마지막 시그니처가 가장 포괄적인 경우(catch-all case)이기 때문입니다. 인자 타입 목록을 기반으로 오버로드를 결정하는 것은 불가능합니다.

declare function stringOrNum(x: string): number;
declare function stringOrNum(x: number): string;
declare function stringOrNum(x: string | number): string | number;

// stringOrNum의 타입에서 반환 타입을 추론합니다.
type T1 = GetReturnType<typeof stringOrNum>;
// type T1 = string | number
// 마지막 시그니처인 (x: string | number): string | number 에서 추론했기 때문입니다.

Distributive Conditional Types

조건부 타입이 제네릭 타입에 적용될 때, 해당 제네릭 타입이 유니언(union) 타입이면 조건부 타입은 분배적(distributive)으로 동작합니다. 이는 조건부 타입이 유니언의 각 멤버 타입에 개별적으로 적용되는 것을 의미합니다.

예를 들어, 다음 ToArray 타입을 보겠습니다.

type ToArray<Type> = Type extends any ? Type[] : never;

이 타입에 유니언 타입을 전달하면 다음과 같이 동작합니다.

type StrArrOrNumArr = ToArray<string | number>;

여기서 ToArraystring | number 유니언의 각 멤버에 분배되어 적용됩니다. 그 결과는 사실상 아래와 같습니다.

ToArray<string> | ToArray<number>;

최종적으로 우리에게 남는 타입은 다음과 같습니다.

string[] | number[];

일반적으로 이러한 분배적 동작은 우리가 원하는 결과입니다. 하지만 이 동작을 피하고 싶다면, extends 키워드의 양쪽을 대괄호([])로 감싸주면 됩니다.

type ToArrayNonDist<Type> = [Type] extends [any] ? Type[] : never;

// 'ArrOfStrOrNum'은 더 이상 유니언 타입이 아닙니다.
type ArrOfStrOrNum = ToArrayNonDist<string | number>;
// type ArrOfStrOrNum = (string | number)[]

Mapped Types

코드를 반복해서 작성하고 싶지 않을 때, 때로는 한 타입을 다른 타입에 기반하여 만들어야 할 필요가 있습니다.

매핑된 타입(Mapped types)은 인덱스 시그니처(index signatures) 구문을 기반으로 만들어집니다. 인덱스 시그니처는 미리 선언되지 않은 프로퍼티들의 타입을 선언하는 데 사용됩니다.

// 인덱스 시그니처 예시
// 이 타입은 모든 프로퍼티의 키는 string, 값은 boolean 또는 Horse 타입이어야 함을 의미합니다.
type OnlyBoolsAndHorses = {
  [key: string]: boolean | Horse;
};

const conforms: OnlyBoolsAndHorses = {
  del: true,
  rodney: false,
};

매핑된 타입은 제네릭 타입의 일종으로, keyof를 통해 생성된 PropertyKey의 유니언을 사용하여 키를 순회하며 새로운 타입을 만듭니다.

// 기본 매핑된 타입
// Type의 모든 프로퍼티를 boolean 타입으로 변환합니다.
type OptionsFlags<Type> = {
  [Property in keyof Type]: boolean;
};

OptionsFlags를 사용하면, 기존 타입의 프로퍼티 이름은 그대로 유지하면서 값의 타입을 boolean으로 바꾼 새로운 타입을 생성할 수 있습니다.

type Features = {
  darkMode: () => void;
  newUserProfile: () => void;
};

// Features 타입의 프로퍼티들을 boolean으로 매핑합니다.
type FeatureOptions = OptionsFlags<Features>;

/*
결과 타입:
type FeatureOptions = {
    darkMode: boolean;
    newUserProfile: boolean;
}
*/

Mapping Modifiers

매핑 과정에서 적용할 수 있는 두 가지 추가적인 수정자(modifier)가 있습니다. readonly?입니다. 이들은 각각 타입의 변경 가능성(mutability)과 선택성(optionality)에 영향을 줍니다.

수정자 앞에 - 또는 +를 붙여서 이러한 속성을 제거하거나 추가할 수 있습니다. 만약 접두사를 붙이지 않으면 +가 있는 것으로 간주됩니다.

readonly Modifier Example

-readonly를 사용하여 타입의 프로퍼티에서 readonly 속성을 제거할 수 있습니다.

// 타입의 프로퍼티에서 'readonly' 속성을 제거합니다.
type CreateMutable<Type> = {
  -readonly [Property in keyof Type]: Type[Property];
};

type LockedAccount = {
  readonly id: string;
  readonly name: string;
};

// LockedAccount에서 readonly를 제거하여 UnlockedAccount를 생성합니다.
type UnlockedAccount = CreateMutable<LockedAccount>;

/*
결과 타입:
type UnlockedAccount = {
    id: string;
    name: string;
}
*/

? Modifier Example

-?를 사용하여 타입의 프로퍼티에서 선택적(?) 속성을 제거하여 필수 프로퍼티로 만들 수 있습니다.

// 타입의 프로퍼티를 모두 필수로 만듭니다.
type Concrete<Type> = {
  [Property in keyof Type]-?: Type[Property];
};

type MaybeUser = {
  id: string;
  name?: string;
  age?: number;
};

// MaybeUser의 모든 선택적 프로퍼티를 필수로 변경합니다.
type User = Concrete<MaybeUser>;

/*
결과 타입:
type User = {
    id: string;
    name: string;
    age: number;
}
*/

Key Remapping via as

TypeScript 4.1 버전부터 매핑된 타입 내에서 as 절을 사용하여 키의 이름을 변경하는 키 리매핑(key remapping)이 가능해졌습니다.

type MappedTypeWithNewProperties<Type> = {
  [Properties in keyof Type as NewKeyType]: Type[Properties]
}

이 기능을 템플릿 리터럴 타입(template literal types)과 같은 다른 기능과 결합해 기존 프로퍼티 이름으로부터 새로운 프로퍼티 이름을 동적으로 생성할 수 있습니다.

Getter Creation Example

예를 들어, 어떤 타입의 모든 프로퍼티에 대한 getter 메서드를 생성하는 타입을 만들 수 있습니다.

// 프로퍼티 이름을 'get' + '프로퍼티명(첫글자 대문자)' 형태로 변환합니다.
type Getters<Type> = {
    [Property in keyof Type as `get${Capitalize<string & Property>}`]: () => Type[Property]
};

interface Person {
    name: string;
    age: number;
    location: string;
}

// Person 타입에 대한 getter 타입을 생성합니다.
type LazyPerson = Getters<Person>;

/*
결과 타입:
type LazyPerson = {
    getName: () => string;
    getAge: () => number;
    getLocation: () => string;
}
*/

Key Filtering Example

조건부 타입을 사용해 키를 never로 매핑하면 해당 키를 결과 타입에서 제외(필터링)할 수 있습니다.

// 'kind' 프로퍼티를 제거하는 타입
type RemoveKindField<Type> = {
  [Property in keyof Type as Exclude<Property, "kind">]: Type[Property]
};

interface Circle {
  kind: "circle";
  radius: number;
}

// Circle 타입에서 'kind' 프로퍼티를 제거합니다.
type KindlessCircle = RemoveKindField<Circle>;

/*
결과 타입:
type KindlessCircle = {
    radius: number;
}
*/

Mapping Union Types Example

string | number | symbol의 유니언뿐만 아니라, 임의의 타입으로 구성된 유니언을 순회하며 매핑할 수도 있습니다.

type EventConfig<Events extends { kind: string }> = {
  [E in Events as E["kind"]]: (event: E) => void;
}

type SquareEvent = { kind: "square", x: number, y: number };
type CircleEvent = { kind: "circle", radius: number };

// SquareEvent | CircleEvent 유니언을 매핑합니다.
type Config = EventConfig<SquareEvent | CircleEvent>;

/*
결과 타입:
type Config = {
    square: (event: SquareEvent) => void;
    circle: (event: CircleEvent) => void;
}
*/

Further Exploration

매핑된 타입은 이 문서에서 다룬 다른 타입 조작 기능들과 잘 어우러집니다. 예를 들어, 다음은 매핑된 타입과 조건부 타입을 함께 사용한 예제입니다. 이 타입은 객체의 특정 프로퍼티 값이 pii: true 리터럴을 가지고 있는지 여부에 따라 true 또는 false를 반환합니다.

type ExtractPII<Type> = {
  [Property in keyof Type]: Type[Property] extends { pii: true } ? true : false;
};

type DBFields = {
  id: { format: "incrementing" };
  name: { type: string; pii: true };
};

// DBFields 타입에서 개인정보(pii) 필드를 추출합니다.
type ObjectsNeedingGDPRDeletion = ExtractPII<DBFields>;

/*
결과 타입:
type ObjectsNeedingGDPRDeletion = {
    id: false;  // id의 값 타입인 { format: "incrementing" } 에는 pii: true가 없음
    name: true; // name의 값 타입인 { type: string; pii: true } 에는 pii: true가 있음
}
*/

Template Literal Types

템플릿 리터럴 타입은 문자열 리터럴 타입을 기반으로 하며, 유니언(union)을 통해 여러 문자열로 확장될 수 있는 기능을 가집니다.

자바스크립트의 템플릿 리터럴 문자열과 동일한 구문을 가지지만, 타입 위치에서 사용됩니다. 구체적인 리터럴 타입과 함께 사용될 때, 템플릿 리터럴은 내용을 연결하여 새로운 문자열 리터럴 타입을 생성합니다.

type World = "world";

// type Greeting = "hello world"
type Greeting = `hello ${World}`;

보간된 위치에 유니언이 사용되면, 해당 타입은 각 유니언 멤버가 표현할 수 있는 모든 가능한 문자열 리터럴의 집합이 됩니다.

type EmailLocaleIDs = "welcome_email" | "email_heading";
type FooterLocaleIDs = "footer_title" | "footer_sendoff";

// type AllLocaleIDs = "welcome_email_id" | "email_heading_id" | "footer_title_id" | "footer_sendoff_id"
type AllLocaleIDs = `${EmailLocaleIDs | FooterLocaleIDs}_id`;

템플릿 리터럴의 각 보간된 위치에 대해 유니언은 교차 곱(cross multiplied)으로 계산됩니다.

type AllLocaleIDs = "welcome_email_id" | "email_heading_id";
type Lang = "en" | "ja" | "pt";

// type LocaleMessageIDs = "en_welcome_email_id" | "en_email_heading_id" | "ja_welcome_email_id" | "ja_email_heading_id" | "pt_welcome_email_id" | "pt_email_heading_id"
type LocaleMessageIDs = `${Lang}_${AllLocaleIDs}`;

일반적으로 큰 문자열 유니언에 대해서는 사전 생성(ahead-of-time generation) 방식을 사용하는 것을 권장하지만, 이 기능은 작은 규모의 경우에 유용합니다.

String Unions in Types

템플릿 리터럴의 진정한 강력함은 타입 내부의 정보를 기반으로 새로운 문자열을 정의할 때 나타납니다.

makeWatchedObject라는 함수가 전달된 객체에 on()이라는 새 함수를 추가하는 경우를 생각해 봅시다. 자바스크립트에서 호출은 makeWatchedObject(baseObject)처럼 보일 것입니다. 기본 객체는 다음과 같다고 상상할 수 있습니다.

const passedObject = {
  firstName: "Saoirse",
  lastName: "Ronan",
  age: 26,
};

기본 객체에 추가될 on 함수는 두 개의 인자, 즉 이벤트 이름과 콜백 함수를 받습니다.

호출되는 콜백 함수는 다음과 같은 특징을 가집니다.

  • passedObject에 있는 속성 name과 연관된 타입의 값을 전달받아야 합니다. 따라서 firstNamestring으로 타입이 지정되었으므로, firstNameChanged 이벤트에 대한 콜백은 호출 시점에 string이 전달될 것으로 예상합니다.
  • 마찬가지로 age와 관련된 이벤트는 number 인자와 함께 호출될 것으로 예상해야 합니다.
  • (시연의 단순화를 위해) void 반환 타입을 가져야 합니다.

따라서 on() 함수의 가장 기본적인 시그니처는 on(eventName: string, callback: (newValue: any) => void)가 될 수 있습니다. 그러나 앞선 설명에서 코드에 문서화하고 싶은 중요한 타입 제약 조건들을 확인했습니다. 템플릿 리터럴 타입을 사용하면 이러한 제약 조건들을 우리 코드로 가져올 수 있습니다.

const person = makeWatchedObject({
  firstName: "Saoirse",
  lastName: "Ronan",
  age: 26,
});

// makeWatchedObject는 익명 객체에 'on' 함수를 추가했습니다.
person.on("firstNameChanged", (newValue) => {
  console.log(`firstName was changed to ${newValue}!`);
});

on 함수는 “firstName”이 아닌 “firstNameChanged” 이벤트를 수신(listen)한다는 점에 주목하세요.

on() 함수의 기본 명세를 더 견고하게 만들 수 있습니다. 관찰 대상 객체의 속성 이름 유니언에 “Changed”를 끝에 추가하여 유효한 이벤트 이름의 집합을 제한하는 것입니다. 자바스크립트에서 Object.keys(passedObject).map(x => \${x}Changed`)` 와 같은 계산을 편안하게 수행하는 것처럼, 타입 시스템 내부의 템플릿 리터럴도 문자열 조작에 유사한 접근 방식을 제공합니다.

type PropEventSource<Type> = {
    on(eventName: `${string & keyof Type}Changed`, callback: (newValue: any) => void): void;
};

declare function makeWatchedObject<Type>(obj: Type): Type & PropEventSource<Type>;

이를 통해 잘못된 속성을 전달했을 때 오류를 발생시키는 무언가를 만들 수 있습니다.

const person = makeWatchedObject({
  firstName: "Saoirse",
  lastName: "Ronan",
  age: 26
});

person.on("firstNameChanged", () => {});

// 흔한 사용자 오류 방지 (이벤트 이름 대신 키를 사용하는 경우)
person.on("firstName", () => {});
// Argument of type '"firstName"' is not assignable to parameter of type '"firstNameChanged" | "lastNameChanged" | "ageChanged"'.
// (오류 메시지: '"firstName"' 타입의 인수는 '"firstNameChanged" | "lastNameChanged" | "ageChanged"' 타입의 매개변수에 할당할 수 없습니다.)

// 오타에 강함
person.on("frstNameChanged", () => {});
// Argument of type '"frstNameChanged"' is not assignable to parameter of type '"firstNameChanged" | "lastNameChanged" | "ageChanged"'.
// (오류 메시지: '"frstNameChanged"' 타입의 인수는 '"firstNameChanged" | "lastNameChanged" | "ageChanged"' 타입의 매개변수에 할당할 수 없습니다.)

Inference with Template Literals

원본으로 전달된 객체에 제공된 모든 정보의 이점을 아직 활용하지 못했다는 점에 주목하세요. firstName의 변경(즉, firstNameChanged 이벤트)이 주어졌을 때, 콜백이 string 타입의 인자를 받을 것으로 예상해야 합니다. 마찬가지로, age 변경에 대한 콜백은 number 인자를 받아야 합니다. 우리는 현재 콜백의 인자 타입을 any로 단순하게 사용하고 있습니다. 다시 한번, 템플릿 리터럴 타입은 속성의 데이터 타입이 해당 속성 콜백의 첫 번째 인자 타입과 동일하도록 보장하는 것을 가능하게 합니다.

이것을 가능하게 하는 핵심적인 통찰은 다음과 같습니다. 제네릭을 가진 함수를 사용해 다음을 수행할 수 있습니다.

  1. 이벤트 이름은 (속성 이름)Changed 형태의 템플릿 리터럴 문자열로 유효성 검사를 받습니다.
  2. 템플릿 리터럴에서 추론을 통해 “속성 이름” 부분을 캡처할 수 있습니다.
  3. 유효성이 검증된 속성의 타입은 인덱싱된 접근(Indexed Access)을 사용하여 제네릭의 구조에서 조회할 수 있습니다.
  4. 이 타이핑 정보는 콜백 함수의 인자가 동일한 타입을 갖도록 보장하는 데 적용될 수 있습니다.
type PropEventSource<Type> = {
    on<Key extends string & keyof Type>
        (eventName: `${Key}Changed`, callback: (newValue: Type[Key]) => void): void;
};

declare function makeWatchedObject<Type>(obj: Type): Type & PropEventSource<Type>;

const person = makeWatchedObject({
  firstName: "Saoirse",
  lastName: "Ronan",
  age: 26
});

person.on("firstNameChanged", newName => {
    // (parameter) newName: string
    console.log(`new name is ${newName.toUpperCase()}`);
});

person.on("ageChanged", newAge => {
    // (parameter) newAge: number
    if (newAge < 0) {
        console.warn("warning! negative age");
    }
})

여기서 우리는 on을 제네릭 메소드로 만들었습니다.

사용자가 "firstNameChanged" 문자열로 호출하면 TypeScript는 Key에 대한 올바른 타입을 추론하려고 시도합니다. 이를 위해 "Changed" 앞의 내용과 Key를 일치시켜 "firstName" 문자열을 추론합니다. TypeScript가 이를 파악하면 on 메소드는 firstName의 타입을 객체로부터 가져올 수 있으며, 이 경우 string입니다. 마찬가지로, "ageChanged"로 호출될 때 TypeScript는 age 속성의 타입을 찾고, 이는 number입니다.

추론은 다양한 방식으로 결합될 수 있으며, 종종 문자열을 해체하고 다른 방식으로 재구성하는 데 사용됩니다.

Intrinsic String Manipulation Types

문자열 조작을 돕기 위해 TypeScript는 문자열 조작에 사용할 수 있는 타입 세트를 포함합니다. 이러한 타입들은 성능을 위해 컴파일러에 내장되어 있으며, TypeScript에 포함된 .d.ts 파일에서는 찾을 수 없습니다.

Uppercase<StringType>

문자열의 각 문자를 대문자 버전으로 변환합니다.

type Greeting = "Hello, world";
// type ShoutyGreeting = "HELLO, WORLD"
type ShoutyGreeting = Uppercase<Greeting>;

type ASCIICacheKey<Str extends string> = `ID-${Uppercase<Str>}`
// type MainID = "ID-MY_APP"
type MainID = ASCIICacheKey<"my_app">;
Lowercase<StringType>

문자열의 각 문자를 소문자 버전으로 변환합니다.

type Greeting = "Hello, world";
// type QuietGreeting = "hello, world"
type QuietGreeting = Lowercase<Greeting>;

type ASCIICacheKey<Str extends string> = `id-${Lowercase<Str>}`
// type MainID = "id-my_app"
type MainID = ASCIICacheKey<"my_app">
Capitalize<StringType>

문자열의 첫 문자를 대문자로 변환합니다.

type LowercaseGreeting = "hello, world";
// type Greeting = "Hello, world"
type Greeting = Capitalize<LowercaseGreeting>;
Uncapitalize<StringType>

문자열의 첫 문자를 소문자로 변환합니다.

type UppercaseGreeting = "HELLO WORLD";
// type UncomfortableGreeting = "hELLO WORLD"
type UncomfortableGreeting = Uncapitalize<UppercaseGreeting>;

Classes

다른 자바스크립트(JavaScript) 언어 기능과 마찬가지로, 타입스크립트(TypeScript)는 클래스와 다른 타입 간의 관계를 표현할 수 있도록 타입 어노테이션(type annotation)과 다른 구문들을 추가합니다.

Class Members

가장 기본적인 클래스는 아무것도 없는 빈 클래스입니다.

class Point {}

아직 이 클래스는 그다지 유용하지 않으므로, 이제 몇 가지 멤버를 추가해 보겠습니다.

Fields

필드(field) 선언은 클래스에 공개적으로 쓰기 가능한(public writeable) 프로퍼티를 생성합니다.

class Point {
  x: number;
  y: number;
}

const pt = new Point();
pt.x = 0;
pt.y = 0;

다른 곳에서와 마찬가지로 타입 어노테이션은 선택 사항이지만, 명시하지 않으면 암시적으로 any 타입이 됩니다.

필드는 초기화자(initializer)를 가질 수도 있으며, 이 초기화자는 클래스가 인스턴스화될 때 자동으로 실행됩니다.

class Point {
  x = 0;
  y = 0;
}

const, let, var와 마찬가지로, 클래스 프로퍼티의 초기화자는 해당 프로퍼티의 타입을 추론하는 데 사용됩니다.

const pt = new Point();
pt.x = "0";
// Type 'string' is not assignable to type 'number'.
// 오류: 'string' 타입은 'number' 타입에 할당할 수 없습니다.
--strictPropertyInitialization

strictPropertyInitialization 설정은 클래스 필드가 생성자(constructor)에서 초기화되어야 하는지를 제어합니다.

class BadGreeter {
  name: string;
  // Property 'name' has no initializer and is not definitely assigned in
  // the constructor.
  // 오류: 'name' 프로퍼티에 초기화자가 없으며 생성자에서 명확하게 할당되지 않았습니다.
}
class GoodGreeter {
  name: string;

  constructor() {
    this.name = "hello";
  }
}

필드는 반드시 생성자 자체에서 초기화되어야 한다는 점에 유의하세요. 타입스크립트는 생성자에서 호출하는 메서드까지 분석해 초기화를 감지하지는 않습니다. 파생 클래스가 해당 메서드를 오버라이드(override)해 멤버를 초기화하지 못할 수도 있기 때문입니다.

생성자가 아닌 다른 방법(예: 외부 라이브러리가 클래스의 일부를 채워주는 경우)으로 필드를 명확하게 초기화하려는 경우, ! 접미사를 사용해 타입스크립트에 알릴 수 있습니다.

class OKGreeter {
  // 초기화되지 않았지만, 느낌표(!)를 붙여 오류가 발생하지 않음
  name!: string;
}
readonly

필드는 readonly 변경자(modifier)를 앞에 붙일 수 있습니다. 이는 생성자 외부에서 해당 필드에 값을 할당하는 것을 막습니다.

class Greeter {
  readonly name: string = "world";

  constructor(otherName?: string) {
    if (otherName !== undefined) {
      this.name = otherName;
    }
  }

  err() {
    this.name = "not ok";
    // Cannot assign to 'name' because it is a read-only property.
    // 오류: 'name'은 읽기 전용 프로퍼티이므로 할당할 수 없습니다.
  }
}

const g = new Greeter();
g.name = "also not ok";
// Cannot assign to 'name' because it is a read-only property.
// 오류: 'name'은 읽기 전용 프로퍼티이므로 할당할 수 없습니다.
Constructors

Constructor (MDN)

클래스 생성자(constructor)는 함수와 매우 유사합니다. 타입 어노테이션(type annotation), 기본값(default value), 그리고 오버로드(overload)를 사용하여 매개변수를 추가할 수 있습니다.

class Point {
  x: number;
  y: number;

  // 일반적인 시그니처와 기본값
  constructor(x = 0, y = 0) {
    this.x = x;
    this.y = y;
  }
}
class Point {
  x: number = 0;
  y: number = 0;

  // 생성자 오버로드
  constructor(x: number, y: number);
  constructor(xy: string);
  constructor(x: string | number, y: number = 0) {
    // 여기에 코드 로직
  }
}

클래스 생성자 시그니처와 함수 시그니처 사이에는 몇 가지 차이점이 있습니다.

  • 생성자는 타입 매개변수(type parameter)를 가질 수 없습니다. 타입 매개변수는 외부 클래스 선언에 속하며, 이에 대해서는 나중에 배우게 될 것입니다.
  • 생성자는 반환 타입 어노테이션(return type annotation)을 가질 수 없습니다. 항상 클래스 인스턴스 타입이 반환됩니다.
Super Calls

자바스크립트(JavaScript)에서와 마찬가지로, 기본 클래스(base class)가 있다면 생성자 본문(body)에서 this 멤버를 사용하기 전에 super()를 호출해야 합니다.

class Base {
  k = 4;
}

class Derived extends Base {
  constructor() {
    // ES5에서는 잘못된 값을 출력하고, ES6에서는 예외를 발생시킴
    super();
    console.log(this.k);
  }
}

super 호출을 잊는 것은 자바스크립트에서 흔히 발생하는 실수이지만, 타입스크립트는 super가 필요할 때 알려줄 것입니다.

Methods

메서드 정의 (Method definitions)

클래스에 있는 함수 프로퍼티를 메서드(method)라고 부릅니다. 메서드는 함수나 생성자처럼 모든 타입 어노테이션을 동일하게 사용할 수 있습니다.

class Point {
  x = 10;
  y = 10;

  scale(n: number): void {
    this.x *= n;
    this.y *= n;
  }
}

표준적인 타입 어노테이션 외에, 타입스크립트는 메서드에 새로운 기능을 추가하지 않습니다.

메서드 본문 안에서는 필드나 다른 메서드에 접근할 때 반드시 this.를 사용해야 한다는 점에 유의하세요. 메서드 본문 안에서 한정자 없이 이름을 사용하면 항상 둘러싸고 있는 스코프(enclosing scope)의 변수를 참조하게 됩니다.

let x: number = 0;

class C {
  x: string = "hello";

  m() {
    // this.x는 string 타입의 프로퍼티를 참조합니다.
    this.x = "world";
  }
}
Getters / Setters

클래스는 접근자(accessor)를 가질 수도 있습니다.

class C {
  _length = 0;
  get length() {
    return this._length;
  }
  set length(value) {
    this._length = value;
  }
}

추가적인 로직이 없는, 필드를 뒷배경으로 하는(field-backed) get/set 쌍은 자바스크립트에서 거의 유용하지 않습니다. get/set 작업 중에 추가 로직이 필요 없다면 공개(public) 필드를 노출하는 것이 좋습니다.

타입스크립트는 접근자에 대해 몇 가지 특별한 추론 규칙을 가지고 있습니다.

  • get은 있지만 set이 없으면, 해당 프로퍼티는 자동으로 readonly가 됩니다.
  • setter 매개변수의 타입이 지정되지 않으면, getter의 반환 타입으로부터 추론됩니다.
  • gettersetter는 서로 다른 타입을 가질 수 있습니다.
class Thing {
  _size = 0;

  get size(): number {
    return this._size;
  }

  set size(value: string | number | boolean) {
    let num = Number(value);

    // NaN, Infinity, -Infinity는 허용하지 않음
    if (!Number.isFinite(num)) {
      this._size = 0;
      return;
    }

    this._size = num;
  }
}
Index Signatures

클래스는 인덱스 시그니처(index signature)를 선언할 수 있습니다. 이는 다른 객체 타입의 인덱스 시그니처와 동일하게 작동합니다.

class MyClass {
  [s: string]: boolean | ((s: string) => boolean);

  check(s: string) {
    return this[s] as boolean;
  }
}

인덱스 시그니처 타입은 메서드의 타입까지 포함해야 하므로, 이러한 타입을 유용하게 사용하기는 쉽지 않습니다. 일반적으로 인덱싱된 데이터는 클래스 인스턴스 자체보다는 다른 곳에 저장하는 것이 좋습니다.

Class Heritage

객체 지향 기능을 가진 다른 언어들처럼, 자바스크립트의 클래스도 기본 클래스(base class)로부터 상속받을 수 있습니다.

implements Clauses

implements 절을 사용해 클래스가 특정 인터페이스(interface)를 만족하는지 확인할 수 있습니다. 클래스가 인터페이스를 올바르게 구현하지 않으면 오류가 발생합니다.

interface Pingable {
  ping(): void;
}

class Sonar implements Pingable {
  ping() {
    console.log("ping!");
  }
}

class Ball implements Pingable {
  // Class 'Ball' incorrectly implements interface 'Pingable'.
  // Property 'ping' is missing in type 'Ball' but required in type 'Pingable'.
  // 오류: 'Ball' 클래스가 'Pingable' 인터페이스를 잘못 구현했습니다.
  // 'Ball' 타입에 'ping' 프로퍼티가 없지만 'Pingable' 타입에서는 필수입니다.
  pong() {
    console.log("pong!");
  }
}

클래스는 class C implements A, B와 같이 여러 인터페이스를 구현할 수도 있습니다.

Cautions

implements 절은 단지 클래스가 해당 인터페이스 타입으로 취급될 수 있는지를 확인하는 역할만 한다는 점을 이해하는 것이 중요합니다. 이 절은 클래스나 그 메서드의 타입을 전혀 변경하지 않습니다. 흔한 오류 중 하나는 implements 절이 클래스 타입을 변경할 것이라고 착각하는 것입니다. 실제로는 그렇지 않습니다!

interface Checkable {
  check(name: string): boolean;
}

class NameChecker implements Checkable {
  check(s) {
    // Parameter 's' implicitly has an 'any' type.
    // 오류: 매개변수 's'는 암묵적으로 'any' 타입을 가집니다.
    // 여기서 오류가 없는 것을 주목하세요.
    return s.toLowerCase() === "ok";
              any
  }
}

이 예제에서, 우리는 s의 타입이 checkname: string 매개변수에 의해 영향을 받을 것이라고 예상했을 수 있습니다. 하지만 그렇지 않습니다. implements 절은 클래스 본문이 검사되거나 타입이 추론되는 방식을 변경하지 않습니다.

마찬가지로, 선택적 프로퍼티(optional property)를 가진 인터페이스를 구현한다고 해서 그 프로퍼티가 생성되지는 않습니다.

interface A {
  x: number;
  y?: number;
}

class C implements A {
  x = 0;
}
const c = new C();
c.y = 10;
// Property 'y' does not exist on type 'C'.
// 오류: 'C' 타입에 'y' 프로퍼티가 존재하지 않습니다.
extends Clauses

extends 키워드 (MDN)

클래스는 기본 클래스(base class)로부터 확장(extend)할 수 있습니다. 파생 클래스(derived class)는 기본 클래스의 모든 프로퍼티와 메서드를 가지며, 추가적인 멤버를 정의할 수도 있습니다.

class Animal {
  move() {
    console.log("Moving along!");
  }
}

class Dog extends Animal {
  woof(times: number) {
    for (let i = 0; i < times; i++) {
      console.log("woof!");
    }
  }
}

const d = new Dog();
// 기본 클래스 메서드
d.move();
// 파생 클래스 메서드
d.woof(3);
Overriding Methods

파생 클래스는 기본 클래스의 필드나 프로퍼티를 오버라이드(override)할 수도 있습니다. super. 구문을 사용해 기본 클래스의 메서드에 접근할 수 있습니다. 자바스크립트 클래스는 단순한 조회 객체(lookup object)이기 때문에 “슈퍼 필드(super field)”라는 개념은 없습니다.

super 키워드 (MDN)

타입스크립트는 파생 클래스가 항상 기본 클래스의 서브타입(subtype)이 되도록 강제합니다.

예를 들어, 다음은 메서드를 합법적으로 오버라이드하는 방법입니다.

class Base {
  greet() {
    console.log("Hello, world!");
  }
}

class Derived extends Base {
  greet(name?: string) {
    if (name === undefined) {
      super.greet();
    } else {
      console.log(`Hello, ${name.toUpperCase()}`);
    }
  }
}

const d = new Derived();
d.greet();
d.greet("reader");

파생 클래스가 기본 클래스의 계약(contract)을 따르는 것이 중요합니다. 파생 클래스 인스턴스를 기본 클래스 참조를 통해 참조하는 것은 매우 흔하며 항상 합법적이라는 것을 기억하세요.

// 별칭(alias)을 통해 기본 클래스 참조로 객체를 생성
const b: Base = new Derived();
// 문제 없음
b.greet();

DerivedBase의 계약을 따르지 않으면 어떻게 될까요?

class Base {
  greet() {
    console.log("Hello, world!");
  }
}

class Derived extends Base {
  // 이 매개변수를 필수로 만듦
  greet(name: string) {
    // Property 'greet' in type 'Derived' is not assignable to the same property in base type 'Base'.
    //   Type '(name: string) => void' is not assignable to type '() => void'.
    // 오류: 'Derived' 타입의 'greet' 프로퍼티는 기본 타입 'Base'의 동일한 프로퍼티에 할당할 수 없습니다.
    //   '(name: string) => void' 타입은 '() => void' 타입에 할당할 수 없습니다.
    console.log(`Hello, ${name.toUpperCase()}`);
  }
}

오류에도 불구하고 이 코드를 컴파일하면, 다음 샘플은 충돌을 일으킬 것입니다.

const b: Base = new Derived();
// "name"이 undefined가 되기 때문에 충돌 발생
b.greet();
Type-only Field Declarations

컴파일 대상(target)이 ES2022 이상이거나 useDefineForClassFieldstrue일 때, 클래스 필드는 부모 클래스 생성자가 완료된 후에 초기화되며, 부모 클래스에서 설정된 모든 값을 덮어씁니다. 이는 상속된 필드에 대해 더 정확한 타입을 다시 선언하고자 할 때 문제가 될 수 있습니다.

이런 경우를 처리하기 위해 declare를 작성해 타입스크립트에 이 필드 선언이 런타임에 아무런 영향을 미치지 않아야 함을 알릴 수 있습니다.

interface Animal {
  breed: any;
}

class AnimalHouse {
  resident: Animal;
  constructor(animal: Animal) {
    this.resident = animal;
  }
}

class DogHouse extends AnimalHouse {
  // 자바스크립트 코드를 생성하지 않음,
  // 단지 타입이 올바른지 확인만 함
  declare resident: Dog;
  constructor(dog: Dog) {
    super(dog);
  }
}
Initialization Order

자바스크립트 클래스가 초기화되는 순서는 몇몇 경우에 놀라울 수 있습니다. 다음 코드를 살펴봅시다.

class Base {
  name = "base";
  constructor() {
    console.log("My name is " + this.name);
  }
}

class Derived extends Base {
  name = "derived";
}

// "derived"가 아닌 "base"를 출력
const d = new Derived();

어떤 일이 일어나는지 순서대로 보면 다음과 같습니다.

  1. 기본 클래스 필드가 초기화됩니다. (name = “base”)
  2. 기본 클래스 생성자가 실행됩니다. (이때 this.name은 “base”이므로, “My name is base”가 출력됩니다.)
  3. 파생 클래스 필드가 초기화됩니다. (name = “derived”)
  4. 파생 클래스 생성자가 실행됩니다.

이는 기본 클래스 생성자가 자신의 생성자 내에서 자신의 name 값을 보았다는 의미입니다. 파생 클래스의 필드 초기화가 아직 실행되지 않았기 때문입니다.

Inheriting Built-in Types

Array, Error, Map 등과 같은 내장 타입으로부터 상속할 계획이 없거나, 컴파일 대상이 명시적으로 ES6/ES2015 이상으로 설정된 경우 이 섹션을 건너뛸 수 있습니다.

ES2015에서, 객체를 반환하는 생성자는 super(...) 호출자에 대해 암묵적으로 this의 값을 대체합니다. 생성된 생성자 코드는 super(...)의 잠재적인 반환 값을 캡처하여 this로 교체해야 합니다.

결과적으로 Error, Array 등을 서브클래싱하는 것이 더 이상 예상대로 작동하지 않을 수 있습니다. 이는 Error, Array 등과 같은 생성자 함수가 프로토타입 체인을 조정하기 위해 ECMAScript 6의 new.target을 사용하기 때문입니다. 하지만 ECMAScript 5에서 생성자를 호출할 때 new.target의 값을 보장할 방법이 없습니다. 다른 다운레벨 컴파일러들도 기본적으로 동일한 제한을 가집니다.

class MsgError extends Error {
  constructor(m: string) {
    super(m);
  }
  sayHello() {
    return "hello " + this.message;
  }
}

다음과 같은 문제를 발견할 수 있습니다.

  • sayHello와 같은 메서드가 new MsgError()에 의해 반환된 객체에 정의되어 있지 않을 수 있습니다.
  • 따라서 new MsgError() instanceof MsgErrorfalse를 반환할 수 있습니다.

권장 사항으로, super(...) 호출 직후에 수동으로 프로토타입을 조정할 수 있습니다.

class MsgError extends Error {
  constructor(m: string) {
    super(m);

    // 프로토타입을 명시적으로 설정합니다.
    Object.setPrototypeOf(this, MsgError.prototype);
  }

  sayHello() {
    return "hello " + this.message;
  }
}

하지만, MsgError의 모든 서브클래스 또한 수동으로 프로토타입을 설정해야 합니다. Object.setPrototypeOf를 지원하지 않는 런타임에서는 대신 __proto__를 사용할 수도 있습니다.

불행히도 이러한 해결 방법은 Internet Explorer 10 및 이전 버전에서는 작동하지 않습니다. 프로토타입의 메서드를 인스턴스 자체에 수동으로 복사할 수 있지만(즉, MsgError.prototypethis에), 프로토타입 체인 자체는 수정할 수 없습니다.

Member Visibility

타입스크립트를 사용해 특정 메서드나 프로퍼티가 클래스 외부의 코드에 노출될지 여부를 제어할 수 있습니다.

public

클래스 멤버의 기본 가시성은 public입니다. public 멤버는 어디에서나 접근할 수 있습니다.

class Greeter {
  public greet() {
    console.log("hi!");
  }
}
const g = new Greeter();
g.greet();

public은 이미 기본 가시성 변경자(modifier)이므로 클래스 멤버에 굳이 작성할 필요는 없지만, 스타일이나 가독성을 위해 선택적으로 사용할 수 있습니다.

protected

protected 멤버는 해당 멤버가 선언된 클래스의 서브클래스(subclass) 내에서만 접근할 수 있습니다.

class Greeter {
  public greet() {
    console.log("Hello, " + this.getName());
  }
  protected getName() {
    return "hi";
  }
}

class SpecialGreeter extends Greeter {
  public howdy() {
    // 여기서 protected 멤버에 접근하는 것은 OK
    console.log("Howdy, " + this.getName());
  }
}
const g = new SpecialGreeter();
g.greet(); // OK
g.getName();
// Property 'getName' is protected and only accessible within class 'Greeter' and its subclasses.
// 오류: 'getName' 프로퍼티는 protected이며 'Greeter' 클래스 및 그 서브클래스 내에서만 접근할 수 있습니다.
Protected Members Exposure

파생 클래스는 기본 클래스의 계약을 따라야 하지만, 더 많은 기능을 가진 기본 클래스의 서브타입을 노출하도록 선택할 수 있습니다. 여기에는 protected 멤버를 public으로 만드는 것이 포함됩니다.

class Base {
  protected m = 10;
}
class Derived extends Base {
  // 변경자가 없으면 public이 기본값
  m = 15;
}
const d = new Derived();
console.log(d.m); // OK

Derived는 이미 m을 자유롭게 읽고 쓸 수 있었으므로, 이 상황이 “보안”을 의미 있게 변경하지는 않는다는 점에 유의하세요. 여기서 주목할 점은, 이러한 노출이 의도적이지 않은 경우 파생 클래스에서 protected 변경자를 반복해서 사용하도록 주의해야 한다는 것입니다.

Cross-hierarchy protected access

다른 언어(예: C++, C#, Java)와 달리, 타입스크립트에서는 파생 클래스가 동일한 기본 클래스에서 파생된 다른 클래스의 protected 멤버에 접근할 수 없습니다.

class Base {
  protected x: number = 1;
}
class Derived1 extends Base {
  protected x: number = 5;
}
class Derived2 extends Base {
  f1(other: Derived2) {
    other.x = 10; // OK
  }
  f2(other: Derived1) {
    other.x = 10; // 오류!
    // Property 'x' is protected and only accessible within class 'Derived1' and its subclasses.
    // 오류: 'x' 프로퍼티는 protected이며 'Derived1' 클래스 및 그 서브클래스 내에서만 접근할 수 있습니다.
  }
}

private

privateprotected와 유사하지만, 서브클래스에서조차 멤버에 접근하는 것을 허용하지 않습니다.

class Base {
  private x = 0;
}
const b = new Base();
// 클래스 외부에서 접근할 수 없음
console.log(b.x);
// Property 'x' is private and only accessible within class 'Base'.
// 오류: 'x' 프로퍼티는 private이며 'Base' 클래스 내에서만 접근할 수 있습니다.

class Derived extends Base {
  showX() {
    // 서브클래스에서도 접근할 수 없음
    console.log(this.x);
    // Property 'x' is private and only accessible within class 'Base'.
    // 오류: 'x' 프로퍼티는 private이며 'Base' 클래스 내에서만 접근할 수 있습니다.
  }
}

private 멤버는 파생 클래스에 보이지 않으므로, 파생 클래스가 그 가시성을 높일 수 없습니다.

class Base {
  private x = 0;
}
class Derived extends Base {
  // Class 'Derived' incorrectly extends base class 'Base'.
  //   Property 'x' is private in type 'Base' but not in type 'Derived'.
  // 오류: 'Derived' 클래스가 'Base' 클래스를 잘못 확장했습니다.
  //   프로퍼티 'x'는 'Base' 타입에서는 private이지만 'Derived' 타입에서는 그렇지 않습니다.
  x = 1;
}
Cross-instance private access

TypeScript는 같은 클래스의 다른 인스턴스의 private 멤버에 접근하는 것을 허용합니다.

class A {
  private x = 10;

  public sameAs(other: A) {
    // 오류 없음
    return other.x === this.x;
  }
}
Caveats

타입스크립트의 다른 타입 시스템 요소들처럼, privateprotected는 타입 검사 중에만 강제됩니다.

이는 in 연산자나 단순 프로퍼티 조회와 같은 자바스크립트 런타임 구조는 여전히 private 또는 protected 멤버에 접근할 수 있음을 의미합니다.

class MySafe {
  private secretKey = 12345;
}

// 자바스크립트 파일에서...
const s = new MySafe();
// 12345를 출력합니다
console.log(s.secretKey);

private는 타입 검사 중 대괄호 표기법([])을 사용한 접근도 허용합니다. 이로 인해 private으로 선언된 필드들은 단위 테스트 같은 작업에서 접근하기 더 쉬울 수 있지만, 이 필드들이 엄격한 프라이버시를 강제하지 않는 “소프트 프라이빗(soft private)”이라는 단점이 있습니다.

const s = new MySafe();
// 타입 검사 중에는 허용되지 않음
console.log(s.secretKey);
// Property 'secretKey' is private and only accessible within class 'MySafe'.
// 오류: 'secretKey'는 private이며 'MySafe' 클래스 내에서만 접근할 수 있습니다.

// OK
console.log(s["secretKey"]);

타입스크립트의 private와 달리, 자바스크립트의 비공개 필드 (#)는 컴파일 후에도 비공개로 유지되며 앞서 언급한 대괄호 표기법 접근과 같은 탈출구를 제공하지 않아 하드 프라이빗(hard private)입니다.

ES2021 이하 버전으로 컴파일할 때, 타입스크립트는 # 대신 WeakMap을 사용합니다.

악의적인 행위자로부터 클래스의 값을 보호해야 한다면, 클로저(closures), WeakMap, 또는 비공개 필드(#)와 같이 강력한 런타임 프라이버시를 제공하는 메커니즘을 사용해야 합니다. 이러한 런타임 프라이버시 검사는 성능에 영향을 줄 수 있다는 점을 유의하세요.

Static Members

정적 멤버 (Static Members) (MDN)

클래스는 정적(static) 멤버를 가질 수 있습니다. 이 멤버들은 클래스의 특정 인스턴스와 연관되지 않습니다. 대신, 클래스 생성자 객체 자체를 통해 접근할 수 있습니다.

class MyClass {
  static x = 0;
  static printX() {
    console.log(MyClass.x);
  }
}

console.log(MyClass.x);
MyClass.printX();

정적 멤버 또한 public, protected, private 가시성 변경자를 사용할 수 있습니다.

class MyClass {
  private static x = 0;
}
console.log(MyClass.x);
// Property 'x' is private and only accessible within class 'MyClass'.
// 오류: 'x' 프로퍼티는 private이며 'MyClass' 클래스 내에서만 접근할 수 있습니다.

정적 멤버는 상속됩니다.

class Base {
  static getGreeting() {
    return "Hello world";
  }
}
class Derived extends Base {
  myGreeting = Derived.getGreeting();
}

Special Static Names

Function 프로토타입의 프로퍼티를 덮어쓰는 것은 일반적으로 안전하지 않거나 불가능합니다. 클래스 자체가 new로 호출될 수 있는 함수이기 때문에, 특정 정적 이름은 사용할 수 없습니다. name, length, call과 같은 함수 프로퍼티는 정적 멤버로 정의할 수 없습니다.

class S {
  static name = "S!";
  // Static property 'name' conflicts with built-in property 'Function.name' of constructor function 'S'.
  // 오류: 정적 프로퍼티 'name'이 생성자 함수 'S'의 내장 프로퍼티 'Function.name'과 충돌합니다.
}

Why No Static Classes?

타입스크립트(와 자바스크립트)는 C#과 같은 언어에 있는 static class와 같은 구문이 없습니다. 이러한 구문은 해당 언어들이 모든 데이터와 함수가 클래스 내부에 있도록 강제하기 때문에 존재합니다. 타입스크립트에는 그런 제약이 없기 때문에 정적 클래스가 필요 없습니다.

단일 인스턴스만 있는 클래스는 일반적으로 자바스크립트/타입스크립트에서 일반 객체로 표현됩니다. 예를 들어, 타입스크립트에서는 일반 객체(또는 최상위 함수)가 동일한 역할을 잘 수행하므로 “정적 클래스” 구문이 필요하지 않습니다.

// 불필요한 "정적" 클래스
class MyStaticClass {
  static doSomething() {}
}

// 선호되는 방식 (대안 1: 네임스페이스)
export namespace MyHelper {
  export function doSomething() {}
}

// 선호되는 방식 (대안 2: 일반 객체)
const MyHelperObject = {
  doSomething() {},
};

static Blocks in Classes

정적 블록(static 블록)을 사용하면, 포함하는 클래스 내의 비공개 필드에 접근할 수 있는 자체 스코프를 가진 문장 시퀀스를 작성할 수 있습니다. 이는 변수 유출 없이, 문장 작성의 모든 기능을 갖추고 클래스 내부에 완전히 접근할 수 있는 초기화 코드를 작성할 수 있음을 의미합니다.

class Foo {
  static #count = 0;

  get count() {
    return Foo.#count;
  }

  static {
    try {
      const lastInstances = loadLastInstances();
      Foo.#count += lastInstances.length;
    }
    catch {}
  }
}

Generic Classes

클래스는 인터페이스와 마찬가지로 제네릭(generic)이 될 수 있습니다. 제네릭 클래스가 new로 인스턴스화될 때, 그 타입 매개변수는 함수 호출에서와 동일한 방식으로 추론됩니다.

class Box<Type> {
  contents: Type;
  constructor(value: Type) {
    this.contents = value;
  }
}

const b = new Box("hello!");
//    ^ const b: Box<string>

클래스는 인터페이스와 동일한 방식으로 제네릭 제약 조건(generic constraints)과 기본값(defaults)을 사용할 수 있습니다.

Type Parameters in Static Members

다음 코드는 왜 안 되는지 명확하지 않을 수 있지만, 올바르지 않은 코드입니다.

class Box<Type> {
  static defaultValue: Type;
  // Static members cannot reference class type parameters.
  // 오류: 정적 멤버는 클래스 타입 매개변수를 참조할 수 없습니다.
}

타입은 항상 완전히 지워진다는 것을 기억하세요! 런타임에는 오직 하나의 Box.defaultValue 프로퍼티 슬롯만 존재합니다. 이는 만약 가능하다면 Box<string>.defaultValue를 설정하는 것이 Box<number>.defaultValue도 변경하게 된다는 의미이며, 이는 바람직하지 않습니다. 제네릭 클래스의 정적 멤버는 절대로 클래스의 타입 매개변수를 참조할 수 없습니다.

this at Runtime in Classes

this 키워드 (MDN)

타입스크립트는 자바스크립트의 런타임 동작을 변경하지 않는다는 점과, 자바스크립트가 몇몇 특이한 런타임 동작으로 유명하다는 점을 기억하는 것이 중요합니다.

자바스크립트의 this 처리는 실제로 특이합니다.

class MyClass {
  name = "MyClass";
  getName() {
    return this.name;
  }
}
const c = new MyClass();
const obj = {
  name: "obj",
  getName: c.getName,
};

// "MyClass"가 아닌 "obj"를 출력합니다.
console.log(obj.getName());

간단히 말해, 기본적으로 함수 내부의 this 값은 함수가 어떻게 호출되었는지에 따라 달라집니다. 이 예제에서는 함수가 obj 참조를 통해 호출되었기 때문에, this의 값은 클래스 인스턴스가 아닌 obj였습니다.

이것은 거의 원하는 동작이 아닐 겁니다! 타입스크립트는 이런 종류의 오류를 완화하거나 방지할 몇 가지 방법을 제공합니다.

Arrow Functions

화살표 함수 (MDN)

this 컨텍스트를 잃어버리는 방식으로 자주 호출될 함수가 있다면, 메서드 정의 대신 화살표 함수 프로퍼티를 사용하는 것이 합리적일 수 있습니다.

class MyClass {
  name = "MyClass";
  getName = () => {
    return this.name;
  };
}

const c = new MyClass();
const g = c.getName;
// 충돌하는 대신 "MyClass"를 출력합니다.
console.log(g());

이 방법에는 몇 가지 장단점이 있습니다.

  • this 값은 타입스크립트로 확인되지 않은 코드에 대해서도 런타임에 올바른 값이 보장됩니다.
  • 이 기법은 메모리와 성능에 영향을 미칩니다. 각 클래스 인스턴스마다 자체적으로 화살표 함수의 복사본을 갖게 되기 때문입니다.
  • 프로토타입 체인에 기본 클래스 메서드를 가져올 항목이 없기 때문에, 파생 클래스에서 super.getName을 사용할 수 없습니다.

this parameters

메서드나 함수 정의에서 this라는 이름의 첫 번째 매개변수는 타입스크립트에서 특별한 의미를 가집니다. 이 매개변수들은 컴파일 중에 지워집니다.

// 'this' 매개변수가 있는 타입스크립트 입력
function fn(this: SomeType, x: number) {
  /* ... */
}

// 자바스크립트 출력
function fn(x) {
  /* ... */
}

타입스크립트는 this 매개변수를 가진 함수가 올바른 컨텍스트로 호출되는지 확인합니다. 화살표 함수를 사용하는 대신, 메서드 정의에 this 매개변수를 추가하여 메서드가 올바르게 호출되도록 정적으로 강제할 수 있습니다.

class MyClass {
  name = "MyClass";
  getName(this: MyClass) {
    return this.name;
  }
}
const c = new MyClass();
// OK
c.getName();

// 오류 발생, 런타임에 충돌함
const g = c.getName;
console.log(g());
// The 'this' context of type 'void' is not assignable to method's 'this' of type 'MyClass'.

이 방법은 화살표 함수 접근 방식과 정반대의 장단점을 가집니다.

  • 자바스크립트 호출자는 여전히 자신도 모르게 클래스 메서드를 잘못 사용할 수 있습니다.
  • 클래스 인스턴스마다 하나가 아닌, 클래스 정의당 하나의 함수만 할당됩니다.
  • 기본 메서드 정의는 여전히 super를 통해 호출할 수 있습니다.
this Types

클래스에서 this라는 특수한 타입은 현재 클래스의 타입을 동적으로 참조합니다. 이것이 어떻게 유용한지 살펴봅시다.

class Box {
  contents: string = "";
  set(value: string) {
    // (method) Box.set(value: string): this
    this.contents = value;
    return this;
  }
}

여기서 타입스크립트는 set의 반환 타입을 Box가 아닌 this로 추론했습니다. 이제 Box의 서브클래스를 만들어 봅시다.

class ClearableBox extends Box {
  clear() {
    this.contents = "";
  }
}

const a = new ClearableBox();
const b = a.set("hello");
//    ^ const b: ClearableBox

setthis를 반환하기 때문에, 파생 클래스의 인스턴스에서 set을 호출하면 파생 클래스의 타입이 반환됩니다. 이 덕분에 유연하게 메서드 체이닝(method chaining)을 사용할 수 있습니다.

또한 this를 매개변수의 타입 어노테이션으로 사용할 수도 있습니다.

class Box {
  content: string = "";
  sameAs(other: this) {
    return other.content === this.content;
  }
}

이것은 other: Box라고 작성하는 것과는 다릅니다. 파생 클래스가 있다면, 그 클래스의 sameAs 메서드는 이제 동일한 파생 클래스의 다른 인스턴스만 받게 됩니다.

class Box {
  content: string = "";
  sameAs(other: this) {
    return other.content === this.content;
  }
}

class DerivedBox extends Box {
  otherContent: string = "?";
}

const base = new Box();
const derived = new DerivedBox();
derived.sameAs(base);
// Argument of type 'Box' is not assignable to parameter of type 'DerivedBox'.
//   Property 'otherContent' is missing in type 'Box' but required in type 'DerivedBox'.
// 오류: 'Box' 타입의 인자는 'DerivedBox' 타입의 매개변수에 할당할 수 없습니다.
//   'Box' 타입에 'otherContent' 프로퍼티가 없지만 'DerivedBox' 타입에서는 필수입니다.
this-based type guards

클래스와 인터페이스의 메서드 반환 위치에서 this is Type 구문을 사용할 수 있습니다. 타입 좁히기(type narrowing, 예: if 문)와 함께 사용하면, 대상 객체의 타입이 지정된 Type으로 좁혀집니다.

class FileSystemObject {
  isFile(): this is FileRep {
    return this instanceof FileRep;
  }
  isDirectory(): this is Directory {
    return this instanceof Directory;
  }
  isNetworked(): this is Networked & this {
    return this.networked;
  }
  constructor(public path: string, private networked: boolean) {}
}

class FileRep extends FileSystemObject {
  constructor(path: string, public content: string) {
    super(path, false);
  }
}

class Directory extends FileSystemObject {
  children: FileSystemObject[];
}

interface Networked {
  host: string;
}

const fso: FileSystemObject = new FileRep("foo/bar.txt", "foo");

if (fso.isFile()) {
  fso.content;
  // const fso: FileRep
} else if (fso.isDirectory()) {
  fso.children;
  // const fso: Directory
} else if (fso.isNetworked()) {
  fso.host;
  // const fso: Networked & FileSystemObject
}

this 기반 타입 가드의 일반적인 용례는 제네릭 클래스의 특정 필드에 대한 지연 유효성 검사(lazy validation)입니다. 예를 들어, 이 BoxhasValuetrue임이 확인되었을 때만 value가 존재함을 보장합니다.

class Box<T> {
  value?: T;

  hasValue(): this is { value: T } {
    return this.value !== undefined;
  }
}

const box = new Box<string>();
box.value = "Gameboy";

box.value;
// (property) Box<string>.value?: string

if (box.hasValue()) {
  box.value;
  // (property) value: string
}

Parameter Properties

타입스크립트는 생성자 매개변수를 동일한 이름과 값을 가진 클래스 프로퍼티로 변환하는 특별한 구문을 제공합니다. 이를 매개변수 속성(parameter properties)이라 하며, 생성자 인자 앞에 가시성 변경자(public, private, protected)나 readonly 중 하나를 붙여 생성합니다. 결과적으로 생성된 필드는 해당 변경자를 갖게 됩니다.

class Params {
  constructor(
    public readonly x: number,
    protected y: number,
    private z: number
  ) {
    // 본문 필요 없음
  }
}
const a = new Params(1, 2, 3);
console.log(a.x);
// (property) Params.x: number

console.log(a.z);
// Property 'z' is private and only accessible within class 'Params'.
// 오류: 'z' 프로퍼티는 private이며 'Params' 클래스 내에서만 접근할 수 있습니다.

Class Expressions

클래스 표현식 (MDN)

클래스 표현식(Class expression)은 클래스 선언과 매우 유사합니다. 유일한 실질적인 차이점은 클래스 표현식은 이름이 필요 없다는 점입니다. 물론 이름이 할당된 식별자를 통해 참조할 수는 있습니다.

const someClass = class<Type> {
  content: Type;
  constructor(value: Type) {
    this.content = value;
  }
};

const m = new someClass("Hello, world");
//    ^ const m: someClass<string>

Constructor Signatures

자바스크립트 클래스는 new 연산자로 인스턴스화됩니다. 클래스 자체의 타입을 고려할 때, InstanceType 유틸리티 타입은 이 작업을 모델링합니다.

class Point {
  createdAt: number;
  x: number;
  y: number
  constructor(x: number, y: number) {
    this.createdAt = Date.now()
    this.x = x;
    this.y = y;
  }
}
type PointInstance = InstanceType<typeof Point>;

function moveRight(point: PointInstance) {
  point.x += 5;
}

const point = new Point(3, 4);
moveRight(point);
point.x; // => 8

abstract Classes and Members

타입스크립트에서 클래스, 메서드, 필드는 추상적(abstract)일 수 있습니다.

추상 메서드(abstract method) 또는 추상 필드(abstract field)는 구현이 제공되지 않은 것을 말합니다. 이러한 멤버들은 추상 클래스(abstract class) 내부에 존재해야 하며, 추상 클래스는 직접 인스턴스화할 수 없습니다.

추상 클래스의 역할은 모든 추상 멤버를 구현하는 서브클래스의 기본 클래스가 되는 것입니다. 클래스에 추상 멤버가 하나도 없으면, 이를 구상 클래스(concrete class)라고 합니다.

예를 살펴보겠습니다.

abstract class Base {
  abstract getName(): string;

  printName() {
    console.log("Hello, " + this.getName());
  }
}

const b = new Base();
// Cannot create an instance of an abstract class.
// 오류: 추상 클래스의 인스턴스를 생성할 수 없습니다.

추상 클래스를 사용하려면, 이를 상속받고 모든 추상 멤버를 구현해야 합니다.

class Derived extends Base {
  getName() {
    return "world";
  }
}

const d = new Derived();
d.printName();

기본 클래스의 추상 멤버를 구현하는 것을 잊으면 오류가 발생합니다.

class Derived extends Base {
  // Non-abstract class 'Derived' does not implement inherited abstract
  // member 'getName' from class 'Base'.
  // 오류: 비-추상 클래스 'Derived'가 'Base' 클래스로부터 상속된 추상 멤버 'getName'을 구현하지 않았습니다.

  // 아무것도 구현하는 것을 잊음
}

Abstract Construct Signatures

때로는 추상 클래스에서 파생된 클래스의 인스턴스를 생성하는 클래스 생성자 함수를 받고자 할 수 있습니다. 예를 들어, 다음과 같은 코드를 작성하고 싶을 수 있습니다.

function greet(ctor: typeof Base) {
  const instance = new ctor();
  // Cannot create an instance of an abstract class.
  // 오류: 추상 클래스의 인스턴스를 생성할 수 없습니다.
  instance.printName();
}

타입스크립트는 당신이 추상 클래스를 인스턴스화하려고 시도하고 있음을 올바르게 알려줍니다. greet의 정의를 보면, 추상 클래스를 생성하게 될 다음 코드를 작성하는 것이 완벽하게 합법적이기 때문입니다.

// greet(Base)는 유효하지 않음
greet(Base);

대신, 생성자 시그니처(construct signature)를 가진 것을 받는 함수를 작성해야 합니다.

function greet(ctor: new () => Base) {
  const instance = new ctor();
  instance.printName();
}
greet(Derived);
greet(Base);
// Argument of type 'typeof Base' is not assignable to parameter of type 'new () => Base'.
//   Cannot assign an abstract constructor type to a non-abstract constructor type.
// 오류: 'typeof Base' 타입의 인자는 'new () => Base' 타입의 매개변수에 할당할 수 없습니다.
//   추상 생성자 타입은 비-추상 생성자 타입에 할당할 수 없습니다.

이제 타입스크립트는 어떤 클래스 생성자 함수가 호출될 수 있는지 정확하게 알려줍니다. Derived는 구상 클래스이므로 가능하지만, Base는 추상 클래스이므로 불가능합니다.

Relationships Between Classes

대부분의 경우, 타입스크립트의 클래스는 다른 타입들과 마찬가지로 구조적(structurally)으로 비교됩니다.

예를 들어, 다음 두 클래스는 동일하기 때문에 서로 대체하여 사용할 수 있습니다.

class Point1 {
  x = 0;
  y = 0;
}

class Point2 {
  x = 0;
  y = 0;
}

// OK
const p: Point1 = new Point2();

마찬가지로, 클래스 간의 서브타입 관계도 멤버와 상관없이 존재합니다.

class Person {
  name: string;
  age: number;
}

class Employee {
  name: string;
  age: number;
  salary: number;
}

// OK
const p: Person = new Employee();

이것은 간단하게 들리지만, 다른 경우보다 더 이상하게 보이는 몇 가지 사례가 있습니다.

멤버가 없는 빈 클래스는 구조적 타입 시스템에서 멤버가 없는 타입은 일반적으로 다른 모든 것의 슈퍼타입(supertype)입니다. 따라서 빈 클래스를 작성하면(작성하지 마세요!), 어떤 것이든 그 자리에 사용될 수 있습니다.

class Empty {}

function fn(x: Empty) {
  // 'x'로 아무것도 할 수 없으므로, 아무것도 하지 않음
}

// 모두 OK!
fn(window);
fn({});
fn(fn);

21. Modules

자바스크립트(JavaScript)는 코드를 모듈화하는 다양한 방법에 대한 오랜 역사를 가집니다. 2012년부터 존재해온 타입스크립트(TypeScript)는 이러한 여러 포맷에 대한 지원을 구현해왔지만, 시간이 지나면서 커뮤니티와 자바스크립트 명세는 ES 모듈(또는 ES6 모듈)이라는 포맷으로 통합되었습니다. 여러분은 아마 import/export 구문으로 알고 계실 것입니다.

ES 모듈은 2015년에 자바스크립트 명세에 추가되었으며, 2020년까지 대부분의 웹 브라우저와 자바스크립트 런타임에서 폭넓게 지원되었습니다. 이 핸드북에서는 ES 모듈과 그 이전의 대중적인 포맷인 CommonJS의 module.exports = 구문을 중점적으로 다룰 것입니다. 다른 모듈 패턴에 대한 정보는 참고 섹션의 모듈 항목에서 찾아볼 수 있습니다.

How JavaScript Modules are Defined

타입스크립트에서는 ECMAScript 2015와 마찬가지로, 최상위 레벨에 import 또는 export 문을 포함하는 모든 파일은 모듈로 간주합니다.

모듈은 전역 스코프(global scope)가 아닌 자체 스코프 내에서 실행됩니다. 이는 모듈 안에서 선언된 변수, 함수, 클래스 등은 export 구문 중 하나를 사용해 명시적으로 내보내지(export) 않는 한 모듈 외부에서 보이지 않는다는 의미입니다. 반대로, 다른 모듈에서 내보낸 변수, 함수, 클래스, 인터페이스 등을 사용하려면 import 구문 중 하나를 사용해 가져와야(import) 합니다.

Non-modules

시작하기에 앞서, 타입스크립트가 무엇을 모듈로 간주하는지 이해하는 것이 중요합니다. 자바스크립트 명세는 import 선언, export, 또는 최상위 await가 없는 모든 자바스크립트 파일을 모듈이 아닌 스크립트(script)로 간주해야 한다고 명시합니다.

스크립트 파일 내의 변수와 타입은 공유된 전역 스코프에 선언되며, 여러분은 outFile 컴파일러 옵션을 사용하여 여러 입력 파일을 하나의 출력 파일로 합치거나, HTML에서 여러 개의 <script> 태그를 사용하여 이 파일들을 (올바른 순서로!) 로드할 것이라고 가정합니다.

현재 importexport가 없는 파일을 모듈로 취급하고 싶다면, 다음 한 줄을 추가하세요:

export {};

이 코드는 해당 파일을 아무것도 내보내지 않는 모듈로 변경합니다. 이 구문은 모듈 타겟(module target)에 관계없이 동작합니다.

Modules in TypeScript

타입스크립트에서 모듈 기반 코드를 작성할 때 고려해야 할 세 가지 주요 사항이 있습니다:

  • 구문(Syntax): 어떤 구문을 사용하여 import하고 export할 것인가?
  • 모듈 해석(Module Resolution): 모듈 이름(또는 경로)과 디스크 상의 파일 간의 관계는 무엇인가?
  • 모듈 출력 타겟(Module Output Target): 내가 생성할 자바스크립트 모듈은 어떤 형태여야 하는가?

ES 모듈 구문 (ES Module Syntax)

파일은 export default를 통해 주된 내보내기(main export)를 선언할 수 있습니다:

// @filename: hello.ts
export default function helloWorld() {
  console.log("Hello, world!");
}

이것은 다음을 통해 가져올 수 있습니다:

import helloworld from "./hello.js";
helloWorld();

기본 내보내기(default export) 외에도, default를 생략한 export를 통해 여러 개의 변수와 함수를 내보낼 수 있습니다:

// @filename: maths.ts
export var pi = 3.14;
export let squareTwo = 1.41;
export const phi = 1.61;

export class RandomNumberGenerator {}

export function absolute(num: number) {
  if (num < 0) return num * -1;
  return num;
}

이들은 다른 파일에서 import 구문을 통해 사용할 수 있습니다:

import { pi, phi, absolute } from "./maths.js";

console.log(pi);
const absPhi = absolute(phi);

Additional Import Syntax

import { old as new }와 같은 형식을 사용해 import의 이름을 변경할 수 있습니다:

import { pi as π } from "./maths.js";

console.log(π);

위의 구문들을 하나의 import 문으로 혼합해 사용할 수 있습니다:

// @filename: maths.ts
export const pi = 3.14;
export default class RandomNumberGenerator {}

// @filename: app.ts
import RandomNumberGenerator, { pi as π } from "./maths.js";

RandomNumberGenerator;
console.log(π);

* as name 구문을 사용해 내보내진 모든 객체를 가져와 하나의 네임스페이스(namespace)에 넣을 수 있습니다:

// @filename: app.ts
import * as math from "./maths.js";

console.log(math.pi);
const positivePhi = math.absolute(math.phi);

파일을 import하되 현재 모듈에 어떤 변수도 포함시키지 않으려면 import "./file"을 사용합니다:

// @filename: app.ts
import "./maths.js";

console.log("3.14");

이 경우, import는 아무것도 하지 않습니다. 하지만 maths.ts 안의 모든 코드가 평가되어, 다른 객체에 영향을 미치는 부수 효과(side-effects)를 발생시킬 수 있습니다.

TypeScript Specific ES Module Syntax

타입(Type)은 자바스크립트 값(value)과 동일한 구문을 사용하여 내보내고 가져올 수 있습니다:

// @filename: animal.ts
export type Cat = { breed: string; yearOfBirth: number };
export interface Dog {
  breeds: string[];
  yearOfBirth: number;
}

// @filename: app.ts
import { Cat, Dog } from "./animal.js";
type Animals = Cat | Dog;

타입스크립트는 타입의 import를 선언하기 위한 두 가지 개념으로 import 구문을 확장했습니다:

import type

이는 값(value)이 아닌 타입(type)만을 가져오는 import 문입니다.

// @filename: animal.ts
export type Cat = { breed: string; yearOfBirth: number };
export type Dog = { breeds: string[]; yearOfBirth: number };
export const createCatName = () => "fluffy";

// @filename: valid.ts
import type { Cat, Dog } from "./animal.js";
export type Animals = Cat | Dog;

// @filename: app.ts
import type { createCatName } from "./animal.js";
const name = createCatName();
// 'createCatName'은 'import type'을 사용하여 가져왔기 때문에 값으로 사용할 수 없습니다.
Inline type imports

타입스크립트 4.5부터는 개별 import 항목 앞에 type을 붙여 가져온 참조가 타입임을 나타낼 수 있습니다:

// @filename: app.ts
import { createCatName, type Cat, type Dog } from "./animal.js";

export type Animals = Cat | Dog;
const name = createCatName();

이 두 가지 기능을 함께 사용하면 Babel, swc 또는 esbuild와 같은 비-타입스크립트 트랜스파일러(transpiler)가 어떤 import를 안전하게 제거할 수 있는지 알 수 있습니다.

CommonJS Syntax

CommonJS는 npm에 있는 대부분의 모듈이 제공되는 형식입니다. 비록 여러분이 위에서 설명한 ES 모듈 구문을 사용해 코드를 작성하더라도, CommonJS 구문이 어떻게 동작하는지 간략하게 이해하면 디버깅을 더 쉽게 하는 데 도움이 됩니다.

Exporting

식별자(Identifier)는 module이라는 전역 객체에 있는 exports 속성을 설정해 내보냅니다.

function absolute(num: number) {
  if (num < 0) return num * -1;
  return num;
}

module.exports = {
  pi: 3.14,
  squareTwo: 1.41,
  phi: 1.61,
  absolute,
};

그러면 이 파일들은 require 문을 통해 가져올 수 있습니다:

const maths = require("./maths");
maths.pi;

또는 자바스크립트의 구조 분해(destructuring) 기능을 사용해 조금 더 간단하게 만들 수 있습니다:

const { squareTwo } = require("./maths");

CommonJS and ES Modules interop

CommonJS와 ES 모듈 사이에는 기본 가져오기(default import)와 모듈 네임스페이스 객체 가져오기(module namespace object import)의 구분에 관한 기능적 불일치가 존재합니다. 타입스크립트는 esModuleInterop라는 컴파일러 플래그를 통해 두 가지 다른 제약 조건 세트 간의 마찰을 줄여줍니다.

TypeScript’s Module Resolution Options

모듈 해석(Module resolution)은 importrequire 문으로부터 문자열을 가져와 그 문자열이 어떤 파일을 참조하는지 결정하는 과정입니다.

타입스크립트는 ClassicNode라는 두 가지 해석 전략을 포함합니다. module 컴파일러 옵션이 commonjs가 아닐 때 기본값인 Classic 전략은 하위 호환성을 위해 포함되어 있습니다. Node 전략은 Node.js가 CommonJS 모드에서 동작하는 방식을 복제하며, .ts.d.ts 파일에 대한 추가적인 검사를 수행합니다.

타입스크립트 내의 모듈 전략에 영향을 미치는 많은 TSConfig 플래그가 있습니다: moduleResolution, baseUrl, paths, rootDirs.

이러한 전략이 어떻게 작동하는지에 대한 전체 내용은 모듈 해석(Module Resolution) 참고 페이지에서 확인할 수 있습니다.

TypeScript’s Module Output Options

생성되는 자바스크립트 출력에 영향을 미치는 두 가지 옵션이 있습니다:

  • target은 어떤 JS 기능이 다운레벨(downlevel, 구형 자바스크립트 런타임에서 실행되도록 변환)되고 어떤 기능이 그대로 유지될지 결정합니다.
  • module은 모듈들이 서로 상호작용하기 위해 어떤 코드를 사용할지 결정합니다.

어떤 target을 사용할지는 타입스크립트 코드를 실행할 것으로 예상되는 자바스크립트 런타임에서 사용 가능한 기능에 따라 결정됩니다. 이는 지원하는 가장 오래된 웹 브라우저, 실행할 것으로 예상되는 Node.js의 최저 버전 또는 Electron과 같은 런타임의 고유한 제약 조건에서 비롯될 수 있습니다.

모듈 간의 모든 통신은 모듈 로더(module loader)를 통해 이루어지며, module 컴파일러 옵션은 어떤 로더를 사용할지 결정합니다. 런타임에 모듈 로더는 모듈을 실행하기 전에 해당 모듈의 모든 의존성을 찾아 실행하는 역할을 담당합니다.

다음은 module 옵션에 따라 동일한 타입스크립트 코드가 어떻게 다른 자바스크립트 코드로 출력되는지를 보여주는 예시입니다.

원본 TypeScript 코드:

// @filename: index.ts
import { valueOfPi } from "./constants.js";

export const twoPi = valueOfPi * 2;

ES2020 출력:

import { valueOfPi } from "./constants.js";
export const twoPi = valueOfPi * 2;

CommonJS 출력:

"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.twoPi = void 0;
const constants_js_1 = require("./constants.js");
exports.twoPi = constants_js_1.valueOfPi * 2;

UMD 출력:

(function (factory) {
    if (typeof module === "object" && typeof module.exports === "object") {
        var v = factory(require, exports);
        if (v !== undefined) module.exports = v;
    }
    else if (typeof define === "function" && define.amd) {
        define(["require", "exports", "./constants.js"], factory);
    }
})(function (require, exports) {
    "use strict";
    Object.defineProperty(exports, "__esModule", { value: true });
    exports.twoPi = void 0;
    const constants_js_1 = require("./constants.js");
    exports.twoPi = constants_js_1.valueOfPi * 2;
});

ES2020 출력은 원본 index.ts와 사실상 동일하다는 점에 유의하세요. 사용 가능한 모든 module 옵션과 그에 따라 생성되는 자바스크립트 코드는 TSConfig 참조에서 확인할 수 있습니다.

TypeScript namespaces

타입스크립트는 ES 모듈 표준 이전에 존재했던 namespaces라는 자체 모듈 형식을 가집니다. 이 구문은 복잡한 정의 파일을 만드는 데 유용한 많은 기능을 가지고 있으며, DefinitelyTyped에서 여전히 활발하게 사용되고 있습니다.

사용이 중단된 것은 아니지만, 네임스페이스의 기능 대부분은 ES 모듈에도 존재하므로, 코드를 현대화하기 위해 ES 모듈을 사용하는 것을 권장합니다. 더 자세한 내용은 네임스페이스(namespaces) 참조 페이지에서 배울 수 있습니다.