1. 타입스크립트와 자바스크립트의 관계 이해하기
타입스크립트는 자바스크립트의 상위집합이다.
.js 파일에 있는 코드는 이미 타입스크립트라고 할 수 있다. .ts로 바꾼다고 해도 달라지는 것은 없다.
이는 기존의 자바스크립트 코드를 타입스크립트로 마이그레이션하는 데 엄청난 이점이 된다.
다시 말해, 모든 자바스크립트 프로그램은 이미 타입스크립트 프로그램이다.
반대로, 타입스크립트는 별도의 문법을 가지고 있기 때문에 일반적으로는 유효한 자바스크립트 프로그램이 아니다.
예를 들어, 다음 코드는 유효한 타입스크립트 프로그램이지만 단순 node로 실행할 경우 오류를 출력한다.
function greet(who: string) {} // SyntaxError: Unexpected token :
타입스크립트 컴파일러는 일반 자바스크립트 프로그램에도 유용하다.
let city = 'new yokr city';
console.log(city.toUppercase());
위 코드를 일반 node로 실행하면 다음과 같은 단순한 오류가 출력될 것이다.
TypeError: city.toUppercase is not a function
반면 타입스크립트의 타입 체커는 문제점을 찾아낸다.
let city = 'new yokr city';
console.log(city.toUppercase());
// 'toUppercase' 속성이 'string' 형식에 없습니다.
// 'toUpperCase'을(를) 사용하시겠습니까?
city 변수가 문자열이라는 것을 알려 주지 않아도 타입스크립트는 초깃값으로부터 타입을 추론한다.
이러한 타입 추론은 타입스크립트에서 중요한 부분이다.
타입스크립트는 자바스크립트 런타임 동작은 모델링하는 타입 시스템을 가지고 있기 때문에,
런타임 오류를 발생시키는 코드를 찾아내려고 한다. (타입스크립트는 '정적' 타입 시스템)
그러나 타입 체커가 모든 오류를 찾아내지는 않는다.
const states = [{name: 'Alabama', capitol: 'Montgomery'}];
for (const state of states) {
console.log(state.capital);
// 'capital' 속성이 ... 형식에 없습니다.
//'capitol'을(를) 사용하시겠습니까?
}
타입스크립트가 제시한 해결책은 잘못되었다. capital과 capitol중 어느 쪽이 오타인지 판단하지 못했다.
따라서 명시적으로 states선언하여 의도를 분명하게 하는 것이 좋다.
interface State {
name: string;
capital: string;
}
const states = [{name: 'Alabama', capitol: 'Montgomery'}];
// 'State' 형식에 'capitol'이(가) 없습니다. 'capital'을(를) 쓰려고 했습니까?
for (const state of states) {
console.log(state.capital);
}
즉, 타입스크립트 프로그램 내에는 자바스크립트 프로그램과 타입체커를 통과한 자바스크립트 프로그램의 두 영역이 있는 것이다.
타입스크립트 타입 시스템은 전반적으로 자바스크립트 동작을 모델링한다.
자바스크립트에서는 허용되지만 타입스크립트에서는 문제가 되는 경우도 있다.
이러한 문법의 엄격함은 온전히 취향의 차이이며 우열을 가릴 수 없는 문제이다.
다만 타입스크립트의 도움을 받으면 오류가 적은 코드를 작성할 수 있다.
2. 타입스크립트 설정 이해하기
타입스크립트 설정은 커맨드 라인을 이용하기보다는 tsconfig.json을 사용하는 것이 좋다.
설정 파일은 tsc --init만 실행하면 간단히 생성된다.
타입스크립트의 설정들은 어디서 소스 파일을 찾을지, 어떤 종류의 출력을 생성할지 제어하는 내용이 대부분이다.
그런데 언어의 핵심 요소에 영향을 미치는 설정도 있다. (noImplicitAny, strictNullChecks, noImplicitThis, strictFunctionTypes 등)
noImplicitAny는 변수들이 미리 정의된 타입을 가져야 하는지 여부를 제어한다.
function add(a, b) {
return a + b;
}
위 코드는 noImplicitAny가 해제되어 있을 때는 유효하다.
이 때 a와 b는 any로 추론되는데, any 타입을 매개변수에 사용하면 타입 체커는 속절없이 무력해진다.
이러한 암시적 any는 noImplicitAny 설정시 오류가 된다. 명시적으로 타입을 선언해주면 해결할 수 있다.
타입스크립트는 타입 정보를 가질 때 가장 효과적이기 때문에, 되도록이면 noImplicitAny를 설정해야 한다.
strictNullChecks는 null과 undefined가 모든 타입에서 허용되는지 확인하는 설정이다.
const x: number = null; //'null'형식은 'number' 형식에 할당할 수 없습니다.
위 코드는 strictNullChecks가 해제되어 있을 때는 유효하다.
null을 허용하려고 한다면, 의도를 명시적으로 드러냄으로써 오류를 고칠 수 있다.
만약 null을 허용하지 않으려면, if문 등으로 null을 체크하는 코드나 assertion을 추가해야 한다.
const x: number | null = null;
"undefined is not an object"와 같은 런타임 오류를 방지하기 위해서는 strictNullChecks를 설정하는 것이 좋다.
3. 코드 생성과 타입이 관계없음을 이해하기
큰 그림에서, 타입스크립트 컴파일러는 두 가지 역할을 수행한다.
- 최신 TS/JS를 브라우저에서 동작할 수 있도록 구버전의 JS로 트랜스파일
- 코드의 타입 오류를 체크
이 두가지는 서로 완벽히 독립적이다.
코드 생성은 타입 시스템과 무관하다. 타입스크립트 타입은 런타임 동작이나 성능에 영향을 주지 않는다.
따라서 타입 오류가 존재하더라도 코드 생성(컴파일)은 가능하다.
코드에 오류가 있을 때 "컴파일에 문제가 있다"라고 말하곤 하는데,
엄밀히 말하자면 타입스크립트가 유효한 자바스크립트라면 컴파일이 되기 때문에
코드에 오류가 있을 때는 "타입 체크에 문제가 있다"고 말하는 것이 더 정확한 표현이다.
만약 오류가 있을 때 컴파일하지 않으려면 noEmitOnError을 설정에 추가하거나 빌드 도구에 동일하게 적용하면 된다.
타입스크립트 타입은 런타임에 사용할 수 없다.
interface Square {
width: number;
}
interface Rectangle extends Square {
height: number;
}
type Shape = Square | Rectangle;
function calculateArea(shape: Shape) {
if (shape instanceof Rectangle) {
// 'Rectangle'은(는) 형식만 참조하지만,
// 여기서는 값으로 사용되고 있습니다.
return shape.width * shape.height;
// 'Shape' 형식에 'height' thrtjddl djqttmqslek.
}
}
instanceof 체크는 런타임에 일어나지만, Rectangle은 타입이기 때문에 런타임 시점에 아무런 역할을 할 수 없다.
컴파일 과정에서 모든 인터페이스, 타입, 타입 구문은 제거되어 버린다.
런타임에 타입을 지정하려면 타입 정보 유지를 위한 별도의 방법이 필요하다.
1. 속성이 존재하는지 체크하기
function calculateArea(shape: Shape) {
shape; // shape: Shape
if ("height" in shape) {
shape; // shape: Rectangle
return shape.width * shape.height;
} else { // shape: Square
shape;
}
}
속성 체크는 런타임에 접근 가능한 값에만 포함되지만, 타입 체커 역시도 타입을 보정해 주기 때문에 오류가 사라진다.
2. tagged union 기법 사용하기
interface Square {
kind: "square";
width: number;
}
interface Rectangle {
kind: "rectangle";
width: number;
height: number;
}
type Shape = Square | Rectangle;
function calculateArea(shape: Shape) {
if (shape.kind === "rectangle") {
shape; // shape: Rectangle
return shape.width * shape.height;
} else {
shape; // shape: Square
}
}
여기서 Shape 타입은 tagged union의 한 예이다.
3. 타입을 클래스로 만들기
타입(런타임 접근 불가)과 값(런타임 접근 가능)을 둘 다 사용할 수도 있다.
class Square {
constructor(public width: number) {}
}
class Rectangle extends Square {
constructor(public width: number, public height: number) {
super(width);
}
}
type Shape = Square | Rectangle;
function calculateArea(shape: Shape) {
if (shape instanceof Rectangle) {
shape; // shape: Rectangle
return shape.width * shape.height;
} else {
shape; // shape: Square
}
}
type Shape = Square | Rectangle 부분에서 Rectangle은 타입으로 참조되지만,
shape instanceof Rectangle 부분에서는 값으로 참조된다.
타입 연산은 런타임에 영향을 주지 않는다. string | number 타입을 항상 number로 정제하는 경우를 가정해 보자.
function asNumber(val: number | string): number {
return val as number;
}
위 코드는 잘못된 방법이다. 컴파일 시 타입 구문은 모두 제거된다.
as number는 타입 연산이고 런타임 동작에 아무런 영향을 미치지 않는다.
값을 정제하기 위해서는 런타임의 타입을 체크해야 하고 자바스크립트 연산을 통해 변환을 수행해야 한다.
function asNumber(val: number | string): number {
return typeof val === "string" ? Number(val) : val;
}
런타입 타입은 선언된 타입과 다를 수 있다.
function setLightSwitch(value: boolean) {
switch (value) {
case true:
turnLightOn();
break;
case false:
turnLightOff();
default:
console.log("default");
break;
}
}
위의 코드에서 default가 출력될 수 있을까? ": boolean"은 타입 선언문이다. 이는 런타임에 제거되며,
만일 네트워크 호출로 받아온 값으로 해당 함수를 실행할 경우 boolean이 아닌 string이 올 수도 있다.
선언된 타입이 언제든지 달라질 수 있다는 것을 명심해야 한다.
타입스크립트 타입으로는 런타임의 동작이 무관하기 때문에 함수를 오버로드할 수 없다.
(동일한 이름에 매개변수만 다른 여러 버전의 함수를 허용하는 것)
타입스크립트 타입은 런타임 성능에 영향을 주지 않는다. (정적 타이핑은 비용이 들지 않음)
하지만 '런타임' 오버헤드가 없는 대신, '빌드타임' 오버헤드가 있다.
어떤 경우든지 호환성과 성능 사이의 선택은 컴파일 타깃과 언어 레벨의 문제이며 타입과는 무관하다.
4. 구조적 타이핑에 익숙해지기
자바스크립트는 본질적으로 덕 타이핑 기반이다.
(객체가 어떤 타입에 부합하는 변수와 메서드를 가질 경우 객체를 해당 타입에 속하는 것으로 간주하는 방식)
자바스크립트가 덕 타이핑 기반이고 타입스크립트가 이를 모델링하기 위해 구조적 타이핑을 사용함을 이해해야 한다.
interface Vector2D {
x: number;
y: number;
}
function calculateLength(v: Vector2D) {
return Math.sqrt(v.x * v.x + v.y * v.y);
}
interface NamedVector {
name: string;
x: number;
y: number;
}
const v: NamedVector = { x: 3, y: 4, name: "Zee" };
calculateLength(v);
NamedVector는 number 타입의 x와 y 속성이 있기 때문에 calculateLength 함수로 호출 가능하다.
흥미로운 점은 Vector2D와 NamedVector의 관계를 전혀 선언하지 않았다는 것이다.
타입스크립트 타입 시스템은 런타임 동작을 모델링한다. NamedVector의 구조가 Vector2D와 호환되기 때문에 calculateLength 호출이 가능하다.
여기서 structural typing이라는 용어가 사용된다.
구조적 타이핑 때문에 문제가 발생할 수도 있다.
interface Vector3D {
x: number;
y: number;
z: number;
}
function normalize(v: Vector3D) {
const length = calculateLength(v);
return {
x: v.x / length,
y: v.y / length,
z: v.z / length,
};
}
normalize({ x: 3, y: 4, z: 5 });
// {x: 0.6, y:0.8, z:1}
벡터의 길이를 1로 만드는 정규화를 진행하고자 했지만, calculateLength는 2D 벡터를 기반으로 연산하기 때문에 3D 벡터로 정규화가 진행되어 z가 정규화에서 무시되었다.
그런데 타입 체커는 이 문제를 잡아내지 못했다.
Vector3D와 호환되는 {x, y, z} 객체로 calculateLength를 호출하면, 구조적 타이핑 관점에서 x와 y가 있어서 Vector2D와 호환된다. (이를 오류로 처리하기 위한 설정이 따로 존재함)
함수를 작성할 때, 호출에 사용되는 매개변수의 속성들이 매개변수의 타입에 선언된 속성만을 가질 거라 생각하기 쉽다.
이를 sealed(봉인된) 또는 precise(정확한) 타입이라 하며 타입스크립트 타입 시스템에서는 표현할 수 없다.
타입스크립트에서 타입은 '봉인'되어 있지 않고 '열려'있다.
interface Vector3D {
x: number;
y: number;
z: number;
}
function calculateLength(v: Vector3D) {
let length = 0;
for (const axis of Object.keys(v)) {
const coord = v[axis];
// 'string'은 'Vector3D'의 인덱스로 사용할 수 없기에
// 엘리먼트는 암시적으로 'any' 타입입니다.
length += Math.abs(coord);
}
return length;
}
오류를 납득하기 어렵다. coord의 타입은 number이 되어야 할 것처럼 보인다.
하지만 실제로 타입은 열려있기 때문에 v는 어떤 속성이든 가질 수 있고, 따라서 타입스크립트는 v[axis]가 어떤 속성이 될지 알 수 없기 때문에 number라고 확정할 수 없다.
클래스 역시 구조적 타이핑 규칙을 따른다는 것을 명심해야 한다. 클래스의 인스턴스가 예상과 다를 수 있다.
class C {
foo: string;
constructor(foo: string) {
this.foo = foo + "foo";
}
}
const c = new C("instance of C");
const d: C = { foo: "object literal" };
console.log(c.foo); // instance of Cfoo
console.log(d.foo); // object literal
d가 C 타입에 할당되는 이유를 알아보자.
d는 string 타입의 foo 속성을 가지며, Object.prorotype으로부터 비롯된 생성자를 가진다.
따라서 구조적으로는 필요한 속성과 생성자가 존재하기 때문에 문제가 없다.
그러나 C의 생성자에 단순 할당이 아닌 연산 로직이 존재하기 때문에, d의 경우 생성자를 실행하지 않으므로 문제가 발생하게 된다.
구조적 타이핑을 사용하면 유닛 테스팅을 손쉽게 할 수 있다. DB에 쿼리하고 결과를 처리하는 함수를 보자.
interface Author {
first: string;
last: string;
}
function getAuthors(database: PostgresDB): Author[] {
const authorRows = database.runQuery("SELECT FIRST, LAST FROM AUTHORS");
return authorRows.map((row) => ({ first: row[0], last: row[1] }));
}
getAuthors 함수를 테스트하기 위해서는 모킹한 PostgresDB를 생성해야 한다.
그러나 구조적 타이핑을 활용하여 더 구체적인 인터페이스를 정의할 수 있다.
interface Author {
first: string;
last: string;
}
interface DB {
runQuery: (sql: string) => any[];
}
function getAuthors(database: DB): Author[] {
const authorRows = database.runQuery("SELECT FIRST, LAST FROM AUTHORS");
return authorRows.map((row) => ({ first: row[0], last: row[1] }));
}
runQuery 메서드가 있기 때문에 실제 환경에서도 getAuthors에 PostgresDB를 사용할 수 있다.
구조적 타이핑 덕분에, PostgresDB가 DB 인터페이스를 구현하는지 명확히 선언할 필요가 없다.
추상화(DB)를 함으로써 로직과 테스트를 특정한 구현(PostgresDB)으로부터 분리한 것이다.
5. any 타입 지양하기
타입스크립트의 타입 시스템은 코드에 타입을 조금씩 추가할 수 있기 때문에 점진적(gradual)이며, 언제든지 타입 체커를 해제할 수 있기 때문에 선택적(optional)이다.
이 기능들의 핵심은 any 타입이다. 그러나 일부 특별한 경우를 제외하고는 any를 사용하면 타입스크립트의 수많은 장점을 누릴 수 없게 된다.
any 타입에는 타입 안정성이 없다.
let age: number;
age = "12" as any;
age += 1; // "121"
any는 함수 시그니처(contract)를 무시해 버린다.
function calculateAge(birhtDate: Date): number {}
let birhtDate: any = "1999-11-08";
calculateAge(birhtDate);
birthDate 매개변수는 string이 아닌 Date 타입이어야 한다. any 타입을 사용하면 calculateAge의 시그니처를 무시하게 된다.
any 타입에는 언어 서비스가 적용되지 않는다.
타입스크립트 언어 서비스는 자동완성 기능과 적절한 도움말을 제공한다. 그러나 any 타입인 심벌을 사용하면 아무런 도움을 받지 못한다.
any 타입은 코드 리펙터링 때 버그를 감춘다.
interface ComponentProps {
onSelectItem: (item: any) => void;
}
function renderSelector(props: ComponentProps) {}
let selectedId: number = 0;
function handleSelectItem(item: any) {
selectedId = item.id;
}
renderSelector({ onSelectItem: handleSelectItem });
onSelectItem에 id만을 전달하도록 ComponentProps의 시그니처를 변경한다고 해보자.
interface ComponentProps {
onSelectItem: (id: number) => void;
}
타입 체크는 통과하지만 문제가 있다. handleSelectItem은 any 매개변수를 받는다. 따라서 id를 전달받아도 문제가 없다고 나온다.
id를 전달받으면 런타임에는 오류가 발생할 것이다.
any는 타입 설계를 감춰버린다.
애플리케이션 상태 같은 객체를 정의할 때 수많은 속성의 타입을 일일이 작성하는 것은 번거롭다.
하지만 any를 사용하면 안 된다. 상태 객체의 설계를 감춰버리면 안 된다.
깔끔하고 정확하고 명료한 코드 작성을 위해 제대로 된 타입 설계는 필수이다.
설계가 명확히 보이도록 타입을 일일이 작성하는 것이 좋다.
any는 타입시스템의 신뢰도를 떨어뜨린다.
any 타입을 쓰지 않아야 런타임에 발견될 오류를 미리 잡을 수 있고 신뢰도를 높을 수 있다.
any 타입으로 인해 오히려 자바스크립트보다 일을 더 어렵게 만들수도 있다.
타입스크립트가 타입 정보를 기억할 수 있도록 타입을 구체적으로 명시해주어야 한다.
any 타입을 사용하면 타입 체커와 타입스크립트 언어 서비스를 무력화시켜 버린다.
any 타입은 진짜 문제점을 감추며, 개발 경험을 나쁘게 하고, 타입 시스템의 신뢰도를 떨어뜨린다. 최대한 사용을 피해야 한다.