본 글은 Modern JavaScript Deep-dive을 요약한 글입니다.
자세한 내용은 본 책을 읽으시기 바랍니다.

18.1 일급 객체

다음과 같은 조건을 만족하는 객체를 일급 객체라 한다.

  1. 무명의 리터럴로 생성할 수 있다. 즉, 런타임에 생성이 가능하다.
  2. 변수나 자료구조(객체, 배열 등)에 저장할 수 있다.
  3. 함수의 매개변수에 전달할 수 있다.
  4. 함수의 반환값으로 사용할 수 있다.

 함수가 일급 객체라는 것은 함수를 객체와 동일하게 사용할 수 있다는 의미다. 객체는 값이므로 함수는 값과 동일하게 취급할 수 있다. 따라서 함수는 값을 사용할 수 있는 곳(변수 할당문, 객체의 프로퍼티 값, 배열의 요소, 함수 호출의 인수, 함수 반환문)이라면 어디서든지 리터럴로 정의할 수 있으며 런타임에 함수 객체로 평가된다. 

 일반 객체와의 차이점은 함수 객체는 호출할 수 있다. 그리고 일반 객체에는 없는 함수 고유의 프로퍼티를 소유한다.

18.2 함수 객체의 프로퍼티

 함수도 객체다. 따라서 함수도 프로퍼티를 가질 수 있다. arguments, caller, length, name, prototype 프로퍼티는 모두 함수 객체의 데이터 프로퍼티다.

18.2.1 arguments 프로퍼티 

 함수 객체의 arguments 프로퍼티 값은 arguments 객체다. arguments 객체는 함수 호출 시 전달된 인수들의 정보를 담고 있는 순회 가능한 유사 배열 객체이며, 함수 내부에서 지역 변수처럼 사용된다. 외부에서는 참조할 수 없다. ES3부터 표준에서 폐지되었다.

 

 함수를 정의할 떄 선언한 매개변수는 함수 몸체 내부에서 변수와 동일하게 취급된다. 즉, 함수가 호출되면 함수 몸체 내에서 암묵적으로 매개변수가 선언되고 undefined로 초기화된 이후 인수가 할당된다. 선언된 매개변수의 개수보다 인수를 적게 전달했을 경우 인수가 전달되지 않은 매개변수는 undefined로 초기화된 상태를 유지한다. 인수를 더 많이 전달한 경우 초과된 인수는 무시된다. 그렇다고 초과된 인수가 그냥 버려지는 것은 아니다. 모든 인수는 암묵적으로 arguments 객체의 프로퍼티로 보관된다.

 

 arguments 객체는 인수를 프로퍼티 값으로 소유하며 프로퍼티 키는 인수의 순서를 나타낸다. arguments 객체의 callee 프로퍼티는 호출되어 arguments 객체를 생성한 함수, 즉 함수 자신을 가리키고 arguments 객체의 length 프로퍼티는 인수의 개수를 가리킨다.

 

 함수가 호출되면 인수 개수를 확인하고 이에 따라 함수의 동작을 달리 정의할 필요가 있을 수 있다. 이때 유용하게 사용되는 것이 arguments 객체다. arguments 객체는 매개변수 개수를 확정할 수 없는 가변 인자 함수를 구현할 때 유용하다.

 

 arguments 객체는 실제 배열이 아닌 유사 배열 객체다. 유사 배열 객체는 배열이 아니므로 배열 메서드를 사용할 경우 에러가 발생한다. 따라서 배열 메서드를 사용하려면 Function.prototype.call, Function.prototype.apply를 사용해 간점 호출해야 하는 번거로움이 있다. 이러한 번거로움을 해결하기 위해 ES6에서는 Rest 파라미터를 도입했다.

// ES6 Rest parameter
function sum(...args){
	return args.reduce((pre, cur) => pre + cur, 0);
}

console.log(sum(1,2)); // 3
console.log(sum(1,2,3,4,5)) // 15

 

18.2.2 caller 프로퍼티

 caller 프로퍼티는 ECMAScript 사양에 포함되지 않은 비표준 프로퍼티다. 이후 표준화될 예정도 없는 프로퍼티이므로 사용하지 말고 참고로 알아두자.

18.2.3 length 프로퍼티

 함수 객체의 length 프로퍼티는 함수를 정의할 때 선언한 매개변수의 개수를 가리킨다. arguments 객체의 length 프로퍼티는 인자의 개수를 가리키고, 함수 객체의 length 프로퍼티는 매개변수의 개수를 가리킨다.

function bar(x){
	return x;
}

console.log((bar.length)); // 1

function baz(x,y){
	return x * y;
}

console.log((baz.length)); // 2

18.2.4 name 프로퍼티

함수 객체의 name 프로퍼티는 함수 이름을나타낸다. ES6에서 정식 표준이 되었다. ES5와 ES6에서 동작을 달리한다. 익명 함수 표현식의 경우 ES5에서 name 프로퍼티는 빈 문자열을 값으로 갖는다. 하지만 ES6에서는 함수 객체를 가리키는 식별자를 값으로 갖는다.

// 익명 함수 표현식
var annonymousFunc = function() {};

// ES5: name 프로퍼티는 빈 문자열을 값으로 갖는다.
// ES6: name 프로퍼티는 함수 객체를 가리키는 변수 이름을 값을 갖는다.

console.log(annonymousFunc.name); // annonymousFunc

18.2.5 __proto__ 프로퍼티

 모든 객체는 [[Prototype]]이라는 내부 슬롯을 갖는다. 이는 객체지향 프로그래밍의 상속을 구현하는 프로토타입 객체를 가리킨다.

18.2.6 prototype 프로퍼티

 prototype 프로퍼티는 생성자 함수로 호출할 수 있는 함수 객체, 즉 constructor만이 소유하는 프로퍼티다. 일반 객체와 생성자 함수로 호출할 수 없는 non-constructor에는 prototype 프로퍼티가 없다.

// 함수 객체는 prototype 프로퍼티를 소유한다.
(function(){}).hasOwnProperty('prototype');) // => true

// 일반 객체는 prototype 프로퍼티를 소유하지 않는다.
({}).hasOwnProperty('prototype'); // => false

 prototype 프로퍼티는 함수가 객체를 생성하는 생성자 함수로 호출될 때 생성자 함수가 생성할 인스턴스의 프로토타입 객체를 가리킨다.

본 글은 Typescript Programming을 요약한 글입니다.
자세한 내요은 본 책을 읽으시길 바랍니다.

 

6장에서는 타입스크립트의 서브타입화, 할당성, 가변성, 넓히기 등을 소개하면서 타입스크립트 개념의 통찰을 발전시킨다. 그리고 정제, 종합성 등을 포함한 타입스크립트의 제어 흐름 기반의 타입 확인 기능도 자세히 살펴본다. 객체 타입을 키로 활용하고 매핑하는 방법, 조건부 타입 사용, 자신만의 타입 안전 장치 정의, 타입 어서션, 확실한 할당 어서션 등의 고급 개념도 살펴본다. 타입 안정성을 더 할 수있는 컴패니엉 객체 패턴, 튜플 타입의 추론 개선, 이름 기반 타입 흉내내기, 안전하게 프로토타입 확장하기 등 고급 패턴도 설명한다.

6.1 타입 간의 관계

6.1.1 서브타입과 슈퍼 타입

  • 서브 타입 : 두개의 타입 a와 b가 있고 b가 a의 서브타입이면 a가 필요한 곳에는 어디든 b를 안전하게 사용할 수 있다.
  • 슈퍼 타입 : 두개의 타입 a와 b가 있고 b가 a의 슈퍼타입이면 b가 필요한 곳에는 어디든 a를 안전하게 사용할 수 있다.
  • 슈퍼 타입과 서브 타입은 정반대로 동작한다.

6.1.2 가변성

매개변수화된(제네릭) 타입 등 복합 타입에서는 타입의 서브 타입을 판단하는 문제가 복잡해 진다. 이런 복합 타입의 서브타입 규칙은 프로그래밍 언어마다 다르며 같은 규칙을 가진 언어가 거의 없을 정도다. 이 규칙들을 더 쉽게 읽을 수 있도록 더욱 정확하고 간단하게 설명할 수 있는 몇 가지 문법을 설명한다.

  • A <: B 는 'A는 B와 같거나 B의 서브타입' 이라는 의미다.
  • A >: B 는 'A는 B와 같거나 B의 슈퍼타입' 이라는 의미다.

형태와 배열 가변성

1. 슈퍼 타입이 필요한 곳에 객체를 할당하는 경우

function deleteUser(user : {id? : number, name: string}){
	delete user.id
}

let existingUser : ExistingUser = {
	id : 1234,
    name : 'User'
}

deleteUser(existingUser)

 어떤 객체를 슈퍼타입을 기대하는 곳에 사용한다는 것은 분명 안전하지 않을 수 있다. 그러나 타입스크립트는 완벽함 보다는 실제 실수를 잡는 것과 쉬운 사용이라는 두 가지 목표를 균형있게 달성하는 것이 목표다. 그렇기에 타입스크립트는 에러를 발생시키지 않고 슈퍼타입이 필요한 곳에 객체를 할당할 수 있도록 허용한다.

 

2. 서브 타입이 필요한 곳에 객체를 할당하는 경우

type LegacyUser = {
	id?: number | string
    name : string
}

ley legacyUser: LegacyUser= {
	id : '793331',
    name : 'hwandu'
}

deleteUser(legacyUser) // 에러

기대하는 타입의 슈퍼타입의 프로퍼티를 포함하는 형태를 전달하면 타입스크립트는 에러를 발생시킨다. 어떤 형태를 요구할 때 건넬 수 있는 타입은, 요구되는 타입에 포함된 프로퍼티 각각에 대해 '<: 기대하는 타입'인 프로퍼티를 가지고 있어야 한다. 기대하는 프로퍼티의 슈퍼타입인 프로퍼티가 있다면 건넬 수 없다. 이를 타입스크립트 형태는 그들의 프로퍼티 타입에 공변한다고 말한다. 

 

3. 가변성 정리

  • 불면 : 정확히 T를 원함
  • 공변 : <:T를 원함 
  • 반변 : >:T를 원함
  • 양변 : <:T 또는 > :T 를 원함 

타입스크립에서 모든 복합 타입의 멤버(객체, 클래스, 배열, 함수, 반환 타입)는 공변 함수 매개변수 타입만 예외적으로 반변이다.

 

함수 가변성

함수 A가 함수 B와 같거나 적은 수의 매개변수를 가지며 다음을 만족하면 A는 B의 서브 타입이다.

  1. A의 this 타입을 따로 지정하지 않으면 'A의 this 타입 >: B의 this 타입'이다.
  2. 'A의 각 매개변수 >: B의 대응 매개변수'이다.
  3. 'A의 반환 타입 <: B의 반환 타입'이다.

왜 반환 타입만 조건이 반대일까? 객체, 배열, 유니온 등 과 달리 함수에서는 this, 매개변수, 반환 타입을 포함한 모든 컴포넌트가 <: 관계를 만족하지 않는 이유는 무엇일까? Crow <: Bird <: Animal 조건에서 예제를 실행해 보자.

function clone (f: (b : Bird) => Bird) : void {
	let parent = new Bird
    let babyBird = f(parent)
    babyBird.chirp()
}

clone에 Animal을 반환하는 함수 f를 전달한다면 f의 반환값에 .chrip를 호출할 수 없다. 함수의 반환 타입은 공변, 즉 함수가 다른 함수으 서브타입이라면 '서브타입 함수의 함수 반환 타입 <: 다른 함수의 반환 타입'를 만족해야한다. 매개변수 타입의 관계는 어떨까?

function animalToBird(a: Animal) : Bird {
	// ...
}

clone(animalToBird) // OK

function crowToBird(c: Crow) : Bird {
	// ...
}

clone(crowToBird) // 에러 : '(c: Crow) => Bird' 타입은 '(b: Bird) => Bird' 타입에 할당할 수 없음

함수를 다른 함수에 할당하려면 'this를 포함한 매개변수 타입 >: 할당하려는 함수의 대응 매개변수 타입' 조건을 만족해야한다.

function crowToBird(c: Crow): Bird {
	c.caw()
    return new Bird
}

clone에 crowToBird를 전달하면 .caw는 Crow에만 정의되어 있고 Bird에는 정의되어 있지 않으므로 예외가 발생한다. 즉 함수의 매개변수, this 타입은 반변이다. 한 함수가 다른 함수의 서브타입이라면 '서브타입 함수의 매개변수와 this 타입 >: 다른 함수의 대응하는 매개변수'라는 조건을 만족해야한다.

6.1.3 할당성

할당성이란 A라는 타입을 다른 B라는 타입이 필요한 곳에 사용할 수 있는지를 결정하는 타입스크립트 규칙을 말한다. 타입스크립트는 몇 가지 규칙에 따라 이를 처리한다. 배열, 불, 숫자, 객체, 함수, 클래스, 클래스. 인스턴스, 문자열, 리터럴 타입 등 열거형이 아닌 타입에서는 다음의 규칙을 A를 B에 할당할 수 있는지 결정한다.

  1. A <: B
  2. A는 any

규칙 1은 서브타입이 무엇인지 정의한다. 규칙 2는 예외를 설명하며 자바스크립트 코드와 상호 운용할 때 유용하다.

enum이나 const enum 키워드로 만드는 열거형 타입에서는 다음 조건 중 하나를 만족해야 A 타입을 열거형 B에 할당할 수 있다.

  1. A는 열거형 B의 멤버다.
  2. B는 number 타입의 멤버를 최소 한개 이상 가지고 있으며 A는 number이다.

규칙 1은 위 와 동일하며 규칙 2는 열거형을 처리할 때 편리하게 적용할 수 있지만 안정성이 많이 떨어지기에 열거형은 지양하도록 하자.

6.1.4 타입 넓히기

(let or var)로 값을 바꿀수 있는 변수를 선언하면  그변수의 타입이 리터럴 값에서 리터럴 값이 속한 기본 타입으로 넓혀진다.

let a = 'X' // string

값을 바꿀 수 없는 변수에서는 상황이 달라진다.

const a = 'x' // 'x'

타입을 명시하면 타입이 넓어지지 않도록 막을 수 있다.

let a : 'x' = 'x' // 'x'

값을 다시 할당하면 타입스크립트는 새로운 값에 맞게 변수의 타입을 넓힌다. 명시적으로 타입 어노테이션을 추가하면 자동 확장은 일어나지 않는다.

const a = 'x' // 'x'
let b = a // string

const c : 'x' = 'x' // 'x'
let d = c // 'x'

null 이나 undefined로 초기화된 변수는 any 타입으로 넓혀진다.

let a = null // undefined

null 이나 underfined로 초기화된 변수가 선언 범위를 벗어나면 타입스크립트는 확실한 타입을 할당한다.

function x() {
	let a = null // any
    a = 3 // any
    a = 'b' // any
    return a
}

x() // string

const 타입

타입이 넓혀지지 않도록 해주는 const라는 특별 타입을 제공한다. const를 사용하면 타입 넓히기가 중지되며 멤버들까지 자동으로 readonly가 된다.(중첩된 자료구조에도 재귀적으로 적용한다.) 변수를 가능한 좁은 타입으로 추론하길 원한다면 as const를 이용하자.

let e = [1, {x : 2}] as const // readonly [1, {x : 2}]

 

초과 프로퍼티 확인

 객체 타입과 그 멤버들은 공변 관계라고 설명했다. 그런데 추가 확인을 수행하지 않고 이 규칙만을 적용하면 문제가 발생할 수 있다. 예를 들어 객체 내 프로퍼티의 철자를 틀리면 어떤 일이 일어날까 ?

type Options = {
	baseURL : string
    cacheSize? : number
    tier? :'prod' | 'dev'
}

new API ({
	baseUrl : 'http://api...'
    tierr : 'prod' // 에러 '{tierr: string}'타입은 파라미터 타입 'Option'에 할당할 수 없음
})

 타입 스크립트가 이를 검출 할 수 있었던 건 초과 프로퍼티 확인 기능 덕분이다. 신선한 객체 리터럴 타입 T를 다른 타입 U에 할당하려는 상황에서 T가 U에 존재하지 않는 프로퍼티를 가지고 있다면 타입스크립트는 이를 에러로 처리한다. 여기서 신선한 객체 리터럴 타입이란 타입스크립트가 객체 리터럴로부터 추론한 타입을 가리킨다. 객체 리터럴이 타입 어션을 사용하거나 변수로 할당되면 이는 일반 객체 타입으로 넓혀지면서 신선함은 사라진다.

6.1.5 정제

 타입스크립트는 심벌 수행의 일종인 흐름 기반 타입 추론을 수행한다. if, ?, ||, switch 같은 제어 흐름 문장까지 고려하여 타입을 정제한다.

 

차별된 유니온 타입

type UserTextEvent = {value : string, target: HTMLInputElement}
type UserMouseEvent = {value : [string, string], target: HTMLElement}

type UserEvent = UserTextEvent|UserMouseEvent

function handle(event: UserEvent) {
	if(typeof event.value === 'string'){
    	event.value // string
        event.target // HTMLInputElement|HTMLElement !!
        // ...
        return
    }
    event.value // [string, string]
    event.value // HTMLInputElement|HTMLElement !!
}

event.value는 잘 정제되었지만 event.target에는 적용되지 않았다.HTMLInputElement|HTMLElement 은 전자나 후자만 전달할 수 있다는 의미가 아니다. 사실 HTMLInputElement|HTMLElement 타입의 인수도 전달 할 수 있다. 유니온의 멤버가 서로 중복될 수 있으므로 타입스크립트는 유니온의 어떤 타입에 해당하는지를 조금 더 안정적으로 파악할 수 있어야한다.

리터럴 타입을 이용해 유니온 타입을 만들어낼 수 있는 각각의 경우를 태그하는 방식으로 이 문제를 해결할 수 있다. 다음은 좋은 태그의 조건이다.

  • 유니온 타입의 각 경우와 같은 위치에 있다. 객체타입 에서는 같은 객체 필드를, 튜플 타입이면 같은 인덱스를 의미한다.
  • 리터럴 타입이다. 한가지의 타입만 사용하는 것이 바람직하며 보통 문자열 리터럴 타입을 사용한다.
  • 제네릭이 아니다.
  • 상호 배타적이다.
type UserTextEvent = {
	type: 'TextEvent',
    value : string, 
    target: HTMLInputElement
    }
type UserMouseEvent = {
	type: 'MouseEvent',
    value : [string, string],
    target: HTMLElement
}

type UserEvent = UserTextEvent|UserMouseEvent

function handle(event: UserEvent) {
	if(event.type === 'TextEvet'){
    	event.value // string
        event.target // HTMLInputElement
        // ...
        return
    }
    event.value // [string, string]
    event.target // HTMLElement 
}

6.2 종합성

철저 검서라고도 불리는 종합성은 피요한 모든 상황을 제대로 처리했는지 타입 검사기가 검사하는 기능이다. 예를 들어, 코드의 반환문이 없다면 에러메세지를 띄워서 코드에서 처리하지 않는 상황이 있거나 어떤 상홍이든 대처할 수 있는 반환문을 함수 마지막에 추가해야 한다고 말해준다. switch, if ,throw 등 어떤 구조를 사용 하든 타입스크립트는 모든 상황을 다 고려했는지 확인한다.

6.3 고급 객체 타입

6.3.1 객체 타입의 타입 연산자

키인 연산자

복잡하게 중첩된 타입이 있다고 가정하자.

type APIResponse = {
	user : {
    	userId: string
        friendList: {
        	count : number
            friends:{
            	firstName: string
                lastName: string
            }[]
        }
    }
}

이 Api로 부터 응답을 받아 보여줘야할 떄 friendList는 어떤 타입이여야 할까? 각 최상위 타입에 쓰는 이름들을 하나하나 다 만들어야하는데 우리는 이런 응답 타입에 키인하는 방법이 있다.

type FriendList = APIResponse['user']['frinedList']
type Friend = FriendList['freinds'][number]

모든 형태와 배열에 키인할 수 있다.  number가 배열 타입에 키인을 적용하는 핵심이다. 튜플에서는 0,1 또는 키인하려는 인덱스를 가리키는 숫자 리터럴 타입을 사용할 수 있다. 일반 자바스크립트 객체의 필드를 찾는 것처럼 타입을 찾을 수 있다.

 

keyof 연산자

 keyof를 이용하면 객체의 모든 키를 문자열 리터럴 타입 유니온으로 얻을 수 있다.

type FriendListKeys = keyof APIResponse['user']['frinedList'] // 'count' | 'friends'

키인과 keyof 연산자를 혼합해 사용하면 객체에서 주어진 키를 해당하는 값을 반환하는 게터를  안전한 방식으로 구현할 수 있다.

6.3.2 Record 타입

내장 Record 타입을 이용하면 무언가를 매핑하는 용도로 객체를 활용할 수 있다. Record 타입은 객체가 특정 키 집합을 정의하도록 하는 두 가지 방법 중 하나다.

let nextDay : Record<Weekday, Day> = {
	Mon : 'Tue'
} // 에러 {Mon : 'Tue'} 타입에는 Record<Weekday, Day> 중 Tue, Wed, Thu, Fri가 빠져 있음

Record는 일반 객체의 인덱스 시그니처에 비해 자유롭다. 인덱스 시그니처에서는 객체 값의 타입은 제한할 수 있지만, 키는 반드시 일반 string, number, symbol이어야 한다. 하지만 Record에서는 객체의 키 타입도 string과 number의 서브타입으로 제한할 수 있다.

6.3.3 매핑된 타입

객체가 특정 키 집합을 정의하도록 하는 두 가지 방법 중 더 강력한 방법은 매핑된 타입을 이용하는 것이다.

let nextDay : {[K in WeekDay]: Day}= {
	Mon : 'Tue'
} // 에러 {Mon : 'Tue'} 타입은 '{Mon: Weekday; Tue: Weekday; Wed: Weekday; Thu: Weekday; Fri: Weekday;}' 중 Tue, Wed, Thu, Fri를 포함하지 않음

예제와 같이 매핑된 타입은 고유 문법이 있다. 인덱스 시그니처와 마찬가지로 한 객체 당 최대 한 개의 매핑된 타입을 가질 수 있다.

매핑된 타입은 Record보다 강력하다. 객체의 키와 값에 타입을 제공할 뿐 아니라, 키인 타입과 조합하면 키 이름별로 매핑할 수 있는 값 타입을 제한할 수 있기 때문이다.

 

내장 매핑된 타입

유도한 매핑된 타입은 매우 유용하여 이 중 많은 것들을 타입스크립트가 내장 타입으로 제공한다.

  • Record<Keys, value> : Keys 타입의 키와 Values 타입의 값을 갖는 객체
  • Partial<Object> : Object의 모든 필드를 선택형으로 표시
  • Required<Object> : object의 모든 필드를 필수형으로 표시
  • Readonly<Object> : object의 모든 필드를 읽기 전용으로 표시
  • Pick<Object, Keys> : 주어진 Keys에 대응하는 Object의 서브타입을 반환

6.3.4 컴패니언 객체 패턴

 타입스크립트에는 타입과 객체를 쌍으로 묶는 컴패니언 객체 패턴이 존재한다.

type Currency = {
	unit : 'EUR' | 'GBP' | 'JPY' |'USD'
    value : number
}

let Currency = {
	DEFAULT: 'USD',
    from(value: number, unit= Currency.DEFAULT) : Currency{
    	return {unit, value}
    }
}

 타입스크립트에서는 타입과 값은 별도의 네임스페이스를 갖느다는 사실을 기억하자. 따라서 영역에서 하나의 이름을 타입과 값 모두에 연결할 수 있다. 한편, 컴패니언 객체 패턴을 이용하면 별도의 네임스페이스를 이용해 한번은 타입으로, 한 번은 값으로 두 번 이름을 선언할 수 있다. 이 패턴을 이용하면 타입과 값 정보를 Currency 같은 한 개의 이름으로 그룹화 할 수도 있고 호출자는 이 둘을 한번에 import할 수 있다.

6.4 고급 함수 타입들

6.4.1 튜플의 타입 추론 개선

타입스크립트는 튜플을 선언할 때 튜플의 타입에 대해 관대한 편이다. 길이, 위치, 타입 등을 무시하고 주어진 상황에서 제공할 수 있는 가장일반적인 타입으로 튜플의 타입을 추론한다.

let a = [1, true] // (number | true)[]

 하지만 때로는 좀 더 엄격한 추론이 필요하다. 물론 타입 어서션이나 as const 어서션을 이용해 좁게 추론할 수 있다. 그러나 이를 제외하고 튜플을 튜플 타입을 만들려면 나머지 매개변수의 타입을 추론하는 기법을 이용하면 된다.

function tuple<
	T extends unknown[] // 1
>(
	...ts: T  // 2
): T {
	return ts // 3
}

let a = tuple(1,true) // [number, boolean]
  1. unkwon[]의 서브타입인 단일 타입 매개변수 T를 선언한다.
  2. tuple은 임의 개수의 매개변수 ts를 받는다. T는 나머지 매개변수를 나타내므로 이를 튜플 타입으로 추론한다.
  3. tuple 함수는 ts의 추론 타입과 같은 튜플 타입의 값을 반환한다.

튜플 타입이 많이 등장한다면 이러한 기법을 활용해 타입 어서션 사용을 줄일 수 있다.

6.4.2 사용자 정의 타입 안전 장치

타입 정제는 강력하지만 현재 영역에 속한 변수만을 처리할 수 있다는 문제가 존재한다. 한 영역에서 다른 영역으로 이동하면 기존의 정제 결과물은 사라져버린다. 사용자 정의 타입 안정 장치라는 기법으로 이를 해결할 수 있다.

function isString(a: unknown) : a is string {
	return typeof a === 'string'
}

 사용자 정의 타입 안정 장치는 매개 변수 하나에만 적용할 수 있지만 복합 타입에도 적용할 수 있다.

function isLegacyDialog(
	dialog : LegacyDialog | Dialog
) : dialog is LegacyDialog {
	// ...
}

 사용자 정의 타입 안정 장치를 사용하지 않으면  isLegacyDialog와 isString 같은 캡술화되고 가독성 좋은 함수를 활용하지 못하고, 대신 typeof나 instanceof 타입 안전 장치를 코드에 일일이 추가 해야한다.

6.5 조건부 타입

조건부 타입의 의미를 말로 풀어본다면 "U와 V 타입에 의존하는 T 타입을 선언하라. U <: V 면 T를 A에 할당하고, 그렇지 않으면 T를 B에 할당하라"라고 해석할 수 있다.

type IsString<T> = T extends string // 1
	? true // 2
    : false // 3
    
type A = IsString<string> // true
type B = IsString<number> // false
  1. 이 조건부 타입에서 조건은 T extends string 부분이고, "T는 string의 서브 타입인가?"라는 의미다.
  2. T가 string의 서브타입이면 true 타입으로 해석한다.
  3. 그렇지 않으면 false 타입으로 해석한다.

위 타입 수준의 연산은 일반적인 삼항 연산자처럼 중첩할 수 도 있다. 조건부 타입은 타입 별칭 외에도 타입을 사용할 수 있는 거의 모든 곳 (타입 별칭, 인터페이스, 클래스, 매개변수 타입, 함수와 메서드의 제네릭 기본값 등)에 사용할 수 있다.

6.5.1 분배적 조건부

타입스크립트는 분배 법칙을 따르기에 간단한 조건을 다양한 방식으로 표현할 수 있다.

이 표현식은 ... 다음과 같다.
(stirng | number) extends T ? A : B (stirng extends T ? A : B) | (number extends T ? A : B
type ToArray<T> = T[]
type A = ToArray<number> // number []
type B = ToArray<number | string> // (numbner | string)[]

type ToArray2<T> = T extends unknown ? T[] : T[]
type A = ToArray2<number> // number []
type B = ToArray2<number | string> // numbner[] | string[]

 조건부 타입을 사용하면 타입스크립트는 유니온 타입을 조건부의 절들로 분배한다. 이를 이용하면 다양한 공통 연산을 안전하게 표현할 수 있다. T에는 존재하지만 U에는 존재하지 않는 타입을 구하는 Without<T,U>도 구현 할 수 있다.

type Without<T, U> = T extends U ? never : T

type A = Without<
	boolean | numebr | string,
    boolean
> // number | string

6.5.2 infer 키워드

조건부 타입의 마지막 특성으로 조건의 일부를 제네릭 타입으로 선언할 수 있는 기능을 꼽을 수 있다. 조건부 타입에서는 제네릭 타입을 인라인으로 선언하는 전용 문법을 제공한다. 바로 infer 키워드다.

type ElementType<T> = T extends unknown[] ? T[number] : T
type A = ElementType<number[]> // number

type ElementType2<T> = T extends (infer U)[] ? U : T
type B = ElementType<number[]> // number

infer 문은 어떤 T를 전달했느냐를 보고 U의 타입을 추론한다. 

6.5.3 내장 조건부 타입들

조건부 타입을 이용하면 강력한 연산자 몇 가지를 타입수준에서 표현할 수 있다.

  • Exclude<T,U> : Without 타입처럼 T에 속하지만 U에는 없는 타입을 구한다.
  • Extract<T,U> : T의 타입 중 U에 할당할 수 있는 타입을 구한다.
  • NonNullable<T> : T에서 null과 undefined를 제외한 버전을 구한다.
  • RetrunType<F> : 함수의 반환 타입을 구한다 (제네릭과 오버로드된 함수에는 동작 X)
  • InstanceType<C> : 클래스 생성자의 타입을 구한다.

6.6 탈출구

 상황에 따라서는 타입을 완벽하게 지정하지 않고도 어떤 작업이 안전하다는 사실을 타입스크립트가 믿도록 만들고 싶을 때가 있다.

6.6.1 타입 어서션

타입 B가 있고 A <: B <: C르 만족하려면 타입 검사기에게 B는 실제로 A거나 C라고 어서션할 수 있다. 주의할 점은, 어떤 하나의 타입은 자신의 슈퍼타입이나 서브타입으로만 어서션할 수 있다. 타입스크립트는 두 가지의 타입 어서션 문법을 제공한다.

formatInput(Input as string)

formatInput(<string>input)

두 타입 사이에 연광성이 충분하지 않아서 한 타입을 다른 타입이라고 어서션 할 수 없을 때도 있다. 이 문제는 any라고 어서션하여 우회가능하지만 안전하지 않기에 되도록 피해야한다.

6.6.2 Nonnull 어서션

널이 될 수 있는 특별한 상황(T | null 또는 T | null | undefined 타입)을 대비해 타입스크립트는 어떤 값의 타입이 null이나 undefined가 아니라 T임을 단언하는 특수 문법을 제공한다.

type Dialog = {
	id? : string
}

function closeDialog(dialog : Dialog) {
	if(!dialog.id){
    	return
    }
    setTimeour(()=>
    	removeFromDOM(
        	dialog,
            document.getElementById(dialog.id) // 에러 'string | undefine' 타입의 인수는 'string' 타입의 매개변수에 할당할 수 없음
        )
    )
}

function removeFromDom(dialog: Dialog, element : Element) {
	element.parentNode.removeChild(element) // 에러 : 객체가 null일 수 있음
}
  • 화살표 함수 내부에서 유효범위가 바뀐다. 위에서 정제가 무효화 되고 타입스크립트 입장에서는 getElementById를 호출하면 HTMLElement | null을 반환한다는 사실만 알고 있을 뿐이다.

필요한 모든 곳에 if _ === null 을 추가해 이 문제를 해결할 수 있다. 대상이 null 인지 여부를 확실할 수 없다면 올바른 해법이다. 하지만 대상이 null | undefined가 아님을 확실하는 경우라면 타입스크립트가 제공하는 특별 문법을 활용할 수 있다.

type Dialog = {
	id? : string
}

function closeDialog(dialog : Dialog) {
	if(!dialog.id){
    	return
    }
    setTimeour(()=>
    	removeFromDOM(
        	dialog,
            document.getElementById(dialog.id)!
        )
    )
}

function removeFromDom(dialog: Dialog, element : Element) {
	element.parentNode!.removeChild(element) 
}

간간히 보이는 nonull 어서션 연사자 ! 로 document.getElementById의 호출 결과인 dialog.id와 element.parentNode가 정의되어 있음을 타입스크립트에 알려주었다. nonull 어서션를 너무 많이 사용하고 있다면 코드를 리팩토링 해야한다는 징후일 수 있다. Dialog를 두 타입의 유니온으로 분리해 어서션을 제거할 수 있다.

6.6.3 확실한 할당 어서션

타입스크립트는 확실한 할당 검사용으로 nonnull 어서션을 적용하는 특별한 상황에 사용할 특수 문법을 제공한다. 확실한 할당 어서션을 이용하여 userId를 사용하는 시점에는 이 변수가 반드시 할당되어 있을 것임을 타입스크립트에 알려줄 수 있다.

let userId! : string
fetchuser()

userId.toUpperCase() // OK

6.7 이름 기반 타입 흉내내기

타입스크립트는 이름 기반 타입을 제공하지 않지만 타입 브랜딩이라는 기법으로 이르 흉내낼 수 있다. 브랜디드 타입을 이용하면 프로그램을 한층 안전하게 만들 수 있다.

type CompanyID = string & {readonly brand : unique symbol}
type OrderID = string & {readonly brand : unique symbol}
type UserID = string & {readonly brand : unique symbol}
type ID = CompanyID | OrderID | UserID
  1. 예제에서 unique symbol을 브랜드로 사용했는데 이는 타입 스크립트에서 실질적으로 제공하는 두 가지 이름 기반 타입 중 하나이기 때문이다.
  2. 그런 다음 이 브랜드를 string과 인터섹션하여 주어진 문자열이 우리가 정의한 브랜디드 타입과 같다고 어서션할 수 있도록 했다.
function CompanyID (id : string){
	return id as CompanyID
}

function OrderID (id : string){
	return id as OrderID
}

function UserID (id : string){
	return id as UserID
}

let companyId = CompanyID('8a6076cf')

캠피니언 객체 패턴을 사용하여 각 브랜디드 타입의 생성자를 만든다. 주어진 값 id를 앞서 정의한 난해한 타입들로 지정하는 타입 어서션 as을 이용한다.

6.8 프로토타입 안전하게 확장하기

예전에는 프로토타입 확장이 안전하지 않은 일이었지만 이제 타입스크립트처럼 정적 타입 시스템을 이용하면 안전하게 확장할 수 있다.

본 글은 Typescript Programming을 요약한 글입니다.
자세한 내요은 본 책을 읽으시길 바랍니다.

 

 객체 지향 프로그래밍 언어에서 클래스는 코드를 조직하고 이해할 수 있는 방법을 제공할 뿐만 아니라 캡슐화의 주요 단위기도 하다. 타입스크립트의 클래스는 가시성 한정자, 프로퍼티 초기자, 다형성, 데코레이터, 인터페이스 등을 제공한다. 또한 믹스인 같은 자바스크립트 표현식도 타입 안정성을 유지하며 사용할 수 있다.

 프로퍼티 초기자와 데코레이터는 자바스크립트에사도 지원하므로 실제 런타임 코드를 생성한다. 반면, 가시정 접근자, 인터페이스, 제네릭 등은 고유 기능이므로 컴파일 타임에만 존재하며 아무 코드도 생성하지 않는다. 5장에선 클래스를 활용하는 방법과 타입스크립트의 객체 지향 언어 기능이 무엇인지 살펴본다.

5.1 클래스와 상속

타입스크립트는 클래스의 프로퍼티와 메서드에 세 가지 접근 한정자를 제공한다.

  • public : 어디에서나 접근할 수 있다. 기본 값이다.
  • protected : 이 클래스와 서브클래스의 인스턴스에서만 접근할 수 있다.
  • private : 이 클래스의 인스턴스에서만 접근할 수 있다.

접근 한정자를 이용해 내부 구현 정보를 많이 공개하지 않고 잘 정의된 API만 노출하도록 클래스를 설계할 수 있다.

abstract 키워드는 해당 클래스를 바로 인스턴스화할 수 없음을 의미할 뿐 필요한 메서드를 추상 클래스에 자유롭게 추가할 수 있다.

// ...
abstract class Piece {
	// ...
    moveTo(position : Position) {
    	this.position = position
    }
    abstract canMoveTo(position : Position): boolean
}

Piece 클래스는 다음과 같은 의미로 해석할 수 있다.

  • canMoveTo라는 케서드를 주어진 시그니처와 호환되도록 구현해야 함을 하위 클래스에 알린다. 추상 클래스를 구현할 때는 추상 메서드도 반드시 구현해야 한다.
  • moveTo의 기본 구현을 포한한다.

이번장을 요약하자면

  • class 키워드로 클래스를 선언 후 extends 키워드로 다른 클래스를 상속 받을 수 있다.
  • 클래스는 구체 클래스와 추상 클래스로 구분된다.
  • 메서드는 private, protected, public 중 한 가지 한정자를 갖는다.
  • 클래스틑 인스턴스 프로퍼티도 가질 수 있으며 이들은 private, protected, public 중 한 가지 한정자를 갖는다. 생성자의 매개변수나 프로퍼티 초기자에도 이들 한정자를 사용할 수 있다.
  • 인스턴스 프로퍼티를 선언할 때 readonly를 추가할 수 있다.

5.2 super

자식 클래스가 부모 클래스에 정의된 메서드를 오버라이드하면 자식 인스턴스는 super를 이용해 부모 버전의 메서드를 호출할 수 있다.

  • super.take 같은 메서드 호출
  • 생성자 함수에서만 호출할 수 있는 super()라는 특별한 타입의 생성자 호출

super로 부모 클래스의 메서드에만 접근할 수 있고 프로퍼티에는 접근할 수 없다는 사실을 기억하자.

5.3 this를 반환 타입으로 사용하기

this를 값 뿐 아니라 타입으로도 사용할 수 있다. 클래스를 정의할 때라면 메서드의 반환 타입을 지정할 때 this 타입을 유용하게 활용할 수 있다. 클래스를 상속받는 서브클래스가 this를 반환하는 모든 메서드의 시그니처를 오버라이드해야하는 경우가 생긴다. 이때 반환 타입을 this로 지정하면 이를 타입스크립트가 알아서 해준다.

class Set {
	has(value: number): boolean {
    	// ...
    }
    add(value: number): this {
		// ...
    }
}

이제 set의 this는 Set 인스턴스를, MutableSet의 this는 MutableSet 인스턴르르 자동으로 가리키므로 MutableSet에서 add 메서드를 오버라이드할 필요가 없다.

class MutableSet extends Set {
	delete(value: number): boolean{
    	// ...
    }
}

5.4 인터페이스

 타입 별칭처럼 인터페이스도 타입에 이름을 지어주는 수단이므로 인터페이스를 사용하면 타입을 더 깔끔하게 정의할 수 있다.

type Sushi = {
	calories: numnber
    salty: boolean
    tasty: boolean
}

interface Sushi = {
	calories: numnber
    salty: boolean
    tasty: boolean
}

 Sushi 타입 별칭을 사용한 모든 곳에 Sushi 인터페이스를 대신 사용할 수 잇다. 둘 다 형태를 정의하며 두 형태 정의는 사로 할당할 수 있다.

type Food = {
	calories: number
    tasty: boolean
}

type Sushi = Food & {
	salty: number
}

interface Food = {
	calories: number
    tasty: boolean
}

interface Sushi extends Food {
	salty: number
}

 타입과 인터페이스의 사이에는 미묘한 차이가 존재한다. 

  1. 타입 별칭은 더 일반적이어서 타입 별칭의 오른편에는 타입 표현식을 포함한 모든 타입이 등장할 수 있다. 반면 인스턴스의 오른편에는 반드시 형태가 나와야한다. 다음과 같은 코드는 인터페이스로 작성 불가능하다. (type A =number, type B = A | string)
  2. 인터페이스를 상송할 때 타입스크립트는 상속받는 인터페이스의 타입에 상위 인스턴스를 할당할 수 있는지를 확인한다. 인터페이스는 엄격하게 할당을 검사하는 반면 인터섹션 타입을 사용한다면 타입스크립트는 확장하는 타입을 최대한 조합하는 방향으로 동작한다. 컴파일 에러가 발생하는 인터페이스의 경우와 달리 오버로드한 시그니처가 만들어진다.
  3. 이름과 범위가 같은 인터페이스가 여러 개 있다면 이들이 자동으로 합쳐진다.

5.4.1 선언 합침

 선언 합침은 같은 이름으로 정의된 여러 정의를 자동으로 합치는 타입스크립트의 기능이다. User라는 똑같은 이름의 인터페이스를 두 개 정의하면 타입스크립트는 자동으로 둘을 하나의 인터페이스로 합친다. 타입 별칭은 똑같은 이름으로 두 개를 만들 수 없다. 한편, 인터페이스끼리는 충돌해서는 안 된다. 한 타입의 프로퍼티는 T와 다른 타입의 프로퍼티 U가 동일하지 않다면 에러가 발생한다. 제네릭을 선언한 인터페이스들의 경우 제네릭들의 선언 방법과 이름까지 똑같아야 합칠 수 있다.

5.4.2 구현

클래스를 선언할 때 implements라는 키워드를 이용해 특정 인터페이스를 만족시킴을 표현할 수 있다.

interface Animal {
	eat(food: string): void
    sleee(hour: number): void
}

class Cat implements Animal {
	eat(food: string){
    	console.info('Ate some', food, ',Mmm!')
    }
    sleee(hour: number){
    	console.info('Slept for', hours, 'hours')
    }
}

 Cat은 Animal이 선언하는 모든 메서드를 구현해야 하며, 필요하다면 메서드나 프로퍼티를 추가로 구현할 수 있다. 인터페이스로 인스턴스 프로퍼티를 정의할 수 있지만 가시성 한정자는 선언할 수 없으며 static 키워드도 사용할 수 없다. 인스턴스 프로퍼티를 readonly로 설정할 수 있다. 필요하다면 한 클래스가 여러 인터페이스를 구현할 수도 있다.

interface Feline {
	meow(): void
}

class Cat implements Animal, Feline {
	// ...
    
   	meow() {
    	conosle.info('Meow')
    }
}

5.4.3 인터페이스 구축 vs. 추상 클래스 구축

 인터페이스는 형태를 정의하는 수단이다. 값 수준에서 이는 객체, 배열, 함수, 클래스, 클래스 인스턴스를 정의할 수 있다는 뜻이다. 아무런 자바스크립트 코드를 만들지 않으며 컴파일 타임에만 존재한다.

 추상 클래스는 오직 클래스만 정의할 수 있다. 이는 런타임의 자바스크립트 클래스 코드를 만든다. 생성자와 기본 구현을 가질 수 있으며 프로퍼티와 메서드에 한정 접근자를 지정할 수 있다.

 여러 클래스를 공유하는 구현이라면 추상 클래스를 사용하고, 가볍게 클래스를 미리 말하는 것이 목적이라면 인터페이스를 사용하자.

5.5 클래스는 구조 기반 타입을 지원한다.

타입스크립트는 클래스를 비교할 떄 다른 타입과 달리 이름이 아니라 구조를 기준으로 삼는다. 클래스는 자신과 똑같은 프로퍼티와 메서드를 정의하는 기존의 일반 객체를 포함해 클래스의 형태를 공유하는 다른 모든 타입과 호환된다.

class Zebra {
	trot() {
    	// ...
    }
}

class Poodle  {
	trot() {
    	// ...
    }
}

function ambleAround(animal: Zebra){
	animal.trot()
}

let zebra = new Zebra
let poodle = new Poodle

ambleAroud(zebra) // Ok
ambleAroud(poodle) // OK

 함수의 관점에서 두 클래스는 .trot를 구현하며 서로 호환되므로 아무 문제없이 함수가 실행된다. 단, private이나 protected 필드를 갖는 클래스는 상황이 다르다. 클래스에 private이나 protected 필드가 있고, 할당하려는 클래스가 서브클래스의 인스턴스가 아니라면 할당할 수 없다고 판정한다.

5.6 클래스는 값과 타입을 모두 선언하다

  클래스와 열거형은 특별하다. 이들은 타입 네임 스페이스에 타입을, 값 네임스페이스에 값을 동시에 생성한다는 점에서 특별하다.

class C {}
let c : C // 1 
	= new C //2
    
enum E {F, G}
let e: E // 3
	= E.F // 4
  1. 문맥상 C는 C 클래스의 인스턴스르 가리킨다.
  2. 문맥상 C는 값 C를 가리킨다.
  3. 문맥상 E는 E 열거형의 타입을 가리킨다.
  4. 문맥상 E는 값 E를 가리킨다.

클래스를 다룰 때는 '이 변수는 이 클래스의 인스턴스여야 한다'라고 표현할 수 있는 방법이 필요한데, 이는. 열거형도 마찬가지다. 클래스와 열거형은 타입 수준에서 타입을 생성하기 때문에 이런 'is-a' 관계를 쉽게 표현할 수 있다.

위 예제에서 C는 C 클래스의 인스턴스를 가리켰다. C 클래스 자체는 어떻게 가리킬 수 잇을까? typeof 키워드를 사용한다. (자바스크립트의 값 수준의 typeof 키워드가 있듯이 타입스크립트에서는 타입 수준의 typeof가 존재한다.)

type State = {
	[key: stirng] : string
}

class StringBasedata {
	state: State = {}
    get(key: string): string | null {
    	// ...
    }
    set(key: string, value: string): void {
    	// ...
    }
    static from(state: State) {
    	// ...
        return db
    }
    
}

// 인스턴스 타입
interface StringDatabase {
	state: State
    get(key: string): string | null
    set(key: string, value: string): void
}

// typeof StringDatabase의 생성자 타입
interface StringDatabaseConstructor {
	new(): StringDatabase
    from(state: State): StringDatabase
}

new() 코드를 생성자 시그니처라 부르며, 생성자 시그니처는 new 연산자로 해당 타입을 인스턴스화할 수 잇음을 정의하는 타입스크립트의 방식이다. 클래스 정의는 용어의 값 수준과 타입 수준으로 생성할 뿐만 아니라, 타입수준에서는 두 개의 용어를 생성했다. 하나는 클래스의 인스턴스를 가리키며, 다른 하나는 클래스 생성자 자체를 가리킨다.

5.7 다형성

클래스와 인터페이스도 기본값과 상한/하한 설정을 포함한 다양한 제너릭 타입 매개변수 기능을 지원한다.

class MyMap<K,V> { // 1
	constructor (initialKey: K, initialValue: V) { // 2
    	// ... 
    }
    get(key: K): V { // 3
    	// ...
    }
    set(key: K, value: V): void {
    	// ...
    }
	merge<K1, V1>(map: MyMap<K1, V1>): MyMap<K | K1, V | V1>{ // 4
    	// ... 
    }
    statid of<K,V>(k:K, v:V): MyMap<K,V>{ // 5
    	// ...
    }
}
  1. class와 함께 제네릭을 선언했으므로 클래스 전체에서 타입을 사용할 수 있다.
  2. constructor에는 제네릭 타입을 선언할 수 없다.
  3. 클래스로 한전된 제네릭 타입은 클래스 내부 어디에서나 사용할 수 있다.
  4. 인스턴스 메서드는 클래스 수준의 제네릭을 사용할 수 있으며 자신만의 제네릭도 추가로 선언할 수 있다. K1, V1를 추가로 선언했다.
  5. 정적 메서드는 클래스의 인스턴스 변수에 값 수준에서 접근할 수 없듯이 클래스 수준의 제네릭을 사용할 수 없다. 자신만의 K,V를 직접 선언했다.
interface MyMap<K,V>{
	get(key: K) : V
    set(key: K, value: V) : void
}

인터페이스에도 제네릭을 사용할 수 있다.

5.8 믹스인

자바스크립트와 타입스크립트는 trait나 mixin 키워드를 제공하지 않지만 손 쉽게 직접 구현할 수 있다. 두 키뭐드 모두 둘 이상의 클래스를 상속받는 다중상속과 관련된 기능을 제공하며, 역할 지향 프로그래밍을 제공한다. 믹스인이란 동작과 프로퍼티를 클래스로 혼합할 수 있게 해주는 패턴으로 다음 규칙을 따른다.

  • 상태를 가질 수있다. ex: 인스턴스 프로퍼티
  • 구체 메서드만 제공할 수 있다.
  • 생성자를 가질 수 있다.

5.9 데코레이터

데코레이터는 타입스크립트의 실험적 기능으로 클래스, 클래스 메서드, 프로퍼티, 메서드 매개변수를 활용한 메타 프로그래밍에 깔끔한 문법을 제공한다. 장식하는 대상의 함수를 호출하는 기능을 제공하는 문법이다.

@serializable
class APIPayload{
	getValue(): Payload {
    	// ...
    }
}

클래스 데코레이터인 @serializable은 APIPayload 클래스를 감싸고 있으며 선택적으로 이를 대체하는 새 클래스를 반환한다.

 타입스크립트는 데코레이터 타입 각각에 대해 주어진 이름 범위에 존재하는 함수와 해당 데코레이터 타입에 요구되는 시그니처를 필요로 한다. 타입스크립트가 기본으로 제공하는 데코레이터는 없다. 직접 구현하거나 NPM으로 설치해야 한다. 모든 종류의 데코레터는 특정 시그니처를 만족하는 일반 함수일 뿐이다.

타입스크립트는 데코레이터가 장식하는 대상의 형태를 바꾸지 않는다고, 즉 메서드나 프로퍼티를 추가하거나 삭제하지 않았다고 가정한다. 반환된 클래스를 전달된 클래스에 할당할 수 있는지 컴파일 타임에만 확인하며, 코드를 작성할 때는 데코레이터가 어떻게 확장되는지 추적하지 않는다. 기능이 더 완벽해지기 전까지는 데코레이터 대신 일반 함수를 사용할 것을 권장한다.

5.10 final 클래스 흉내내기

final 키워드는 클래스나 메서드를 확장하거나 오버라이드할 수 없게 만드는 기능이다. 타입스크립트의 비공개 생성자 흉내낼 수 있다.

class MessageQueue {
	private constructor(private messages: string[])
}

 생성자를 private으로 선언하면 new로 인스턴스를 생성하거나 클래스를 확장할 수 없게된다. 반면 final 클래스는 상속만 막을 뿐 인스턴스는 정상적으로 만들 수 있다. 다음과 같이 한다면 쉽게 해결할 수 있다.

class MessageQueue {
	private constructor(private messages: string[])
    static create(message: string[]){
    	return new MessageQueue(messages)
    }
}

5.11 디자인 패턴

5.11.1 팩토리 패턴

 팩토리 팬던은 어떤 객체를 만들지를 전적으로 팩토리에 위임하는 패턴이다.

type Shoe = {
	purpose: string
}

class BalletFlat implements Shoe {
	purpose: 'dancing'
}


class Boot implements Shoe {
	purpose: 'woodcutting'
}

class Sneaker implements Shoe {
	purpose: 'waliking'
}

let Shoe = {
	create(type : 'BalletFlat', 'Boot', 'Sneaker') : Shoe { // 1
    	switch (type) { // 2
        	case 'BalletFlat' return new BalletFlat
            case 'Boot return new Boot
            case 'Sneaker' return new Sneaker
        }
    }
}
  1. type을 유니온 타입으로 지정해서 컴파일 타임에 호출자가 유효하지 않은 type을 전달하지 못하도록 방지하였다
  2. switch문을 이용해 누락된 Shoe타입이 없는지 타입스크립트가 쉽게 확인할 수 있게 하였다.

5.11.2 빌더 패턴

빌더 패턴으로 객체의 생성과 객체 구현 방식을 분리할 수 있다.

class RequestBulilder {
	private url: string | null = null // 1
    
    setURl(url: string): this { // 2
    	this.url = url
        return this
    }
}
  1. url 이라는 비공개 변수로 사용자가 설정한 URL를 추적한다.
  2. setURL의 반환 타입은 this다. 즉, 사용자가 호출한 특정 RequestBuilder의 인스턴스다.

5.12 마치며

  1. 클래스 선언 방법
  2. 클래스 상속과 인터페이스 구현방법
  3. 클래스를 인스턴스화할 수 없도록 abstract 추가하는 방법
  4. 클래스의 필드와 메서드에 static을 추가하고 인스턴스에는 추가하지 않는 방법
  5. pricate, protected, public 가시성 한정자로 필드와 메서드의 접근을 제어하는 방법
  6. readonly 한정자로 필드에 값을 기록할 수 없게 만드는 방법
  7. this와 super를 안전하게 사용하는 방법
  8. this와 super 이들이 클래스의 값과 타입 모두에 어떤 의미 인지
  9. 타입 별칭과 인터페이스의 차이
  10. 선언 합치기 개념
  11. 클래스에 제네릭 타입을 사용하는 방법
  12. 믹스인, 데코레이터, final 클래스 흉내내기
  13. 팩토리 팩턴과 빌더 패턴 알아보기

을 5장에서 배웠다.

본 글은 Typescript Programming을 요약한 글입니다.
자세한 내요은 본 책을 읽으시길 바랍니다.

 

이번 장에선 다음과 같은 내용을 살펴본다.

  • 타입스크립트에서 함수를 선언하고 실행하는 다양한 방법
  • 시그니처 오버로딩
  • 다형적 함수
  • 다형적 타입 별칭

4.1 함수 선언과 호출

자바스크립트에서 함수는 일급 객체다. 즉, 객체를 다루듯이 함수를 다룰 수 있다.

function add(a : number, b : number) : number {
 return a + b
}

 보통 함수 매개변수의 타입은 명시적으로 정의한다. 타입스크립트는 항상 함수의 본문에서 사용된 타입들을 추론하지만 특별한 상황을 제외하면 매개변수 타입은 추론하지 않는다. 반환 타입은 자동으로 추론하지만 원한다면 명시할 수 있다.

 자바스크립트와 타입스크립트는 최소 다섯 가지의 함수 선언 방법을 지원한다.

// 이름 붙인 함수
function greet(name : string){
	return 'hello' + name
}

// 함수 표현식
let greet2 = function (name : string){
	return 'hello' + name
}

// 화살표 함수 표현식
let greet3 = (name : string) => {
	return 'hello' + name
}

// 단축형 화살표 함수 표현식
let greet4 =(name : string) =>
	return 'hello' + name


// 함수 생성자
let greet5 = new function('name', 'return "hello" + name')

 타입스크립트는 함수 생성자를 제외한 모든 문법을 안전하게 지원하며 이 모든 문법은 보통 매개변수 타입의 필수 어노테이션, 반환 타입의 선택형 어노테이션에 적용하는 것과 같은 규칙을 따른다. 타입스크립트에서 함수를 호출할 때 타입 정보는 따로 제공할 필요가 없으며, 바로 인수를 전달하면 타입스크립트가 함수의 매개변수와 인수의 타입이 호환되는지 확인한다.

4.1.1 선택적 매개변수와 기본 매개변수

함수에서도 ?를 이용해 선택적 매개변수를 지정할 수 있다. 함수의 매개변수를 선언할 때 필수 매개변수를 먼저 지정하고 선택적 매개변수를 뒤에 추가한다.

function log (message : string, userId? : string){
	let time = new Date().toLocalTimeString()
    console.log(time, message, userID || 'Not singed in')
}

log('Page loaded') // "12:38:31 PM Page loaded Not singed in" 출력

log('User signed in', 'da763be') // "12:38:32 PM User signed in da763be" 출력

자바스크립트에서처럼 매개변수에 기본값을 지정할 수 있다. 의미상으로는 호출자가 해당 매개변수에 값을 전달하지 않아도 되므로 매개변수를 선택적으로 만드는 것과 같다.(선택적 매개변수는 뒤에 와야 하지만 기본 매개변수는 어디에나 추가할 수 있다는 점이 다르다.)

function log (message : string, userId='Not singed in'){
	let time = new Date().toLocalTimeString()
    console.log(time, message, userId)
}

기본값 제공으로 인해 선택형 마크와 타입을 지정할 필요가 없어졌다. 덕분에 코드가 간결해지고 읽기도 쉬워진다. 물론 일반 매개변수에 타입을 지정하듯이 기본 매개변수에도 타입을 지정할 수 있다.

type Context = {
	appID? : string
    userID? : string
}

function log (message : string, context : Context ={}){
	let time = new Date().toLocalTimeString()
    console.log(time, message, context.userId)
}

4.1.2 나머지 매개변수

인수를 여러개 받는 함수라면 그 목록을 베열 형태로 건넬 수도 있다.

function sum(numbers : number[]):number{
	return numbers.reduce((total, n) => total + n, 0)
}

  떄로는 인수의 개수가 고정된 고정 인자 API가 아니라 인수의 개수가 달라질 수 있는 가변 인자 API가 필요할 때도 있다. 자바스크립트는 런타임이 함수에 자동으로 arguments를 정의해 개발자가 함수로 전달한 인수 목록을 할당할 수 있도록 기능을 제공한다. arguments는 일종의 배열이므로 .reduce와 같은 내장 기능을 사용하러면 먼저 진짜 배열로 변환해야한다.

function sumVariadic() : number {
	return Array
    	   .from(arguments)
           .reduce((total,n)=> total + n, 0)
}

 하지만 arguments는 전혀 안전하지 않다. 타입스크립트는 n 과 total를  모두 any 타입으로 추론했고 이를 실행시킨다면 함수가 인수를 받지 않도록 선언했으므로 타입스크립트의 입장에선 인수를 받을 수 없다면서 타입 에러를 발생시킨다.

 그렇다면 안전한 타입의 가변 인수 함수를 어떻게 만들 수 있을까? 나머지 매개변수로 이 문제를 해결할 수 있다. 나머지 매개변수를 이용해 sum 함수가 안전하게 임의의 인수를 받게 만든다.

function sumVariadicSafe(...number : numebr[]) : number {
	return number.reduce((total,n)=> total + n, 0)
}

 함수는 최대 한개의 나머지 매개변수를 가질 수 있으며 나머지 매개변수는 함수의 매개변수 목록 맨 마지막에 위치해야 한다.

4.1.3 call, apply, bind

함수를 괄호 ()로 호출하는 방법도 있지만 자바스크립트는 두가지 방법을 추가로 제공한다.

function add(a : number, b : number) : number {
 return a + b
}

add(10,20) // 30으로 평가
add.apply(null,[10,20]) // 30으로 평가
add.call(null, 10,20) // 30으로 평가
add.bind(,null 10,20)() // 30으로 평가
  • apply는 함수 안에서 값을 this로 한정하며 (위 에시에서는 this를 null로 한정) 두 번째 인수를 펼쳐 함수에 매개변수로 전달한다.
  • call도 같은 기능을 수행하지만 인수를 펼쳐 전달하지 않고 순서대로 전달한다는 점이 다르다.
  • bind도 this 인수를 함수의 인수 목록으로 한정한다. 다른점은 bind는 함수를 호출하지 않고 새로운 함수를 반환하는데, 개발자는 ()나 .call을 이용해 반환된 함수를 호출하거나 .apply로 아직 한정하지 않은 매개 변수를 추가로 전달 할 수 있다.

4.1.4 this의 타입

 자바스크립트에서 this 변수는 클래스에 속한 메서드들 뿐만 아니라 모든 함수에서 정의된다. this의 값은 함수를 어떻게 호출했는 지에 따라 달라지는데 이는 자바스크립트를 어렵게 만드는 고질병 중 하나다.

 this가 자주 문제를 일으키는 원인은 할당 방법에 있다. 메서드를 호출할 때 this는 . 점 왼쪽에의 값을 갖는다는 것이 일반적인 원칙이다.

let x ={
 a() {
 	return this
 }
}

x.a() // a()의 바디 안에서 this는 객체 x임

하지만 호출이 일어나기 전 어느 시점에서 a를 다시 할당하면 결과가 달라진다 !

let a = x.a
a() // 이제 a()의 바디 안에서 this는 정의되지 않은 상태임!

다음처럼 날짜의 타입을 포매팅하는 유틸리티 함수가 있다고 가정하자.

function fancyDate(){
	return ${this.getDate()}/${this.getMonth()}/${this.getFullYear()}
}

이 버전의 fancyDate를 호출할며녀 this로 한정할 Date를 제공해야한다. 깜빡하고 Date를 한정하지 않으면 런타임 예외가 발생한다.

fancyDate.call(new Date) // "5/20/2024"로 평가

fancyDate() // TypeError : this.getDate는 함수가 아님

 여기서는 this의 동작은 예상과 크게 다를 수 있다는 점만 짚고 넘어가자. 어떻게 선언하느냐가 아니라 함수를 어떻게 호출하느냐에 영향을 받는다. 다행히 타입스크립트는 이 문제를 잘 처리해준다. 함수에서 this를 사용할 떄는 항상 기대하는 this 타입을 함수의 첫 번째 매개변수로 선언하자. 그러면 함수 안에 등장하는 모든 this가 의도한 this가 됨을 타입스크립트가 보장해준다. 함수 시그니쳐에 사용한 this는 예약어이므로 다른 매개변수와 완전히 다른 방식으로 처리된다.

function fancyDate(this : Date){
	return ${this.getDate()}/${this.getMonth()}/${this.getFullYear()}
}

fancyDate.call(new Date)
fancyDate() // 에러 TS2684 : void 타입의 'this'를 메서드에 속한 'Date'타입의 'this'에 할당할 수 없음

4.1.5 제네레이터 함수

제네레이터 함수는 여러 개의 값을 생성하는 편리한 기능을 제공한다. 이를 이용하면 값을 생상하는 속도도 정교하게 조절할 수 있다. 제네레이터는 요청해야만 다음 값을 계산하기 때문에 무한의 목록 등 생성하기 까다로운 기능을 제공할 수 있다.

function* createFionacciGenenrator() { // 1
	let a = 0
    let b = 1
    while (true) { // 2
    	yield a; // 3
        [a,b] = [b,a + b] // 4
    }
}

let fionacciGenenrator = createFionacciGenenrator()
						// IterableIterator<number>
fionacciGenenrator.next() // {value : 0, done : false} 로 평가
fionacciGenenrator.next() // {value : 1, done : false} 로 평가
fionacciGenenrator.next() // {value : 1, done : false} 로 평가
fionacciGenenrator.next() // {value : 2, done : false} 로 평가
fionacciGenenrator.next() // {value : 3, done : false} 로 평가
fionacciGenenrator.next() // {value : 5, done : false} 로 평가
  1. 함수명 앞의 *는 함수는 제네레이터임을 의미한다. 호출하면 이터러블 반복자가 반환된다.
  2. 영구적으로 값을 생성할 수 있다.
  3. yield라는 키워드로 값을 생성할 수 있다. 다음 값을 요청하면 (예 : next 호출), yield를 이용해 결과를 소비자에게 보내고, 다음 값을 다시 요청하기 전까지는 실행을 중지한다. 이런 방식으로 동작하므로 무한 루프에 걸릴 가능성이 존재하지 않는다.
  4. 피보나치 숫자를 계산하기 위해 a에 b를 , b에 a + b를 한번에 다시 할당한다.

 타입스크립트가 방출된 값의 타입을 이용해 반복자의 타입을 추론함을 알 수 있다.

4.1.6 반복자

반복자는 제네레이터는 상생관계다. 제너레이터로 값의 스트림을 생성할 수 있고 반복자로 생성된 값을 소비할 수 있기 때문이다.

  • 이터러블 : Symbol.iterator 라는 프로퍼티, 반복자를 반환 함수를 가진 모든 객체
  • 반복자 : next라는 메서드(value, done 두 프로퍼티를 가진 객체를 반환)를 정의한 객체

 가령 createFionacciGenenrator 함수를 호출하면 Symbol.iterator 프로퍼티와 next 메서드를 모두 정의한 값을 얻게 된다. 즉, 이터러블과 반복자 두 가지가 결합된 제너레이터가 반환된다. 

 Symbol.iterator 프로퍼티와 next 메서드를 구현하는 객체 또는 클래스를 만들어 반복자나 이터러블을 직접 정의할 수 있다.

let numbers = {
	*[Symbol.iterator](){
    	for(let n = 1; n <=10; n++){
        	yield n
        }
    }
}

 반복자 코드를 코드 편집기에 입력한 다음 마우스를 올려놓으면 타입스크립트가 반복자 코드의 타입을 어떻게 추론하는지 확인할 수 있다. number는 이터러블이며, 제너레이터 함수 numbers[Symbol.iterator]()를 호출하면 이터러블 반복자가 반환된다.

4.1.7 호출 시그니처

함수의 전체 타입을 표현하는 방법을 알아보자.

function sum(a : number, b : number) : number {
 return a + b
}

 sum의 타입은 Function이다. Function은 모든 함수의 타입을 뜻할 뿐이며 그것이 가리키는 특정 함수와 타입과 관련된 정보는 아무것도 알려주지 않는다. 그렇다면 이를 다르게 표현할 수 있는 방법은 무엇일까? sum은 두 개의 number를 인수로 받아 한 개의 number를 반환하는 함수다. 다음과 같이 표현할 수 있다.

(a : number, b : number) => number

  이 코드는 타입스크립트의 함수 타입 문법으로, 호출 시그니쳐 또는 타입 시그니쳐라 부른다. 함수에 함수를 인수로 전달하거나 함수에서 다른 함수를 반환하는 경우 이 문법으로 인수나 반환 함수의 타입을 지정할 수 있다. 함수 호출 시그니쳐는 타입 수준 코드, 즉 값이 아닌 타입 정보만 포함한다. 이는 함수 호출 시그니처로 매개변수 타입, this 타입, 반환 타입, 나머지 타입, 조건부 타입을 표현할 수 있지만 기본값은 표현할 수 없다. 또한 바디를 포함하지 않아 타입스크립트가 타입을 추론할 수 없으므로 반환 타입을 명시해야 한다.

  • 타입 수준 코드와 값 수준 코드
    타입 수준코드는 타입과 타입 연산을 포함하는 코드를 의미한다. 반면 값 수준 코드는 그 밖의 모든 것을 가리킨다.
    어떤 코드가 유효한 자바스크립트 코드라면 값 수준이고, 유효한 자바스크립트 코드는 아니지만 유효한 타입스크립트 코드라면 타입 수준으로 쉽게 구분 가능하다.
 // greet(name : string) 함수
type Greet = (name : string) => string

 // log(message : string, userId? : string) 함수
type Log = (message:string, userId?: string)

 // sumVariadicSafe(...number : number[]) : number 함수
type SumVariadicSafe = (...number : number[]) => number

 함수의 호출 시그니쳐는 구현 코드와 거의 같다. 언어 설계상 의도한 결정으로, 쉽게 호출 시그니처를 추론할 수 있다. 호출 시그니처와 구현의 관계를 더 구체적으로 확인하자. 호출 시그니처가 주어졌을 때 어떻게 그 시그니처를 만족하는 함수를 구현할 수 있을까? 간단하게 호출 시그니처를 함수 표현식과 합칠 수 있다.

type Log = (message : string, userId? : string) => void 
// 기존 함수를 새로운 시그니처에 맞게 다시 구현해보자

let log : log = ( // 1
	message, // 2
    userId = 'Not singed In' // 3
) => {// 4
	let time = new Date().toISString()
    console.log(time,message,userId)
}
  1. 함수 표현식 log를 선언하면서 log 타입임을 명시했다.
  2. Log에서 message의 타입을 string으로 이미 명시했으므로  매개변수의 타입을 다시 지정할 필요는 없다. 
  3. userId에 기본값을 지정한다. 호출 시그니처는 값을 포함할 수 없으므로 Log에서는 userId의 타입은 지정할 수 있지만 기본값은 지정할 수 없기 때문이다.
  4. Log 타입에서 반환 타입을 void로 지정했으므로 반환 타입은 다시 지정할 필요가 없다.

4.1.8 문맥적 타입화

위 에시는 문맥적 타입화라는 타입스크립트의 강력한 추론 기능으로 매개변수의 타입을 명시하지 않아도 되었다.

function times(
	f : (index : number) => void,
    n : number
) {
	for (let i = 0; i < n; i ++){
    	f(i)
    }
}

 times를 호출할 떄 함수 선언을 인라인으로 제공하면 인수로 전달하는 함수의 타입을 명시할 필요가 없다.

times(n => console.log(n), 4)

times의 시그니처에서 f의 인수 index를 number로 선언했으므로 타입스크립트는 문맥상 n의 number임을 추론할 수 있다. 
f 를 인라인으로 선언하지 않으면 타입스크립트는 타입을 추론할 수 없다.

function f(n) { // 에러 TS7006 : 매개변수 'n'의 타입은 암묵적으로 'any'타입이 됨
	console.log(n)
}

4.1.9 오버로드된 함수 타입

 이전 절에서 사용한 함수 타입 문법은 단축형 호출 시그니처다. 이를 더욱 명확하게 표현할 수 있다.

// 단축형 호출 시그니쳐
type Log = (message : string, userId? : string) => void

// 전체 호출 시그니쳐
type Log = {
	(message : string, userId? : string): void
}

두 코드는 문법만 조금 다를 뿐 모든 면에서 같다. 간단한 상황이라면 단축형을 주로 사용하되 더 복잡한 함수라면 전체 시그니처를 사용하는 것이 좋을 때도 있다. 바로 함수 타입의 오버로딩이 좋은 예이다. 

  • 오버로드된 함수 : 호출 시그니처가 여러 개인 함수

 자바스크립트는 동적언어이므로 어떤 함수를 호출하는 방법이 여러 가지다. 뿐만 아니라 인수 입력 타입에 따라 반환 타입이 달라질 때도 있다! 타입스크립트는 이런 동적 특징을 오버로드된 함수 선언으로 제공하고, 입력 타입에 따라 달라지는 함수의 출력 타입은 정적 타입 시스템으로 각각 제공한다. => 타입 시스템의 고급 기능에 속한다. 오버로드 된 함수 시그니처를 사용하면 표현력 높은 API를 설계할 수 있다.

// 휴가 예약 API를 예시로 만들어 보자

type Reserve = {
	(from: Date, to: Date, destination: string) : Resevation
}

let reserve : Reserve = (from, to, destination) => {
	// ...
}

다음 처럼 편도 여향을 지원하도록 API를 개선할 수 있다.

type Reserve = {
	(from: Date, to: Date, destination: string) : Resevation
   	(from: Date, destination: string) : Resevation
}

 그러나 이를 실행할려고 하면 타입스크립트가 에러를 발생시킨다. 이 문제는 타입스크립트가 호출 시그니처 오버로딩을 처리하는 방식 때문에 발생한다. 함수 f에 여러 개의 오버로드 시그니처를 선언하면, 호출자 관점에서 f의 타입은 이들 오버로드 시그니처들의 유니온이 된다.
 하지만 f를 구현하는 관점에서 단일한 구현으로 조합된 타입을 나타낼 수 있어야 한다. 이 조합된 시그니처는 자동으로 추론되지 않으므로 f를 구현할 때 직접 선언해야 한다.

type Reserve = {
	(from: Date, to: Date, destination: string) : Resevation
   	(from: Date, destination: string) : Resevation
} // 1

// 함수를 다음과 같이 바꿀 수 있다.
let reserve : Reserve = (
	from : Date,
    toOrDestination : Date | string,
    destination? : string
) => { // 2
	// ...
}
  1. 오버로드된 함수 시그니처 두 개를 선언한다.
  2. 구현의 시그니처는 두 개의 오버로드 시그니처를 수동으로 결합한 결과와 같다. (즉, Signature 1 | Signature 2를 손으로 계산).
    결합된 시그니처는 reserve를 호출하는 함수에는 보이지 않는다. 즉, 다음은 소비자 관점의 Reserve 시그니처다.
type Reserve = {
	(from: Date, to: Date, destination: string) : Resevation
   	(from: Date, destination: string) : Resevation
}

 결과적으로 이전에 정의한 결합된 시그니처를 모두 포함하지 않는다.

// 잘못됨 !!
type Reserve = {
	(from: Date, to: Date, destination: string) : Resevation
   	(from: Date, destination: string) : Resevation
    (from: Date, toOrDestination: Date | string,
    	Destination? : string) : Resevation
}

 두 가지 방식으로 reserve를 호출할 수 있으므로 reserve를 구현할 때 타입스트립트에 어떤 방식으로 호출되는 지 확인 시켜 주어야한다.

let reserve : Reserve = (
	from : Date,
    toOrDestination : Date | string,
    destination? : string
) => { 
	if(toOrDestination intstanceif Date && destination !== undefined){
    	// 편도 여행 예약
    } else if (typeof toOrDestination === 'string'){
    	// 왕복 여행 예약
    }
}
  • 오버로드 시그니처는 구체적으로 유지하자
    오버로드 된 함수 타입을 선언할 때는 각 오버로드 시그니처를 구현의 시그니처에 할당할 수 있어야 한다. 즉, 오버로드를 할당할 수 있는 범위에서 구현의 시그니처를 얼마든지 일반화할 수 있다.
    오버로드를 사용할 떄는 함수를 쉽게 구현할 수 있도록 가능한 한 구현의 시그니처를 특정하는 것이 좋다. 예를 들어 any 대신 특정한 타입을 사용할 수있다.
    any 타입으로 받은 매개변수를 Date로 사용하고자 한다면 먼저  그 값이 실제로 날짜임을 타입스크립트에 증명해야한다. 그러나 Date타입임을 미리 명시해두면 구현 시 일이 줄어든다.

오버로드는 자연스럽게 브라우저 DOM API에서 유용하게 활용된다. 새로운 HTML 요소를 만들 때 사용하는 createElement DOM API를 예를 들어보자. 이 API는 HTML 태그에 해당하는 문자열을 받아 이 태그 타입의 새 HTML 요소를 반환한다.

type CreateElement = {
	(tag : 'a') : HTMLAnchorElement // 1
    (tag : 'canvas') : HTMLCanvasElement
    (tag : 'table') : HTMLTableElement
    (tag : string) : HTMLElement // 2
}

let createElement : CreateElement = (tag : string) : HTMLElement => { // 3
	// ...
}
  1. 매개변수는 문자열 리터럴 타입으로 오버로드했다.
  2. 오버로드에 지정되지 않은 문자열을 createElement로 전달하면 타입스크립트는 이를 HTMLElement로 분류한다.
  3. 구현의 매개변수는 createElement의 오버로드 시그니처가 가질수 있는 모든 매개변수 타입을 합친 타입 ('a' | 'canvas' | 'table' | string)지원해야한다.
    세 개의 문자열 리터럴 타입은 모두 string의 서브 타입이므로 간단하게 타입 유니온 결과를 string으로 축약할 수 있다.
  • 함수 선언을 오버로드하고 싶다면 동일한 문법을 제공한다.
function createElement(tag : 'a') : HTMLAnchorElement
function createElement(tag : 'a') : HTMLAnchorElement
function createElement(tag : 'canvas') : HTMLCanvasElement
function createElement(tag : 'table') : HTMLTableElement
function createElement(tag : string) : HTMLElement {
	// ...
}

 

 전체 타입 시그니처를 함수 호출 방식 오버로딩에만 사용할 수 있는 것은 아니며 함수의 프로퍼티를 만드는 데도 사용할 수 있다.

function warnUser(warning){
	if(warnUser.wasCalled){
    	return
    }
    warnUser.wasCalled = true
    alert(warning)
}
wasUser.wasCalled = false

type WarnUser = {
	(warning : string) : void
    wasCalled : boolean
}

wasUser는 호출할 수 있는 함수인 불리언 속성인 wasCalled도 가지고 있다.

4.2 다형성

구체 타입이란 지금까지 우리가 살펴본 모든 타입이 구체 타입이다. 기대하는 타입을 정확하게 알고 있고, 실제 이 타입이 전달되었는지 확인할 때는 구체 타입이 유용하다. 하지만 때로는 어떤 타입을 사용할지 미리 알 수 없는 상황이 있는데 이런 상황에서는 함수를 특정 타입으로 제한하기 어렵다. 
 filter라는 함수를 예로 만들며 살펴보자.

function filter(array, f) {
	let result = []
    for (let i = 0; i < array.length ; i++){
    	let item = array[i]
        if(f(item)){
        	result.push(item)
        }
    }
    return result
}

 filter의 전체 타입 시그니처를 unknown에서 number로 가정하여 바꾼다.

type Filter = {
	(array : number[], f : (item : number)=> boolean) : number[]
}

 filter가 범용함수, 즉 숫자, 문자열, 객체, 배열, 기타 모든 것으로 구성된 배열을 거스를 수 있도록 오버로드를 이용해 함수를 확장해보면 코드가 지져분해질 겉다는 점만 제외하면 문제가 없어보인다. 그러나 객체 배열도 지원할 수 있을까?

type Filter = {
	(array : number[], f : (item : number)=> boolean) : number[]
    (array : string[], f : (item : string)=> boolean) : string[]
    (array : object[], f : (item : object)=> boolean) : object[]
}

문제가 없이 실행될것 처럼 보이지만 에러가 발생한다. 객체 배열 시그니처 대로 함수를 구현하고 실행해 보자

let name = [
	{firstName : 'beth'},
    {firstName : 'caitlyn'},
    {firstName : 'xin'},
]

let result = filter(
	names,
    _ => _.firstName.startsWith('b')
) // 에러 TS2339: 'firstName' 프로퍼티는 'object' 타입에 존재하지 않음

result[0].firstName // 에러 TS2339: 'firstName' 프로퍼티는 'object' 타입에 존재하지 않음

 object는 객체의 실제 형태에 대해서는 어떤 정보도 알려주지 않는다는 사실을 기억하자. 따라서 배열에 저장된 객체의 프로퍼티에 접근하려 시도하면 타입스크립트 에러가 발생한다. 배열에 저장된 객체의 형태를 우리가 알려주지 않았기 때문이다. 이 문제를 해결할 수 있는 것이 제네릭 타입이다.

  • 제네릭 타입 매개변수 : 여러 장소에 타입 수준에 제한을 적용할 떄 사용하는 플레이스홀더 타입 = 다형성 타입 매개변수
type Filter = {
	<T>(array:T[], f : (item: T)=> boolean) : T[]
}

 위는 이 타입이 무엇인지 지금 알 수 없으니 누군가 filter를 호출할 때마다 타입스크립트가 타입을 추론해주기 바란다라는 뜻이다. 전달된 array의 타입을 보고 T의 타입을 추론한다. filter를 호출한 시점에 타입스크립트가 T의 타입을 추론해내면 filter에 정의된 모든 T를 추론한 타입으로 대체한다. T는 자리를 맡아둔다는 의미의 '플레이스홀더' 타입이며, 타입 검사기가 문맥을 보고 이 플레이스홀더 타입을 실제 타입으로 채우는 것이다. 이처럼 T는 filter의 타입을 매개변수화한다.

 꺽쇠 괄호 <> 로 제네릭 타입 매개변수임을 선언한다. 꺽쇠 기호를 추가하는 위치에 따라 제네릭의 범위가 결정되며 타입스크립트는 지정된 영역에 속하는 모든 제네릭 타입 매개변수 인스턴스가 한 개의 구체 타입으로 한정되도록 보장한다.  필요하면 꺾쇠괄호 안에 제네릭 타입 매개변수 여러 개를 콤마로 구분해 선언할 수 있다.

 제네릵은 함수의 기능을 더 일반화하여 설명할 수 있는 강력한 도구다. 제네릭을 제한 기능으로 생각할 수 있다. 제네릭 T는 T로 한정하는 타입이 무엇이든 모든 T를 같은 타입으로 제한한다.

4.2.1 언제 제네릭 타입이 한정되는가?

제네릭 타입의 선언 위치에 따라 타입의 범위뿐만 아니라 타입스크립트가 제네릭 타입을 언제 구체 타입으로 한정하는지도 결정된다.

type Filter = {
	<T>(array:T[], f : (item: T)=> boolean) : T[]
}

let filter : Filter = (array,f) =>
	// ...

 

 이 예에서는 <T>를 호출 시그니처의 일부로 (시그니처를 여는 괄호 바로 앞에) 선언했으므로 타입스크립트는 Filter 타입의 함수를 실제 호출할 때 구체 타입을 T로 한정한다. 이와 달리 T의 범위를 Filter의 타입 별칭으로 한정하러면 Filter를 사용할 떄 타입을 명시적으로 한정해야 한다.

type Filter = {
	<T>(array:T[], f : (item: T)=> boolean) : T[]
}

let filter : Filter = (array, f) => // 에러 TS2314 : 제네릭 타입 'Filter'는 한 개의 타입 인수를 요구함

type OtherFilter = Filter // 에러 TS2314 : 제네릭 타입 'Filter'는 한 개의 타입 인수를 요구함

let filter : Filter<number> = (array, f) => 
	// ...
    
type StringFilter = Filter<string>
let stringFilter: StringFilter = (array, f) =>
	// ...

 타입스크립트는 제네릭 타입을 사용하는 순간에 제네릭과 구체 타입을 한정한다. 제네릭을 사용할때란 함수에서는 함수를 호출할 떄, 클래스라면 클래스를 인스턴스화할 떄, 타입 별칭과 인터페이스에서는 이들을 사용하거나 구현할 때를 가리킨다.

4.2.2 제네릭을 어디에 선언할 수 있을까?

타입스크립트에서는 호출 시그니처를 정의하는 방법에 따라 제네릭을 추가하는 방법이 정해져 있다.

type Filter = { // 1
	<T>(array: T[], f : (item: T) => boolean) : T[]
}
let filter : Filter = // ...

type Filter<T> = { // 2
	(array: T[], f : (item: T) => boolean) : T[]
}
let filter : Filter<number> = // ...

type Filter = <T>(array: T[], f : (item: T) => boolean) : T[] // 3
let filter : Filter = // ...

type Filter<T> = <T>(array: T[], f : (item: T) => boolean) : T[] // 4
let filter : Filter<T> = // ...

function filter<T>(array : T[], f : (item: T)=> boolean): T[] { // 5
	// ...
}
  1. T의 범위를 개별 시그니처로한정한 전체 호출 시그니처. 각각의 filter 호출은 자신만의 T 한정 값을 갖는다.
  2. T의 범위를 모든 시그니처로 한정한 전체 호출 시그니처. 타입스크립트는 Filter 타입의 함수를 선언할 떄 한정한다.
  3. 1과 비슷하지만 전체 시그니처가 아닌 단축 호출 시그니처 문법.
  4. 2과 비슷하지만 전체 시그니처가 아닌 단축 호출 시그니처 문법.
  5. T를 시그니처 범위로 한정한, 이름을 갖는 함수 호출 시그니처. 각 filter 호출은 자신만의 T 한정 값을 갖는다.

 map 함수를 구현하여 예시를 들어보자.

function map(array : T[], f : (item : T) => U) : U[] {
	let result = []
    for (let i = 0; i < array.length; i ++){
    	result[i] = f(array[i])
    }
	return result
}

 인수 배열 멤버의 타입을 대변하는 T, 반환 배열 멤버 타입을 대변하는 U, 이렇게 두 가지 제네릭 타입이 필요하다. T 타입의 요소를 포함하는 배열을 전달하면 매핑 함수가 T 타입의 값을 가지고 U 타입의 값을 변환한다. 그리고 최종적으로 U 타입의 함목을 포함하는 배열을 반환한다.

4.2.3 제네릭 타입 추론

대부분의 상황에서 타입스크립트는 제네릭 타입을 추론해낸다. 그러나 제네릭도 명시적으로 지정할 수 있다. 제네릭의 타입을 명시할 때는 모든 필요한 제네릭 타입을 명시하거나 반대로 아무것도 명시해서는 안 된다.

map<string,boolean>(
['a','b','c'],
_ => _ === 'a'
) // OKAY

map<string>(
['a','b','c'],
_ => _ === 'b'
) // 에러 TS 2558: 두 개의 타입 인수가 필요한데 한 개만 전달됨

타입스크립트는 추론된 각 제네릭 타입을 명시적으로 한정한 제네릭에 할당할 수 있는지 확인한다. 할당할 수 없으면 에러가 발생한다.

// boolean은 boolean | string에 할당할 수 있으므로 OK
map<string,boolean | string>(
['a','b','c'],
_ => _ === 'a'
)

map<string,number>(
['a','b','c'],
_ => _ === 'b'
) // 에러 TS 2322: 'boolean'타입은 'number'에 할당할 수 없음

 타입스크립트는 제네릭 함수로 전달한 인수의 정보를 이용해 제네릭의 구체 타입을 추론하므로 다음과 같은 에러가 발생할 수도 있다.

let promise = new Promise(resolve => 
	resolve(45)
)

promise.then(result => // {}로 추론함
	result * 4	// 에러 TS2362 : 수학 연산의 왼쪽 연산자는 'any', 'numebr', 'bigint', enum 타입 중 하나여야 함
)

let promise = new Promise<number>(resolve => 
	resolve(45)
)

promise.then(result => // {}로 추론함
	result * 4	
)

 타입스크립트는 제네릭 함수의 인수에만 의지하여 제네릭 타입을 추론하는데 인수가 아무것도 없으니 기본적으로 T를 {}로 간주하여 에러가 발생하였다. 제네릭 타입 매개변수를 명시해서 해결할 수 있다.

4.2.4 제네릭 타입 별칭

타입 별칭에 제네릭을 활용하는 방법을 자세히 살펴보자.

type MyEvent<T> = {
	target: T
    type: string
}

type ButtonEvent = MyEvent<HTMLButtonElement>

 타입 별칭에서는 타입 별칭명과 할당 기호 사이에만 제네릭 타입을 선언할 수 있다. MyEvent 같은 제네릭 타입을 사용할 때는 타입이 자동으로 추론되지 않으므로 타입 매개 변수를 명시적으로 지정해줘야 한다.

let myEvent : Event<HTMLButtonElement | null> = {
	targer: document.querySelector('#myButton'),
    type: 'click'
}

제네릭 타입 별칭을 함수 시그니처에도 사용할 수 있다. 타입 스크립트는 구체 타입 T로 한정하는 동시에 MyEvent에도 적용한다.

function triggerEvent<T>(event: MyEvent<T>) : void {
	// ...
}

triggerEvent({ // T는 Element | null
	traget : document.querySelector('#myButton'),
    type : 'mouseover'
})
  1. 객체에 triggerEvent를 호출한다.
  2. 함수 시그니처를 통해 MyEvent<T>타입임을 파악된다. 이 타입이 {target: T, type: string}으로 정의 됐다는 것도 파악된다.
  3. 호출자가 전달한 객체의 target 필드가 document.querySelector('#myButton')임이 파악된다.
  4. T의 타입이 document.querySelector('#myButton') 이며 이는 Element | null 타입으로 한정된다.
  5. 타입스크립트가 T를 Element | null로 대체한다.

4.2.5 한정된 다형성

  때로는 U타입은 적어도 T타입을 포함하는 기능이 필요하다. 이런 상황을 U가 T의 상한 한계라고 설명한다. 예는 다음과 같은 세종류의 이진 트리를 구현한다고 가정한다.

  1. 일반 TreeNode
  2. 자식을 갖지 않는 TreeNode인 LeafNode
  3. 자식을 갖는 TreeNode인 InnerNode
type TreeNode = {
	value : string
}

type LeafNode = TreeNode & {
	isLeaf : true
} 

type InnerNode = TreeNode & {
	children : [TreeNode] | [TreeNode, TreeNode]
}

TreeNode의 서브타입을 인수로 받아 같은 서브타입을 반환하는 mapNode 함수를 구현한다면 

function mapNode<T extends TreeNode>( // 1
	node : T, // 2
    f : (value : string) => string
) : T { // 3
	return {
    	...node,
        value: f(node.value)
    }
}
  1. T의 상한 경계는 TreeNode다. 즉, T는 TreeNode가 아니면 TreeNode의 서브타입이다.
  2. mapNode는 두 개의 매개변수를 받는데 첫 번째 매개변수는 T 타입의 노드다. node는  TreeNode가 아니면 TreeNode의 서브타입이다.
  3. mapNode는 타입인 T인 값을 반환한다.
  • extends TreeNode를 생략하고 T 타입만을 사용한다면 T 타입의 상한 경계가 없으므로 node.value를 읽는 행위가 안전해지지 않기에 컴파일 타임 에러를 던질 확률이 커진다.
  • T를 아예 사용하지 않고 mapNode를 TreeNode로만 선언한다면 타입 정보가 날아가 모두 TreeNode로만 인식된다.

T extends TreeNode라고 표현함으로써 매핑한 이후에도 입력 노드가 특정 타입(TreeNode, LeafNode, InnerNode)이라는 정보를 보존할 수 있다.

 

여러 제한을 적용한 한정된 다형성

 만약 여러 개의 제한을 둘려면 어떻게 해야 할까? 단순히 인터섹셕으로 제한들을 이어 붙이면 된다.

type HasSides = {numberOfSides : number}
type SidesHaveLength = {sideLength: number}

function logPerimeter< // 1
	Shape extends hasSides & SideshaveLength // 2
>(s: Shape) : Shape { // 3
	console.log(s.numberOfSides * s.sideLength)
    return s
}
  1. logPerimeter는 Shape 타입의 인자 s 한 개를 인수로 받는 함수다.
  2. Shape는 HasSides 타입과 SidesHaveLength 타입을 상속받는 제네릭 타입이다.
  3. logPerimeter는 인수와 타입이 같은 값을 반환한다.

한정된 다형성으로 인수의 개수 정의하기

가변 인수 함수(임의의 개수의 인수를 받는 함수)에서도 한정된 다형성을 사용할 수 있다. 자바스크립트의 내장함수인 call 함수를 예시로 구현해보자

function call (
	f : (...args : unknown[]) => unknown,
    ...args : unknown[]
) : unknown {
	return f (...args)
}

function fill(length : number, value: string) : string[] {
	return Array.from({legnth}, ()=> value)
}

call(fill, 10, 'a') // 'a' 10개를 갖는 배열로 평가

우리가 표현하려는 제한은 다음과 같다.

  • f는 T 타입의 인수를 몇 개 받아서 R 타입을 반환하는 함수다. 인수가 몇 개인지 미리 알 수 없다.
  • call은 f 한 개와 T 몇 개를 인수로 받으며 인수로 받은 T들을 f가 다시 인수로 받는다. 인수가 몇 개인지 미리 알 수 없다.
  • call은 f의 반환 타입과 같은 R 타입을 반환한다.
function call <T extends unknown[], R> ( // 1
	f : (...args : T) => R, // 2
    ...args : T // 3
) : R { // 4
	return f (...args)
}
  1. call 은 가변 인수로 T와 R 두 개의 타입 매개변수를 받는다. T는 unknown[]의 서브타입, 어떤 타입의 배열 또는 튜플이다.
  2. f 또한 가변 인수 함수로, args와 같은 타입의 인수를 받는다.
  3. args의 타입은 T이며 T는 배열 타입이어야 하므로 타입스크립트는 args용으로 전달한 인수를 보고 T에 맞는 튜플 타입을 추론한다.
  4. call은 R타입의 값을 반환한다.

4.2.6 제네릭 타입 기본값

함수 매개변수와 같이 제네릭 타입 매개변수에도 기본 타입을 지정할 수 있다.

type MyEvent<T> = {
	target: T
    type: string
}

새 이벤트를 만들려면 제네릭 타입을 MyEvent로 명시적으로 한정하여 이벤트가 발생한 HTML요소를 정확하게 가리켜야 한다.

let myEvent : MyEvent<HTMLButtonElement> = {
	targer: myButton
    type: string
}

특정 요소 타입을 알 수 없을 때를 대비해 MyEvent의 제네릭 타입에 기본 값을 추가할 수 있다.

type MyEvent< T = HTMLElement> = {
	target: T
    type: string
}

T가 HTML 요소로 한정되도록 T에 경계를 추가할 수도 있다.

type MyEvent< T extends HTMLElement = HTMLElement> = {
	target: T
    type: string
}

특정 HTML 요소 타입에 종속되지 않는 이벤트도 만들 수 있다. 밑 예시 MyEvent의 T를 HTMLElement에 수동으로 한정하지 않아도 된다.

let myEvent : MyEven = {
	target: myElement
    type: string
}

함수의 선택적 매개변수처럼 기본 타입을 갖는 제네릭은 반드시 기본 타입을 갖지 않는 제네릭의 뒤에 위치해야한다.

type MyEvent2<
	Type extends string,
    Target extends HTMLElement = HTMLElemnet
> = {
	target : Target
    type : type
} // GOOD

type MyEvent3<
    Target extends HTMLElement = HTMLElemnet,
	Type extends string
> = {
	target : Target
    type : type
} // 에러 TS2706 : 필수 타입 매개변수는 선택적 타입 매개변수 뒤에 올수 없음

4.3 타입 주도 개발

  • 타입 주도 개발 : 타입 시그니처를 먼저 정하고 값을 나중에 채우는 프로그래밍 방식

표현력이 높은 타입 시스템을 함수에 적용하면 함수 타입 시그니처를 통해 함수에 관하여 원하는 거의 모든 정보를 얻을 수 있다.

function map<T,U> (array : T[], f: (item: T)=> U) : U[] {
	// ...
}

이전에 map을 본 적 없더라도 이 시그니처를 보고 map이 어떤 동작을 하는지 어느 정도는 감을 잡을 수 있을 것이다. 타입스크립트 프로그램을 구현할 때는 먼저 함수의 시그니처를 정의한 다음 구현을 추가한다.

4.4 마치며 

지금까지의 배운 것들을 요약한다면

  • 함수를 선언하고 호출하는 방법
  • 매개변수의 타입을 지정하는 방법
  • 매개변수의 기본값, 나머지 매개변수, 제너레이터 함수
  • 타입스크립트의 반복자
  • 함수의 호출 시그니처와 구현의 차이
  • 문맥적 타입화
  • 함수를 오버로드하는 다양한 방법
  • 함수의 다형성과 타입 별칭
  • 제네릭 타입은 어디에 선언해야하고 어떻게 추론되는지
  • 제네릭 타입에 한계와 기본값을 설정하고 추가하는 방법

연습 문제

1. 타입스크립트는 함수 타입 시그니처에서 어떤 부분을 추론하는가? 매개변수 타입, 반환 타입 또는 두 가지 모두?

- 타입스크립트는 함수의 실제 구현을 포함한 경우에 한해서 매개변수 타입과 반환 타입을 모두 추론할 수 있다. 그러나 함수 타입 시그니처(즉, 함수의 선언에서 구현이 없는 형태)만으로는 타입스크립트가 매개변수 타입과 반환 타입을 추론할 수 없다. 이런 경우에는 반환 타입을 명시해야 한다.

 

2. 자바스크립트의 arguments 객체는 타입 안전성을 제공하는가? 그렇지 않다면 무엇으로 대체할 수 있는가 ?

- arguments 객체는 함수로 전달된 인수들의 타입 정보를 제공하지 않아  타입스크립트의 타입 체크 기능을 사용할 수 없게 만든다. 나머지 매개변수(Rest Parameters)를 사용하여 arguments 객체를 대체할 수 있다. 나머지 매개변수는 함수의 인수들을 배열로 수집하므로, 타입 안전성을 제공하고 배열 메서드를 사용할 수 있다.

 

3.

type Reserve = {
	(from: Date, to: Date, destination: string) : Reservation
   	(from: Date, destination: string) : Reservation
    (destination: string) : : Reservation
}

 

본 글은 Typescript Programming을 요약한 글입니다.
자세한 내요은 본 책을 읽으시길 바랍니다.
  • 타입 : 값과 이 값으로 할 수 있는 일의 집합

어떤 값이 T 타입이라면, 이 값을 가지고 어떤 일을 할 수 있고 어떤 일을 할 수 없는지도 알 수 있다. 여기서 중요한 점은 타입검사기를 이용해 유요하지 않은 동작이 실행되는 일을 예방하는 것이다.

3.1 타입을 이야기하다

fuction squareOf(n : number){
	return n*n
}
squareOf(2) // 4
squareOf('z') // 에러 TS2345: "z" 라는 타입의 인수는 'number'타입의 매개변수에 할당할 수 없음

이 예제 코드에서 다음을 알 수 있었다.

  1. squareOf의 매개변수 n은 number로 제한된다.
  2. 2 값은 number에 할당할 수 있는 타입이다.

일단 타입을 제한하면 타입스크립트가 함수를 호출할 때 호환이 되는 인수로 호출했는지 판단한다. 이를 경계의 개념으로 해석할 수도 있다. 타입스크립트에 n의 상위 한정값이 number라고 알려주면 squareOf에 전달하는 모든 값이 number 이하여야 한다. 만약 number 이상의 것(number 또는 문자열이 될 수도 있는 값)이라면 n에 할당할 수 없게 된다.

3.2 타입의 가나다

타입스크립트가 지원하는 각각의 타입을 살펴보면서 각 타입이 무엇을 포함할 수 있는지, 어떤 동작을 수행할 수 있는 지 알아보자. 타입 별칭, 유니온 타입, 인터섹션 타입 등 여러 가지 언어 기능도 확인한다.

3.2.1 any

타입스크립트에서는 컴파일 타임에 모두가 타입이 있어야 하므로 프로그래머와 타입스크립트 둘 다 타입을 알 수 없는 상황에서는 기본 타입인 any라고 가정한다. any는 모든 값의 집합이므로 any는 모든 것을 할 수 있다. 되도록이면 any를 피하고 반드시 최후의 수단으로만 사용하자.

  • TSC 플래그 : nolmplitcitAny
    타입스크립트의 기본 설정은 자유를 허용하므로 any로 추론되는 값을 발견하더라도 예외를 발생시키지 않는다. 그러니 암묵적인 any가 나타났을 때 예외를 일으키고 싶다면 tsconfig.json 파일에서 noImplicitAny 플래그를 활성화하자. 그러나 이는 strict 패밀리에 속하기에 strict를 활성화했다면 따로 설정하지 않아도 된다.

3.2.2 unknown

 타입을 미리 알 수 없는 어떤 값이 있을 떄 any 대신 unknown을 사용하자. any처럼 unknown도 모든 값을 대표하지만, unknown의 타입을 검사해 정제하기 전까지는 타입스크립트가 unknown 타입의 값을 사용할 수 없게 강제한다. 비교연산(==, ===, ||, &&, ?)과 반전(!)을 지원하고 자바스크립트의 typeof, instanceof 연산자로 정제할 수 있다.

let a : unknown = 30 // unknown
let b : a === 123
let c = a + 10 // 에러 TSC2571 : 객체의 타입이 'unknown'임
if(typeof a === 'number'){
	let d = a + 10 // number
}
  1. 타입스크립트가 무언가의 타입을 unknown이라고 추론하는 상황은 없다. 명시적으로 설정해야 한다.
  2. unknown 타입이 아닌 값과 unknown 타입인 값을 비교할 수 있다.
  3. 하지만 unknown 값이 특정 값이라고 가정하고 해당 타입에서 지원하느 동작을 수행할 수 없다. 먼저 해당값이 특정 타입임을 증명해야한다.

3.2.3 boolean

boolean 타입은 true, false 두 개의 값을 갖는다. 비교 연산과 반전 연산만을 사용가능하다.

let a = true // boolean
var b = false // boolean
const c = true // true
let d : boolean = true // boolean
let e : true = true // true
let f : true = false // 에러 TS2322: 'false' 타입을 'true' 타입에 할당할 수 없음
  1. 어떤 값이 boolean인지 타입스크립트가 추론하게 한다.
  2. 어떤 값이 특정 boolean인지 타입스크립트가 추론하게 한다.
  3. 값이 boolean임을 명시적으로 타입스크립트에 알린다.
  4. 값이 특정 boolean임을 명시적으로 타입스크립트에 알린다.

실제 프로그래밍에서는 보통 첫 번째와 두 번째 방법을 사용한다. 두번째와 네번째 방법은 값을 타입으로 사용하므로 e와 f에 사용할 수 있는 값은 boolean 타입이 가질 수 있는 값 중 특정한 하나의 값으로 한장된다. 이 기능을 타입 리터럴이라 부른다.

  • 타입 리터럴 : 오직 하나의 값을 나타내는 타입

const를 사용함으로 타입스크립트는 그 변수의 값이 절대 변하지 않으리라는 사실을 알게 되어 해당 변수가 가질 수 있는 가장 좁은 타입으로 추론한다. 타입 리터럴은 모든 곳에서 일어날 수 있는 실수를 방지해 안전성을 추가로 확보해주는 강력한 언어 기능이다.

3.2.4 number

number 타입은 모든 숫자의 집합이다. 덧셈, 뺄셈, 모듈로(%), 비교 등의 숫자 관련 연산을 수행할 수 있다.

let a = 1234 // number
var b = Infinity * 0.10 // number
const c = 5678 // 5678
let d = a < b // boolean
let e : number = 100 // number
let f : 26.218 = 26.218 // 26.218
let g : 26.218 = 10 // 에러 TS2322 : '10' 타입을 '26.218' 타입에 할당할 수 없음
  1. 어떤 값이 number인지 타입스크립트가 추론하게 한다.
  2. const를 이용해 타입스크립트 값이 특정 number임을 추론하게 한다.
  3. 값이 number임을 명시적으로 타입스크립트에 알린다.
  4. 값이 특정 number임을 명시적으로 타입스크립트에 알린다.

boolean 방식처럼 개발자들은 대개 타입스크립트가 number 타입을 추론하도록 만든다. number 타입임을 명시해야 하는 상황은 거의 없다.

3.2.5 bigint

새로 추가된 타입으로 라운딩 관련 에러 걱정 없이 큰 정수를 처리할 수 있다. number는 253까지의 정수를 표현하지만 bigint를 이용하면 이보다 큰 수도 표현할 수 있다. 덧셈, 뺄셈, 곱셈, 나눗셈, 비교 등의 연산을 지원한다.

let a = 1234n // bigint
const b = 5678n // 5678n
var c = a + b // bigint
let d = a < 1235 // boolean
let e = 88.5n // 에러 TS1353 : bigint 리터럴은 반드시 정수여야 함
let f : bigint = 100n // bigint
let g : 100n = 100n // 100n
let h : bigint = 100 // 에러 TS2322 : '100'타입은 'bigint'타입에 할당 할 수 없음

boolean과 number처럼 bigint타입도 네가지 방법으로 선언할 수 있으며 가능하다면 타입스크립트가 bigint 타입으로 추론하도록 두는 것이 좋다

3.2.6 string

string은 모든 문자열의 집합으로 연결(+), 슬라이스(.slice) 등의 연산을 수행할 수 있다.

let a = 'hello' // string
var b = 'billy' // string
const c = '!' // '!'
let d = a + '' + b + c // string
let e : string = 'zoom' // string
let f : 'john' = 'john' // 'john'
let g : 'john' = 'zoe' 에러 TS2322 : "zoe" 타입을 "john"타입에 할당할 수 없음

boolean과 number처럼 string타입도 네가지 방법으로 선언할 수 있으며 가능하다면 타입스크립트가 string타입으로 추론하도록 두는 것이 좋다

3.2.7 symbol

symbol은 ES2015에 새로 추가된 기능이다. 실무에서는 자주 사용하지 않는 편이며 객체와 맵에서 문자열 키를 대신하는 용도로 사용한다. 사람들이 잘 알려진 키만 사용하도록 강제할 수 있으므로 키를 잘못 설정하는 실수를 방지한다. 객체의 기본 반복자를 설정하거나 객체가 어떤 인스턴스인지를 런타임 오버라이딩 하는 것과 비슷한 기능을 제공한다. symbol타입으로 할 수 있는 동작은 그렇게 많지 않다.

let a = Symbol('a') //symbol
let b : symbol = Symbol('b') // symbol
var c = a === b //boolean
let d = a + 'X' // 에러 TS2469" '+' 연산을 'symbol' 타입에 적용할 수 없음

 만들어진 symbol은 고유하여 다른 symbol과 == 또는 ===로 비교했을 때 같지 않다고 판단된다. symbol도 symbol 타입으로 추론되거나 아니면 멍시적으로 unique symbol을 정의할 수 있다.

const e = Symbol('e') // typeof e
const f : unique symbol = Symbol('f') // typeof f
let g : unique symbol = Symbol('f') // 에러 TS1332 : 'unique symbol' 타입은 반드시 const여야 함
let g = e === e // boolean
let i = e ===f // 에러 TS2367 : 'unique symbol'타입은 서로 겹치는 일이 없으므로 이 비교문의 결과는 항상 'false'

unique symbol도 결국 1, true, 'literal' 등 다른 리터럴 타입과 마찬가지로 특정 symbol를 나타내는 타입이다.

3.2.8 객체

객체 타입은 객체의 형태를 정의한다. 객체 타입({ })만으로 만든 간단한 객체와 복잡한 객체(new로 만든)를 구분할 수 없다. 이는 자바스크립트가 구조 기반 타입을 갖도록 설계되었기 때문이다. 따라서 타입스크립트도 이름 기반 타입 보다 이를 선호한다.

  • 구조 기반 타입화 : 객체의 이름에 상관없이 객체가 어떤 프로퍼티를 갖고 있는지를 따진다.
let a : object = {
	b : 'x'
}

a.b // 에러 TS2399 : 'b' 프로퍼티는 object에 존재하지 않음

object는 서술하는 값에 관한 정보를 거의 알려주지 않으며, 값 자체가 자바스크립트 객체이자 null이 아니라고만 말해줄 뿐이다.

let a = {
	b : 'x'
} // {b : string}
a.b // string

let a : {b : number} {
	b : 12
} // {b : number}

위는 객체 리터럴 문법이다. 타입스크립트가 a의 형태를 추론하게 하거나 중괄호 안에서 명시적으로 타입을 묘사할 수 있다.

  • 객체를 const로 선언할 때의 타입추론 : 자바스크립트 객체의 값은 바뀔 수 있으며, 타입스크립트도 객체를 만든 . 후필드 값을 바꾸려 할수 있다는 사실을 알기에 다른 기본타입과 달리 더 좁은 타입으로 추론하지 않는다.
let a : {b : nmumber}
a = {} // 에러 TS2741 : '{}'타입에는 {b : nmumber} 타입에 필요한 'b'가 없음

a = {
	b : 1,
    c : 2 // 에러 TS2322 : '{b : number; c : number}'타입을 {b : nmumber}에 지정할 수 없음
}
  • 확싫한 할당 : 변수를 선언하고 나중에 초기화 하는 상황에서 타입스크립틑 변수를 사용하기 전에 값을 할당하도록 강제한다.

타입스크립트는 객체 프로퍼티에 엄격한 편이다. 예를 들어 객체에 number 타입의 b라는 프로퍼티가 있어야 한다고 정의하면 b가 없거나 다른 추가 프로퍼티가 있으면 에러를 발생시킨다. 

let a : {
	b : number
    c? : string
    [key : number] : boolean
}
  1. a는 number 타입의 프로퍼티 b를 포함한다.
  2. a는 string 타입의 프로퍼티 c를 포함할 수도 있다.
  3. a는 boolean 타입의 값을 갖는 number 타입의 프로퍼타를 여러 개를 포함할 수 있다.
a = {10 :true} // 에러 TS2741 : {10 : true} 타입에는 'b' 프로퍼티가 없음
a = {b : 1, 33 : 'red'}  // 에러 TS2741 : 'string'타입은 'boolean' 타입에 할당할 수 없음
  • 인덱스 시그니쳐 : [key : T] : U 와 같은 문법은 타입스크립트에 어떤 객체가 여러 키를 가질 수 있음을 알려준다. 명시적으로 정의한 키 외에 다양한 키를 객체에 안전하게 추가할 수 있다. 인덱스 시그니쳐의 키 T 는 반드시 number나 string 타입에 할당할 수 있는 타입이어야한다. 키 이름은 원하는 이름을 가져다 바꿔도 된다. 예시) let airplaneSeatingAssignments : {[seatNumber : string] : string}

 필요한 경우 readonly 한정자를 이용해 특정 필드를 읽기 전용으로 정의할 수 있다. 객체 리터럴 표기법에는 빈 객체 타입({})이라는 특별한 상황이 존재한다. null과 undefined를 제외한 모든 타입은 빈 객체 타입에 할당할 수 있으나 사용하기 까다롭기에 피하는 것이 좋다.

 마지막으로 객체 : Object로 객체 타입을 만드는 방법도 있다. {}과 비슷한 방법이며 마찬가지로 가능하면 사용하지 않아야 한다. 객체를 정의하는 방법은 다음과 같이 네 가지로 요약할 수 있다.

  1. 객체 리터럴 또는 형태라 불리는 표기법({a : string}). 객체가 어떤 필드를 포함할 수 있는지 알고 있거나 객체의 모든 값이 같은 타입을 가질 떄 사용한다.
  2. 빈 객체 리터럴 표기법({}). 이 방법믄 비추천한다.
  3. object 타입. 어떤 필드를 가지고 있는지는 관심 없고, 그저 객체가 필요할 때 사용한다.
  4. Object 타입. 이 방법은 비추천한다.

3.2.9 타입 별칭, 유니온, 인터섹션

값 뿐만 아니라 타입에도 어떤 동작을 수행 할 수 있다.

타입 별칭

변수를 선언해서 값 대신 변수로 칭하듯이 타입 별칭으로 타입을 가리킬 수 있다.

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

 타입스크립트는 별칭을 추론하지 않으므로 반드시 별칭의 타입을 명시적으로 정의해야한다.

let age : Age = 55
let driver : Person = {
	name : 'James May',
    age : age
}

Age는 number의 별칭이므로 number에도 할당할 수 있다.

let age = 55
let driver : Person = {
	name : 'James May',
    age : age
}

정리하면 위 코드처럼 바꿀 수 있다.

 자바스크립트 변수 선언과 마찬가지로 하나의 타입을 두번 정의할 수는 없다. let과 const처럼 타입 별칭에도 블록 영역이 적용된다. 내부에 정의한 타입 별칭이 외부의 정의를 덮어쓴다. 타입 별칭은 복잡한 타입을 DRY하지 않도록 해주며 변수가 어떤 목적으로 사용되었는지 쉽게 이해할 수 있게 도와준다. 값을 변수로 할당할지를 결정하는 것과 같은 기준으로 타입 별칭을 사용할지 여부를 결정할 수 있다.

유니온과 인터섹션 타입

 타입스크립트는 타입에 적용할 수 있는 특별한 연산자인 유니온(|)과 인터섹션(&)을 제공한다. 타입은 집합과 비슷하므로 집합처럼 연산을 수행할 수 있다.

type Cat = {name : string, purrs : boolean}
type Dog = {name : string, bark : boolean, wags : boolean}
type CarOrDogOrBothh = Cat | Dog
type CatAndDog = Cat & Dog

// Cat
let a : CarOrDogOrBoth = {
	name : 'Bonkers',
    purrs : true
}

// Both
a = {
	name : 'Donkers',
    barks : true,
    purrs : true,
    wags : true
}

let b : CatAndDog = {
	name : 'Domino',
    barks : true,
    purrs : true,
    wags : true
}

 유니온 타입 (|)에 사용된 값이 꼭 유니온을 구성하는 타입 중 하나일 필요는 없으며 양쪽 모두에 속할 수 있다.

실전에서는 대개 인터섹션보다 유니온을 자주 사용한다.

function trueOrNull(isTrue : boolean) {
	if(ifTrue){
    	return 'true'
    }
    return null
}

이 함수는 string 또는 null을 반환할 수 있다. 이를 다음처럼 표현할 수 있다.

type Returns = string | null

다음 함수 예제를 보자

function(a : string, b : number){
	return a || b
}

조건이 참이면 반환타입이 string이고 그렇지 않으면 number다. 즉, string | number를 반환한다.

3.2.10 배열

 타입스크립트 배열도 연결, 푸시, 검색, 슬라이스 등을 지원하는 특별한 객체이다.

let a = [1,2,3] // number[]
var b = ['a','b'] // string[]
let c : string[] = ['a'] // string[]
let d = [1, 'a'] // (numbner | string)[]
const e = [2, 'b'] // (numbner | string)[]

let f = ['red']
f.push('blue')
f.push(true) // 에러 TS2345 : 'true' 타입 인수를 'string' 타입 매개변수에 할당할 수 없음

let g = [] // any[]
g.push(1) // number[]
g.push('red') // (string | number)[]

let h : number[] = [] // number[]
h.push(1) // number[]
h.push('red') // 에러 TS2345 : 'red' 타입 인수를 'number' 타입매개변수에 할당할 수 없음

  대개 배열을 동형으로 만든다. 즉.  한 배열에 모든 항목이 같은 타입을 갖도록 설계하려 노력한다. 그렇지 않으면 타입스크립트에 배열과 관련한 작업이 안전한지 증명해야 하므로 추가 작업을 해야 한다.

 예제 f를 보면 왜 동열 배열의 처리가 쉬운지 알 수 있다. 배열을 선언하고 문자열 타입의 값을 추가했을 때 타입스크립트는 이 배열이 문자 값을 갖는 배열이라 추론한다. 'blue'는 문자열이므로 아무 문제없이 추가되지만 true를 추가하려하면 에러가 발생한다. 반면 d는 초기화하면서 number와 string을 저장했으므로 타입스크립트는 d의 타입을 number | string으로 추론한다.객체와 마찬가지로 배열을 const로 만들어도 타입스크립트는 타입을 더 좁게 추론하지 않는다.

 g는 특별한 상황으로, 빈 배열로 초기화 하면 타입스크립트는 배열의 요소타입을 알 수 없으므로 any일 것으로 추측한다. 배열을 조작하여 요소를 추가하면 타입스크립트가 주어진 정보를 이용해 배열의 타입을 추론한다. 배열이 정의된 영역을 벗어나면(예 : 함수 안에서 배열을 선언하고 이를 반환) 타입스크립트는 배열을 더 이상 확장할 수 없도록 최종타입을 할당한다.

3.2.11 튜플

 튜플은 길이가 고정되어 있고, 각 인덱스의 타입이 알려진 배열의 일종이다. 다른 타입과 달리 튜플은 선언할 떄 타입을 명시해야 한다. 자바스크립트에서 배열과 튜플에 같은 대괄호을 사용하는데 타입스크립트에서는 대괄호를 배열 타입으로 추론하기 때문이다.

let a : [number] = [1]

// [이름, 성씨, 생년] 튜플
let b : [string, string, number] = ['malcolm','gladwell',1963]
b = ['queen', 'elizabeth', 'ii', 1962] // 에러 TS2322 : 'string'은 'number'타입에 할당할 수 없음

튜플은 선택형 요소도 지원한다. 객체 타입에서와 마찬가지로 ?는 선택형을 뜻한다.

// 방향에 따라 다른 값을 갖는 기차 요금 배열
let trainFares : [number, number?][] = [
	[3.75],
    [8.75, 7.70],
    [10.50]
]

// 다음과 같음
let moreTrainFares : ([number] | [number, number])[] = [
 // ...
]

 또한 튜플이 최소 길이를 갖도록 지정할 때는 나머지 요소(...)를 사용할 수 있다.

let friends : [string, ...string[]] = ['Sara', 'Tail', 'Chloe', 'Claire']
// 이형 배열
let list : [number, boolean, ...string[]] = [1, false, 'a', 'b', 'c']

 

읽기 전용 배열과 튜플

 타입스크립트는 readonly 배열 타입을 기본을 지원하므로 이를 이용해 볼변 배열을 바로 만들 수 있다. 읽기 전용 배열은 명시적 타입 어노테이션으로 만들 수 있다. 읽기 전용 배열을 갱신하려면 .push, .slice처럼 내용을 바꾸는 동작 대신 .concat, .slice같이 내용을 바꾸지 않는 메서드를 사용해야 한다.

let as : readonly number[] = [1,2,3] // readonly number[]
let bs : readnoly numner[] = as.concat(4) // readonly number[]
let three = bs[2] // number
as[4] = 5 // 에러 TS2542 : 'readonly number[]'의 인덱스 시그니쳐 타입은 읽기만 허용함
as.push(6) // 에러 TS2339 : 'push' 프로퍼티는 'readonly number[]' 타입에 존재하지 않음

타입스크립트는 Array처럼 읽기 전용 배열과 튜플을 만드는 긴 형태의 선언 방법을 지원한다.

type A = readonly string[] // readonly string[]
type B = ReadonlyArray<string> // readonly string[]
type C = Readonlt<string[]> // readonly string[]

type D = readonly [number, string] // readonly [number, string]
type E = Readonly<[number, string]> // readonly [number, string]

읽기 전용 배열은 스프레드(...)나 .slice 등으로 배열을 조금만 바꿔도 우선 배열을 복사해야 하므로, 주의하지 않으면 응용 프로그램의 성능이 느려질 수 있다.

3.2.12 null, undefined, void, never

 자바스크립트는 null, undefined 두 가지 값으로 부재를 표현한다. 타입스크립트도 두 가지 값 모두를 지원한다. 타입스크립트에서 undefined 값의 타입은 오직 undefined 뿐이고 null 값의 타입은 null 뿐이라는 점에서 특별한 타입이다. 두 값은 조금 다른데 undefined는 아직 정의하지 않았음을 의미하는 반면 null은 값이 없다는 의미다. 

 타입스크립트는 이외에도 void와 never 타입도 제공한다. void는 명시적으로 아무것도 반환하지 않는 함수의 반환타입을 가리키며 never는 절대 반환하지 않는 함수 타입을 가리킨다.

// (a) nnumber 또는 null을 반환하는 함수
function a (x : number){
	if(x<10){
    	return x
    }
    return null
}

// (b) undefined를 반환하는 함수
function b() {
	return undefined
}

// (c) void를 반환하는 함수
function c() {
	let a = 2 +2
    let b = a * a
}

// (d) never를 반환하는 함수
function d() {
	throw TypeError('I always error')
}

// (e) never를 반환하는 함수
function e() {
	while (true) {
    	dosomething()
    }
}
  1. a은 null를 , b는 undefined를 명시적으로 반환한다.
  2. c는 undefined를 반환하지만 명시적인 return문을 사용하지 않았으므로 void를 반환한다고 말할 수 있다.
  3. d는 예외를 던진다
  4. e는 영원히 실행되며 반환하지 않았므로 반환타입이 never라 할 수 있다.

unknown이 모든 타입의 상위 타입이라면 never는 모든 타입의 서브타입이다. 즉, 모든 타입에 never를 할당할 수 있으며 never 값은 어디서든 안전하게 사용할 수 있다.

타입 의미
null 값이 없음
undefined 아직 값을 변수에 할당하지 않음
Void return문을 포함하지 않는 함수
never 절대 반환하지 않는 함수
  • 엄격한 null 확인
    예전 버전의 타입스크립트 또는 strictNullCheck 옵션을 false인 경우에서는 null이 조금 다르게 작동한다. 이때 null은 never를 제외한 모든 타입의 하위 타입이다. 즉, 모든 타입이 null이 될 수 있으므로 모든 값이 null인지 아닌지 먼저 확인하지 않고는 타입이 무엇이라고 단정할 수 없다. 실무에서는 이는 굉장히 불편한 일이므로 보통 이과정을 생략한다. 그리고 예상치 않은 상황에서 값이 null이라면 런타임에 치명적인 널 포인터 예외가 발생한다.

3.2.13 열거형

열거형은 해당 타입으로 사용할 수 있는 값을 열거하는 기법이다. 열거형은 키를 값에 할당하는, 순서가 없는 자료구조다. 키가 컴파일 타임에 고정된 객체라고 생각하면 쉽다. 따라서 타입스크립트는 키에 접근할 떄 주어진 키가 실제 존재하는 지 확인할 수 있다.

enum Language {
	English,
    Spanish,
    Russain
}

타입스크립트는 자동으로 열거형의 각 멤버에 적절한 숫자를 추론해 할당하지만, 값을 명시적으로 설정할 수도 있다.

enum Language {
	English = 0,
    Spanish = 1,
    Russain = 2
}

점 또는 괄호 표기법을 열거형 값에 접근할 수 있다.

let myFirstLanguage = Language.Russian // Language
let mySecondLanguage = Language['English'] // Language

열거형을 여러 개로 나눠 저의한 다음 타입스크립트가 이들을 합치도록 할 수도 있다. 타입스크립트는 여러 열거형 정의 중 한 가지 값만 추론할 수 있으므로 열거형을 분할할 때 주의해야 하며, 각 열거형 멤버에 명시적을 값을 할당하는 습관을 기르는 것이 좋다. 계산된 값을 사용할 수도 있으므로 모든 값을 정의할 필요는 없다.(빠진 값은 타입스크립트가 추론한다.)

enum Language {
	English = 100,
    Spanish = 200 + 300,
    Russain // 501로 추론
}

열거형에 문자열 값을 사용하거나 문자열과 숫자 값을 혼합할 수 있다. 타입스크립트에서는 값이나 키로 열거형에 접근할 수 있도록 허용하지만 이는 불안정한 결과를 초래하기 쉽다.

let a = Color.Red // Color
let b = Color.Green // 에러 TS2339 : 'Green' 프로퍼티는 'typeof Color'타입에 존재하지 않음

let c = Color[255] // stirng
let d = Color[6] // string !!! << 에러가 발생하지 않고 실행된다

더 안전한 열거형 타입인 const enum을 이용하면 타입스크립트가 이런 안전하지 않은 작업을 막도록 만들 수 있다.

const enum Language {
	English ,
    Spanish ,
    Russain 
}

// 유요한 enum 키 접근
let a = Language.English // Language

// 유요하지않은 enum 키 접근
let b = Language.Taglog // 에러 TS2339: 'Taglog' 프로퍼티는 'typeof Language'타입에 존재하지 않음

// 유요한 enum 키 접근
let c = Language[0] // 에러 TS2476: const enum 멤버는 문자열 리터럴로만 접근할 수 있음

// 유요하지 않은 enum 키 접근
let d = Language[6] // 에러 TS2476: const enum 멤버는 문자열 리터럴로만 접근할 수 있음

const enum은 기본적으로 아무 자바스크립트도 생성하지 않으며 그 대신 필요한 곳에 열거형 멤버의 값을 채워 넣는다. (타입스크립트는 Language.Spanish가 사용된 모든 코드를 값 1로 바꾼다.)

  • TSC 플래그 : preserveConstEnums
    누군가의 타입스크립트 코드에 정의된 const enum을 가져왔을 때는 이 채워 넣기 기능이 문제를 일으킬 수 있다. 개발자가 타입스크립트 코드를 컴파일한 이후에 열거형을 만든 사람이 자신의 const enum을 갱신하면 런타임에 같은 열거형이 버전에 따라 다른 값을 갖게 되고, 타입스크립트가 이 상황에서 할 수 있는 일은 없다.
    const enum을 사용할 때는 채워 넣기 기능을 되도록 피해야 하며 제어할 수 있는 타입스크립트 프로그램에서만 사용해야한다. NPM으로 배포하거나 라이브러리로 제공할 프로그램에서는 const enum를 사용하지 말아야한다.
    const enum의 런타임 코드 생성을 활성화할려면 tsconfing.json 파일에서 preserveConstEnums TSC 설정을 true로 바꾼다.
const enum Flippable {
	Burger,
    Chair,
    Cup,
    Skateboard,
    Table
}

function flip(f : Filppable) {
	retrun 'flipped it'
}

filp(Flippable.Chair) // 'flipped it'
filp(12) // 'flipped it' !!!! <<< 에러가 발생해야하지만 실행된다

위 예제처럼 타입스크립트 할당규칙 때문에 생긴 운이 나쁜 결과로 에러가 발생하지 않고 실행될 수 있다. 문자열 값을 갖는 열거형을 사용해 해결할 수 있다. 결과적으로 숫자 값을 받는 열거형은 전체 열거형의 안정성을 해칠 수 있다.

const enum Flippable {
	Burger = 'Burger',
    Chair = 'Chair',
    Cup = 'Cup',
    Skateboard = 'Skateboard',
    Table = 'Table'
}

function flip(f : Filppable) {
	retrun 'flipped it'
}

filp(Flippable.Chair) // 'flipped it'
filp(12) // 에러 TS2345: '12' 인수 타입은 'Flippable' 매개변수 타입에 할당할 수 없음
filp('Hat') // 에러 TS2345: 'Hat' 인수 타입은 'Flippable' 매개변수 타입에 할당할 수 없음
  • 열거형을 안전하게 사용하는 방법은 까다로우므로 열거형 자체를 멀리할 것을 권한다. 타입스크립트에는 열거형을 대체할 수단이 많다.

3.3 마치며

타입스크립트가 값의 타입을 추론하도록 하거나 값의 타입을 명시할 수 있다. let과 var를 사용하면 일반적인 타입으로 추론하는 반면, const를 이용하면 더 구체적인 타입을 추론하게 만든다. 구체적 타입은 보통 일반 타입의 서브 타입이다.

타입 서브 타입
Boolean 볼 리터럴
bigint 큰 정수 리터럴
number 숫자 리터럴
string 문자열 리터럴
symbol unique symbol
object 객체 리터럴
Array 튜플
enum const enum
본 글은 Typescript Programming을 요약한 글입니다.
자세한 내요은 본 책을 읽으시길 바랍니다.

 본 장은 타입스크립트 컴파일러(TypeScript Compiler, TSC)의 동작원리, 타입스크립트의 기능 소개, 프로그램 개발에 적용할 수 있는 패턴 등을 소개한다.

2.1 컴파일러

 프로그램은 프로그래머가 작성한 다수의 텍스트 파일로 구성된다. 이 텍스트를 컴파일러라는 특별한 프로그램이 파싱하여 추상 문법 트리(abstract syntax tree, AST)라는 자료구조로 변환한다. 그리고 컴파일러는 다시 AST를 바이트코드라는 하위 수준의 표현으로 변환한다. 이후 런타임이라는 다른 프로그램에 바이트코드를 입력해 평가하고 결괄르 얻을 수 있다.

  1. 프로그램이 AST로 파싱된다.
  2. AST가 바이트코드로 컴파일된다.
  3. 런타임이 바이트코드를 평가한다.

 타입스트립트가 다른 언어와 다른 점은 컴파일러가 코드를 바이트코드 대신 자바스크립트 코드로 변환한다는 점이다. 타입스크립트 컴파일러는 AST를 만들어 결과 코드를 내놓기 전에 타입 확인을 거친다.

  • 타입 검사기 : 코드의 타입 안전성을 검증하는 특별한 프로그램

타입 확인과 자바스크립트 방출 부분을 포함하면 타입스크립트 컴파일 과정은 대략 예시처럼 된다.

  1. 타입스크립트 소스 => 타입스크립트 AST
  2. 타입 검사기가 AST를 확인
  3. 타입스크립트 AST => 자바스크립트 소스 : TS 영역
  4. 자바스크립트 소스 => 자바스크립트 AST
  5. AST => 바이트 코드
  6. 런타임이 바이트코드를 평가 : JS 영역

TSC가 타입스크립트 코드를 자바스크립트 코드로 컴파일할 때는 개발자가 사용한 타입을 확인하지 않는다. 개발자가 코드에 기입한 타입 정보는 최종적으로 만들어지는 프로그램에 아무런 영향을 주지 않으며, 단지 타입을 확인하는 데만 쓰인다는 뜻이다.

2.2 타입 시스템

  •  타입 시스템 : 타입 검사기가 프로그램에 타입을 할당하는 데 사용하는 규칙 집합

 타입 시스템은 보통 두가지 종류로 나뉜다. 어떤 타입을 사용하는 지를 컴파일러에 명시적으로 알려주는 타입 시스템과 자동으로 타입을 추론하는 타입 시스템으로 구분된다. 타입 스크립트는 두 가지 시스템 모두의 영향을 받았다. 즉, 개발자는 타입을 명시하거나 타입스크립가 추론하도록 하는 방식 중에서 선택할 수 있다.

타입스크립트 vs 자바스크립트

타입 시스템 기능 자바스크립트 타입스크립트
타입 결정 방식 동적 정적
타입이 자동으로 변환되는가? O X
언제 타입을 확인하는가? 런타임 컴파일 타임
언제 에러를 검출하는가? 런타임(대부분) 컴파일 타임(대부분)

 

타입은 어떻게 결정되는가?

 동적 타입 바인딩이란 자바스크립트가 프로그램을 실행해야만 특정 데이터의 타입을 알 수 있음을 의미한다. 타입스크립트는 점진적으로 타입을 확인하는 언어다. 즉, 타입스크립트는 컴파일 타임에 프로그램의 모든 타입을 알고 있을 떄 최상의 결과를 보여줄 수 있지만, 프로그램을 컴파일하는 데 반드시 모든 타입을 알아야 하는 것은 아니다.

자동으로 타입이 변환되는가?

 자바스크립트는 약한 언어다. 유효하지 않은 연산을 수행하면 다양한 규칙을 적용해가며 개발자가 정말 의도한 바를 알아내려 노력하고, 주어진 정보로 최상의 결과를 도출한다. 반면 타입스크립트는 유효하지 않은 작업을 발견하는 즉시 불평한다. 같은 자바스크립트 코드를 TSC로 실행하면 다음처럼 에러가 발생한다.

3 + [1] ; // 에러 TS2365 : '+' 연산자를 '3'과 'number[]'타입에 적용할 수 없음
(3).toString() + [1].toString() // "31"로 평가

 올바르지 않아 보이는 연산을 수행하면 타입스크립트가 바로 그 부분을 지적하며, 의도를 명시해야 타입스크립트의 지적을 무사히 통과할 수 있다.

언제 타입을 검사하는가?

자바스크립트는 주어진 상황에서 개발자가 무엇을 의도하는지에 맞춰 변환하려 최대한 노력할 뿐 거의 대부분의 상황에서 타입이 무엇인지 따지지 않는다. 반면 타입스크립트는 컴파일 타임에 코드의 타입을 확인하기 떄문에 코드를 실행하지 않고도 이전 예제 코드에 에러가 있음을 바로 알 수 있다. 타입스크립트는 정적으로 코드를 분석해 이런 에러를 검출하여 코드를 실행하기도 전에 알려준다.

에러는 언제 검출되는가?

자바스크립트는 런타임에 예외를 던지거나 암묵적 형변환을 수행한다. 즉, 프로그램을 실행해야만 어떤 문제가 있음을 확인할 수 있다. 타입스크립트는 컴파일 타임에 문법 에러와 타입 관련 에러를 모두 검출한다. 실제 개발자가 코딩을 시작하면 코드 편집기가 이런 종류의 에러를 바로 보여준다.

2.3 코드 편집기 설정

 TSC 자체도 타입스크립트로 구현된 명령행 도구(이런 이유로 자체 호스팅 컴파일러 또는 자신을 컴파일하는 컴파일러라는 특별한 종류의 컴파일러가 된다)이므로 TSC를 실행하려면 NodeJS가 필요하다. NPM을 이용해 TSC와 TSLint(TypeScript Linter)를 설치한다.

# 새 디렉터리 생성
mkdir chapter-2
cd chapter-2

# 새 NPM 프로젝트 초기화 (프롬프트의 지시에 따름)
npm init

# TSC, TSLint, NodeJS용 타입 선언 설치
npm install --save-dev typescript tslint @types/node

2.3.1 tsconfig.json

모든 타입스크립트 프로젝트는 루트 디렉터리에 tsconfig.json이라는 파일이 존재해야 한다. tsconfig.json 파일은 타입스크립트의 프로젝트에서 어떤 파일을 컴파일하고, 어떤 자바스크립트 버전을 방출하는 지 등을 정의한다.( ./node_modules/.bin/tsc --init 이라는 타입스크립트의 내장 명령을 이용해 자동으로 설정할 수 있다.)

{
	"compilerOptions" : {
    	"lib": ["es2015"],
        "module": "commonjs",
        "outDir": "dist",
        "sourceMap: true,
        "strict": true,
        "target": "es2015"
    },
    "include": [
    	"src"
    ] 
}
옵션 설명
include TSC가 타입스크립트 파일을 찾을 디렉터리
li TSC가 코드 실행 환경에서 이용할 수 있다고 가정하는 API(ES5의 Fuction.prototype.bind, ES2015의 Object.assign, DOM의 document.querySelector 등)
module TSC가 코드를 컴파일할 대상 모듈 시스템(CommonJS, SystemJS, ES2015 등)
outDir 생성된 자바스크립트 코드를 출력할 디렉터리
strict 유효하지 않은 코드를 확인할 떄 가능한 엄격하게 검사함. 이 옵셥을 이용하면 코드가 적절하게 타입을 갖추도록 강제할 수 있다. 이 책에서는 모든 경우에 strict 옵션을 적용하므로 프로젝트에도 이 옵션을 사용하도록 권장한다.
target TSC가 코드를 컴파일할 자바스크립트 버전(ES3, ES5, ES2015 등)

 위 표는 tsconfig.json이 지원하는 옵션 중 일부만 나열했으며 언제든 새로운 옵션을 추가할 수 있다. 실무에서는 이 옵션들을 자주 바꿀 일은 없다. 다만 새로운 번들러를 추가하거나, 브라우저용 타입스크립트를 작성하기 위해 "dom"을 lib에 추가하거나, 자바스크립트 코드를 타입스크립트로 마이그레이션할 때 엄격함의 수준을 조절하는 상황 등에서는 옵션 설정을 바꿔야 한다.

 tsconfig.json 파일을 이용해 간편하게 소스 버전 관리 시스템에 설정을 포함할 수 있고, 명령행을 이용해 TSC의 옵션 대부분을 제어하는 방법도 있다.

2.3.2 tslint.json

보통 프로젝트는 TSLint 설정을 정의하는 tslint.json 파일도 포함된다. ./node_modules/.bin/tslint -init 으로 기본값으로 채워진 파일을 만들 수 있다. 그리고 만들어진 파일을 자신의 코딩 스타일에 맞게 편집할 수 있다.

{
	"defaultSeverity": "error",
    "extends": [
    	"tslint:recommended"
    ],
    "rules" : {
    	"semiconlon": false,
        "trailing-comma": false
    }
}

2.4 index.ts

 tsconfig.json, tslint.json 파일을 설정했으면 타입스크립트 파일을 추가한다.

mkdir src
touch src/index.ts

이후 index 파일에 코드을 입력한다!

  • 이는 백지상태에서 설정해본 경험이 없다고 가정하고 각 단계를 차근차근 설명했다. 다음부터는 프로젝트를 더 빠르게 설정할 수 있는 여러 방법을 이용할 수 있다.
  • ts-node를 설치한다. 이를 이용하면 명령 한 번으로 타입스크립트를 컴파일하고 실행할 수 있다.
  • typescript-node-starter 같은 뼈대 제공 도구를 이용해 프로젝트 디렉터리 구조를 빠르게 생성할 수 있다.
본 글은 Modern JavaScript Deep-dive을 요약한 글입니다.
자세한 내용은 본 책을 읽으시기 바랍니다.

16.1 내부 슬롯과 내부 메서드

 프로퍼티 어트리뷰트를 이해하기 위해 먼저 내부 슬롯과 내부 메서드의 개념에 대해 알아보자. 내부 슬롯과 내부 메서드는 자바스크립트 엔진의 구현 알고리즘을 설명하기 위해 ECMAScript 사양에서 사용하는 의사 프로퍼티와 의사 메서드다. ECMAScript 사양에 등장하는 이중 대괄호로([[ ... ]]) 감싼 이름들이 내부 슬롯과 내부 메서드다.

 자바스크립트 엔진에서 실제로 동작하지만 개발자가 직접 접근할 수 있도록 외부에 공개된 객체의 프로퍼티는 아니다. 자바스크립트 엔진의 내부 로직 이므로 직접적으로 접근하거나 호출할 수 있는 방법은 제공하지 않는다. 단, 일부 내부 슬롯과 내부 메서드에 한하여 간접적으로 접근할 수 있는 수단을 제공하기는 한다.

16.2 프로퍼티 어트리뷰트와 프로퍼티 디스크립터 객체

 자바스크립트 엔진은 프로퍼티를 생성할 때 프로퍼티의 상태를 나타내는 프로퍼티 어트리뷰트를 기본값으로 자동 정의한다. 프로퍼티의 상태란 프로퍼티의 값, 값의 갱신 가능 여부, 열거 가능 여부, 재정의 가능 여부를 말한다. 프로퍼티 어트리뷰트에 직접 접근할 수 없지만 Object.getOwnPropertyDescriptor 메서드를 사용하여 간접적으로 확인 할 수 있다.

const person ={
	name : 'Lee',
};

conosole.log(Object.getOwnPropertyDescriptor(person, 'name'));
//{value :"Lee", writable : true, enumerable : true, configurable: true}

 Object.getOwnPropertyDescriptor 메서드를 호출할 때 첫 번째 매개변수에는 객체의 참조를 전달하고, 두 번째 매개변수에는 프로퍼티 키를 문자열로 전달한다. 이때 Object.getOwnPropertyDescriptor 메서드는 프로퍼티 어트리뷰트 정볼르 제공하는 프로퍼티 디스크립터 객체를 반환한다. ES8 이후로는 하나의 프로퍼티에 대해 디스크립터 객체를 반환하던 것이 모든 프로퍼티에 대해 반환하는 것으로 바뀌었다.

16.3 데이터 프로퍼티와 접근자 프로퍼티

16.3.1 데이터 프로퍼티

프로퍼티 어트리뷰트 프로퍼티 디스크립터 객체의 프로퍼티 설명
[[Value]] value 프로퍼티 키를 통해 프로퍼티 값에 접근하면 반환되는 값이다.
프로퍼티 키를 통해 프로퍼티 값을 변경하면 [[Value]]에 값을 재할당한다. 이때 프로퍼티 . 가없으면 프로퍼티를 동적생성하고 생성된 프로퍼티의 [[Value]]에 값을 저장한다.
[[Writable]] writable 프로퍼티 값의 변경 가능 여부를 나타내며 불리언 값을 갖는다.
[[Writable]]의 값이 false인 경우 해당 프로퍼티의 [[Value]]의 값을 변경할 수 없는 읽기 전용 프로퍼티가 된다.
[[Enumerable]] enumerable 프로퍼티의 열거 가능 여부를 나타내며 불리언 값을 갖는다.
[[Enumerable]]의 값이 false인 경우 해당 프로퍼티는 for ... in 문이나 Object.keys 메서드 등으로 열거할 수 없다.
[[Configurable]] configurable 프로퍼티의 재정의 가능 여부를 나타내며 불리언 값을 갖는다.
[[Configurable]]의 값이 false인 경우 해당 프로퍼티의 삭제, 프로퍼티 어트리뷰트 값의 변경이 금지된다.
단, [[Writable]] 이 true인 경우 [[Value]]의 변경과 [[Writable]]을 false로 변경하는 것은 허용된다.

16.3.2 접근자 프로퍼티

 접근자 프로퍼티는 자체적으로 값을 갖지 않고 다른 데이터 프로퍼티의 값을 읽거나 저장할 떄 사용하는 접근자 함수로 구성된 프로퍼티다.

프로퍼티 어트리뷰트 프로퍼티 디스크립터 객체의 프로퍼티 설명
[[Get]] get 접근자 프로퍼티를 통해 데이터 프로퍼티의 값을 읽을 떄 호출되는 접근자 함수다. 즉, 접근자 프로퍼티 키로 프로퍼티 값에 접근하면 프로퍼티 어트리뷰트[[Get]]의 값, 즉 getter 함수가 호출되고 그 결과가 프로퍼티 값으로 반환한다.
[[Set]] set 접근자 프로퍼티를 통해 데이터 프로퍼티의 값을 저장할 떄 호출되는 접근자 함수다. 즉, 접근자 프로퍼티 키로 프로퍼티 값을 저장하면 프로퍼티 어트리뷰트 [[Set]]의 값 즉, setter 함수가 호출되고 그 결과가 프로퍼티 값으로 저장된다.
[[Enumberable]] enumerable 데이터 프로퍼티의 [[Eumberable]]과 같다
[[Configurable]] configurable 데이터 프로퍼티의 [[Configurable]]과 같다

접근자 함수는 getter/setter 함수라고도 부른다.

const person = {
	// 데이터 프로퍼티
    firstName : 'Ungmo',
    lastName : 'Lee',


	// fullNmae은 접근자 함수로 구성된 접근자 프로퍼티다.
    // getter 함수
    get fullName(){
    	return `${this.firstName} ${this.lastName}`;
    },
    // setter 함수
    set fullName(name){
    	// 배열 디스트럭처링 할당
        [this.firstName, this.lastName] = name.split('');
    }
}

// 데이터 프로퍼티를 통한 프로퍼티 값의 참조
conosole.log(person.firstName + '' + perosin.lastName); // Ungmo Lee

// 접근자 프로퍼티를 통한 프로퍼티 값의 저장
// 접근자 프로퍼티 fullNmae에 값을 저장하면 setter 함수가 호출된다.
person.fullName = 'Heegun Lee';
console.log(person) // {firstNmae : "Heegun", lastName : "Lee"}

// 접근자 프로퍼티를 통한 프로퍼티 값의 참조
// 접근자 프로퍼티 fullName에 접근하면 getter 함수가 호출된다.
console.log(person.fullName); // Heegun Lee

// firstNmae은 데이터 프로퍼티다.
// 데이터 프로퍼티는 [[Value]], [[Writable]], [[Enumerable]], [[Configurable]]
// 프로퍼티 어트리뷰트를 갖는다.
let descriptor = Object.getOwnPropertyDescriptor(person, 'firstName');
// {value : 'Heegun', writable : true, enumerable : true, configurable : true}

// fullName은 접근자 프로퍼티다.
// 접근자 프로퍼티는 [[Get]], [[Set]], [[Enumerable]], [[Configurable]]
// 프로퍼티 어트리뷰트를 갖늗다.
descriptor = Object.getOwnPropertyDescriptor(person, 'fullName');
console.log(descriptor)
// {get:f, set: f, enumerable: true, configurable: true}

 접근자 프로퍼티는 자체적으로 값( 프로퍼티 어트리뷰트 [[Value]])을 가지지 않으며 다만 데이터 프로퍼티의 값을 읽거나 저장할 때 관여할 뿐이다. 접근자 프로퍼티와 데이터 프로퍼티의 프로퍼티 디스크립터 객체의 프로퍼티가 다른 것을 알 수 있다.

16.4 프로퍼티 정의

프로퍼티 정의란 새로운 프로퍼티를 추가하면서 프로퍼티 어트리뷰트를 명시적으로 정의하거나, 기존 프로퍼티의 프로퍼티 어트리뷰트를 재정의하는 것을 말한다. 예를 들어, 프로퍼티 값을 갱신 가능하도록 . 할것인지, 프로퍼티를 열거 가능하도록 할 것인지, 재정의 가능하도록 할 것인지 정의할 수 있다. 이를 통해 객체의 프로퍼티가 어떻게 동작해야 하는지 명확히 정의할 수 있다.

// 데이터 프로퍼티 정의
Object.defineProperty(person, 'firstName',{
	value : 'Ungmo',
    writavle : true,
    enumberable : true,
    configurable : true
})

// 접근자 프로퍼티 정의
Object.defineProperty(person, 'fullName', {
	// getter 함수
    get(){
    	return `${this.firstName} ${this.lastName}`;
    },
    // setter 함수
    set(){
    	[this.firstName, this.lastName] = name.split(' ');
    },
    enumerable: true,
    configurable: true
});

16.5  객체 변경 방지

 객체는 변경 가능한 값이므로 재할당 없이 직접 변경할 수 있다. 즉, 프로퍼티를 추가하거나 삭제할 수있고, 프로퍼티 값을 갱신할 수 있으며, Object.defineProperty 또는 Object.defineProperties 메서드를 사용하여 프로퍼티 어트리뷰트를 재정의 할 수도 있다.

 객체 변경 방지 메서드들은 객체의 변경을 금지하는 정도가 다르다.

구분 메서드 프로퍼티 추가 프로퍼티 삭제 프로퍼티 값 읽기 프로퍼티 값 쓰기 프로퍼티 어트리뷰트 재정의
객체 확장 금지 Object.preventExtensions X O O O O
객체 밀봉 Object.seal X X O O X
객체 동결 Object.freeza X X O X X

16.5.1 객체 확장 금지

 Object.preventExtensions 메서드는 객체의 확장을 금지한다. 확장이 금지된 객체는 프로퍼티 추가가 금지된다. Object.defineProperty 메서드로 추가할 수 있지만 두 가지 추가 방법이 모두 금지된다. 확장 가능한 객체 인지 여부는 Object.isExtensible 메서드로 확인할 수 있다.

16.5.2 객체 밀봉

객체 밀봉이란 프로퍼티 추가 및 삭제와 프로퍼티 어트리뷰트 재정의 금지를 의미한다. 즉 읽기와 쓰기만 가능하다. 밀봉된 객체인지 여부는 Object.isSealed 메서드로 확인할 수 있다.

16.5.3 객체 동결

Obejct.freeze 메서드는 객체를 동결한다. 객체 동결이란 프로퍼티 추가 및 삭제와 프로퍼티 어트리뷰트 재정의 금지, 프로퍼티 값 갱신 금지를 의미한다.  즉, 동결된 객체는 읽기만 가능하다. 동결된 객체인지 여부는 Object.isFrozen 메서드로 확인할 수 있다.

16.5.4 불변 객체 

지금까지 살펴본 변경 방지 메서드들은 얕은 변경 방지로 직속 프로퍼티만 변경이 방지되고 중첩객체까지는 영향을 주지는 못한다. 객체의 중첩 객체까지 동결하여 변경이 불가능한 읽기 전용의 불변 객체를 구현하려면 객체를 값으로 갖는 모든 프로퍼티에 대해 재귀적으로 Object.freeze 메서드를 호출해야 한다.

function deepFreeze(target){
	// 객체가 아니거나 동결된 객체는 무시하고 객체이고 동결되지 않은 객체만 동결한다.
    if(target && typeof target === 'object' && !Object.isFrozen(target)){
    Object.freeze(target);
    /*
    	모든 프로퍼티를 순회하며 재귀적으올 동결한다.
        Object.keys 메서드는 객체 자신의 열거 가능한 프로퍼티 키를 배열로 반환하다.
        forEach 메서드는 배열을 순회하며 배열 각 요소에 대하여 콜백 함수를 실행한다.
    */
    Object.key(target).forEach(key => deepFreeze(target[key]));
    }
    return target;
}

const person = {
	name: 'Lee',
    address: { city: 'Seoul'}
};

// 깊은 객체 동결
deepFreeze(person);
본 글은 Modern JavaScript Deep-dive을 요약한 글입니다.
자세한 내용은 본 책을 읽으시기 바랍니다.

15.1 var 키워드로 선언한 변수의 문제점

15.1.1 변수 중복 선언 허용

var 키워드로 선언한 변수는 중복 선언이 가능하다.

var x = 1;
var y = 1;

// var 키워드로 선언된 변수는 같은 스코프 내에서 중복 선언을 허용한다.
// 초기화문이 있는 변수 선언문은 자바스크립트 엔진에 의해 var 키워드가 없는 것처럼 동작한다.
var x = 100;
// 초기화문이 없는 변수 선언문은 무시된다.
var y;

console.log(x); // 100
console.log(y); // 1

 만약 위 예제와 같이 동일한 이름의 변수가 이미 선언되어 있는 것을 모르고 변수를 중복 선언하면서 값까지 할당했다면 의도치 않게 먼저 선언된 변수 값이 변경되는 부작용이 발생한다.

15.1.2 함수 레벨 스코프

var 키워드로 선언한 변수는 오로지 함수의 코드 블록만을 지역 스코프로 인정한다.

var x = 1;

if (true) {
	// x는 전역변수다. 이미 선언된 전역 변수 x가 있으므로 x 변수는 중복 선언된다.
    // 이는 의도치 않게 변수값이 변경되는 부작용을 발생시킨다.
    var x = 10;
}

conosole.log(x); // 10

for 문의 변수 선언문 에서 var 키워드로 선언한 변수도 전역 변수가 된다. 함수 레벨 스코프는 전역 변수를 남발할 가능성을 높인다.

15.1.3 변수 호이스팅

 var 키워드로 변수를 선언하면 변수 호이스팅에 의해 변수 선언문이 스코프의 선두로 끌어 올려진 것처럼 동작한다. 단, 할당문 이전에 변수를 참조하면 언제나 undefined를 반환한다. 변수 선언문 이전에 변수를 참조하는 것은 변수 호이스팅에 의해 에러를 발생시키지는 않지만 프로그래밍 흐름상 맞지 않을뿐더러 가독성을 떨어뜨리고 오류를 발생시킬 여지를 남긴다.

15.2  let 키워드

 var 키워드의 단점을 보완하기 위해 ES6에서는 새로운 변수 선언 키워드인 let과 const를 도입했다.

15.2.1 변수 호이스팅

 let 키워드로 이름이 같은 변수를 중복 선언하면 문법 에러가 발생한다.

let bar = 123;
// let이나 const 키워드로 선언된 변수는 같은 스코프 내에서 중복 선언을 허용하지 않는다.
let bar = 456; // SyntaxError: Identifier 'bar' has already been declared

15.2.2  블록 레벨 스코프

 let 키워드로 선언한 변수는 모든 코드 블록(함수, if, for, while, try/catch 등)을 지역 스코프로 인정하는 블록 레벨 스코프를 따른다.

let foo = 1; // 전역변수

{
	let foo = 2; // 지역 변수
    let bar = 3; // 지역 변수
}

console.log(foo); // 1
console.log(bar); // ReferenceError: bar is not defined

 함수도 코드 블록이므로 스코프를 만든다. 이때 함수 내의 코드 블록은 함수 레벨 스코프에 중첩된다.

15.2.3  변수 호이스팅

let 키워드로 선언한 변수는 변수 호이스팅이 발생하지 않는 것처럼 동작한다.

console.log(foo); // ReferenceError: foo is not defined
let foo;

 var 키워드로 선언한 변수는 런타임 이전에 자바스크립트에 의해 암묵적으로 "선언 단계"와 "초기화 단계"가 한번에 진행된다. 즉, 선언 단계에서 스코프(실행 컨텍스트의 렉시컬 환경)에 변수 식별자를 등록해 자바스크립트 엔진에 변수의 존재를 알린다. 그리고 최기화 단계에서 undefined로 변수를 초기화한다. 따라서 변수 선언문 이전에 변수에 접근해도 스코프에 변수가 존재하기 때문에 에럭 ㅏ발생하지 않는다. 다만 undefined를 반환한다. 이후 변수 할당문에 도달하면 비로소 값이 할당된다.

// var 키워드로 선언한 변수는 런타임 이전에 선언 단계와 초기화 단계가 실행된다.
// 따라서 변수 선언문 이전에 변수를 참조할 수 있다.

conosole.log(foo); // undefined

var foo;
conosole.log(foo); // undefined

foo = 1; // 할당문에서 할당 단계가 실행된다.
conosle.log(foo); // 1

 let 키워드로 선언한 변수는 "선언 단계"와 "초기화 단계"가 분리되어 진행된다. 즉, 런타임 이전에 자바스크립트 엔진에 의해 암묵적으로 선언 단계가 진행되지만 초기화 단계는 변수 언문에 도달했을 때 실행된다. let 키워드로 선언한 변수는 스코프의 시작 지점부터 초기화 단계 시작 지점(변수 선언문)까지 변수를 참조할 수 없다. 스코프의 시작 지점부터 초기화 시작 지점까지 변수를 참조할 수 없는 구간을 일시적 사각지대라고 부른다.

// 런타임 이전에 선언 단계가 실행된다. 아직 변수가 초기화되지 않았다.
// 초기화 이전의 일시적 사각지대에서는 변수를 참조할 수 없다.
console.log(foo); // ReferenceError: foo is not defined

let foo; // 변수 선언문에서 초기화 단계가 실행된다.
console.log(foo); // undefined

foo = 1 ; // 할당문에서 할당 단계가 실행된다.
console.log(foo); // 1

 결국 let 키워드로 선언한 변수는 변수 호이스팅이 발생하지 않는 것처럼 보인다. 하지만 그렇지 않다.

let foo = 1; // 전역 변수

{
	console.log(foo); //  ReferenceError: Cannot access 'foo' before initializtion
    let foo = 2; // 지역 변수
}

 let 키워드로 선언한 변수의 경우 변수 호이스팅이 발생하지 않는다면 위 예제는 전역 변수 foo의 값을 출력해야 한다. 하지만 let 키워드로 선언한 변수도 여전히 호이스팅이 발생하기 때문에 참조 에러가 발생한다.

 자바스크립트 ES6에서 도입된 let, const를 포함해서 모든 선언을 호이스팅한다. 단, ES6에서 도입된 let ,const, class를 사용한 선언문은 일시적 사각지대가 존재한다.

15.2.4  전역 객체와 let

 let 키워드로 선언한 전역 변수는 전역 객체의 프로퍼티가 아니다. let 전역 변수는 보이지 않는 개념적인 블록(전역 렉시컬 환경의 선언적 환경 레코드) 내에 존재하게 된다.

// 이 예제는 브라우저 환경에서 실행해야 한다.
let x = 1;

// let, const 키워드로 선언한 전역 변수는 전역 객체 window의 프로퍼트가 아니다.
console.log(window.x); // undefined
console.log(x); // x

15.3 const 키워드

 const 키워드는 상수를 선언하기 위해 사용한다. const 키워드의 특징은 let 키워드와 대부분 동일하므로 let 키워드와 다른 점을 중심으로 살펴보자.

15.3.1  선언과 초기화

 const 키워드로 선언한 변수는 반드시 선언과 동시에 초기화해야 한다.

const foo; // SyntaxError : Missing initializer in const declaration

 const 키워드로 선언한 변수는 블록 레벨 스코프를 가지며, 변수 호이스팅이 발생하지 않는 것처럼 동작한다.

15.3.2 재할당 금지

 var 또는 let 키워드로 선언한 변수는 재할당이 자유로우나 const 키워드로 선언한 변수는 재할당이 금지된다.

const foo = 1 ;
foo = 2; // TypeError: Assignment to constant variable.

15.3.3 상수

 const 키워드로 선언한 변수에 원시 값을 할당한 경우 변수 값을 변경할 수 없다. 원시 값은 변경 불가능한 값이므로 재할당 없이 값을 변경할 수 있는 방법이 없기 때문이다. 이러한 특징을 이용해 const 키워드는 상수를 표현하는데에 사용되기도 한다. 변수의 상대 개념인 상수는 재할당이 금지된 변수를 말한다.  상수는 상태 유지와 가독성, 유지보수의 편의를 위해 적극적으로 사용해야한다.

// 세전 가격
let preTaxPrice = 100;

// 세후 가격
// 0.1의 의미를 명확히 알기 어렵기 때문에 가독성이 좋지 않다.
let afterTaxPrice = preTaxPrice + (preTaxPrice * 0.1);

console.log(afterTaxPrice); // 110

 코드 내에서 사용한 0.1은 어떤 의미로 사용했는지 명확히 알기 어렵기 때문에 가독성이 좋지 않다. 또한 세율을 의미하는 0.1은 쉽게 바뀌지 않는 값이며, 프로그램 전체에서 고정된 값을 사용해야 한다. 이때 세율을 상수로 정의하면 값의 의미를 쉽게 파악할 수 있고 변경될 수 없는 고정값을 사용할 수 있다.

 const 키워드로 선언된 변수에 원시 값을 할당한 경우 원시 값은 변경할 수 없는 값이고 const 키워드에 의해 재할당이 금지되므로 할당된 값을 변경할 수 있는 방법은 없다. 또한 상수는 프로그램 전체에서 공통적으로 사용하므로 나중에 세율이 변경되면 상수만 변경하면 되기 때문에 유지보수성이 대폭 향상된다.

 일반적으로 상수의 이름은 대문자와 언더스코어로 구분하는 스네이크 케이스로 표현하는 것이 일반적인다.

// 세율을 의미하는 0.1은 변경할 수 없는 상수로서 사용될 값이다.
// 변수 이름을 대문자로 선언해 상수임을 명확히 나타낸다.
const TAX_RATE = 0.1;

// 세전 가격
let preTaxPrice = 100;

// 세후 가격
let afterTaxPrice = preTaxPrice + (preTaxPrice * TAX_RATE);

console.log(afterTaxPrice);// 110

15.3.4 const 키워드와 객체

const 키워드로 선언된 변수에 원시 값을 할당한 경우 값을 변경할 수 없다. 하지만 const 키워드로 선언된 변수에 객체를 할당한 경우 값을 변경할 수 있다. 변경 불가능한 값인 원시 값은 재할당 없이 변경할 수 있는 방법이 없지만 변경 가능한 값인 객체는 재할당 없이도 직접 변경이 가능하기 때문이다.

const person = {
	name : 'Lee'
};

// 객체는 변경 가능한 값이다. 따라서 재할당 없이 변경이 가능하다.
person.name = 'Kim';

console.log(person); // {name : 'Kim'}

 const 키워드는 재할당을 금지할 뿐 "불변"을 의미하지 않는다. 다시말해, 새로운 값을 재할당하는 것은 불가능하지만 프로퍼티 동적 생성, 삭제, 프로퍼티 값의 변경을 통해 객체를 변경하는 것은 가능하다. 이때 객체가 변경되더라도 변수에 할당된 참조 값은 변경되지 않는다.

15.4 var vs. let vs. const

 변수 선언에는 기본적으로 const를 사용하고 let은 재할당이 필요한 경우에 한정해 사용하는 것이 좋다. const 키워드를 사용하면 의도치 않은 재할당을 방지하기 때문에 좀 더 안전하다.

  • ES6를 사용한다면 var 키워드는 사용하지 않는다.
  • 재할당이 필요한 경우에 한정해 let 키워드를 사용한다. 이때 변수의 스코프는 최대한 좁게 만든다.
  • 변경이 발생하지 않고 읽기 전용으로 사용하는 (재할당이 필요 없는 상수) 원시 값과 객체에는 const 키워드를 사용한다.

 변수를 선언하는 시점에는 재할당이 필요한지 잘 모르는 경우가 많다. 그리고 객체는 의외로 재할당하는 경우가 드물다. 따라서 변수를 선언할 때는 일단 const 키워드를 사용하자. 반드시 재할당이 필요하다면 그때 const 키워드를 let 키워드로 변경해도 결코 늦지 않다.

'Javascript' 카테고리의 다른 글

Deep-dive .18 : 함수와 일급 객체  (0) 2024.06.11
Deep-dive .16 : 프로퍼티 어트리뷰트  (1) 2024.04.26
Deep-dive .14 : 전역 변수의 문제점  (0) 2024.04.18
Deep-dive .13 : 스코프  (0) 2024.04.18
Deep-dive .12 : 함수  (0) 2024.04.15

+ Recent posts