1장에서는 타입스크립트의 큰 그림을 이해하는 데 도움이 될 내용을 다룬다.
타입스크립트는 사용 방식 면에서 조금 독특한 언어이다.
타입스크립트의 타입 시스템도 조금 독특한 특징을 가지고 있다.
타입스크립트는 자바스크립트의 상위집합니다. 모든 자바스크립트 프로그램은 이미 타입스크립트 프로그램이다. 반대로, 타입스크립트는 별도의 문법을 가지고 있기 떄문에 일반적으로는 유효한 자바스크립트 프로그램이 아니다.
타입스크립트는 자바스크립트 런타임 동작을 모델링하는 타입 시스템을 가지고 있기 떄문에 런타임 오류를 발생시키는 코드를 찾아내려한다. 그러나 모든 오류를 찾아낼 수 없고, 타입 체커를 통과해도 런타임 오류를 발생시키는 코드가 있다.
타입스크립트 타입 시스템은 전반적으로 자바스크립트 동작을 모델링한다. 그러나 잘못된 매개변수 개수로 함수를 호출하는 경우처럼, 자바스크립트에서는 허용되지만 타입스크립트에서는 문제가 되는 경우도 있다. 이러한 문법의 엄격함은 취향차이이다.
"타입스크립트는 자바스크립트의 상위 집합(superset) 이다.
타입 스크립트는 문법적으로 자바스크립트의 상위집합이다.
모든 자바스크립트 프로그램이 타입스크립트라는 명제는 참이지만, 그 반대는 성립하지 않는다.
function greet(who: string){
console.log("Hello", who);
}
타입 스크립트 컴파일러는 타입스크립트뿐만 아니라 일반 자바스크립트 프로그램에도 유용하다.
let city = 'new york city';
console.log(city.toUppercase());
이 코드를 실행하면 다음과 같은 오류가 발생한다.
TypeError: city.toUppercase is not a funcion
위 코드에는 타입 구문이 없지만, 타입 스크립트의 타입 체커는 문제점을 찾아 낸다.
let city = 'new york city';
console.log(city.toUppercase());
// 'toUppercase' 속성이 'string' 형식에 없습니다.
// 'toUppserCase'을(를) 사용하시겠습니까?
city 변수가 문자열 이라는 것을 알려 주지 않아도 타입스크립트는 초깃값으로 부터 타입을 추론한다.
타입 시스템의 목표 중 하나는 런타임에 오류를 발생시킬 코드를 미리 찾아 내는것이다.
const states = [
{name: 'Alabama', capital: 'Montgomery'},
{name: 'Alaska', capital: 'Juneau'},
{name: 'Arizona', capital: 'Phonenix'},
];
for (const state of states){
console.log(state.capitol);
}
실행한 결과는 다음과 같다.
undefined
undefined
undefined
앞의 코드는 유효한 자바스크립트(또한 타입스크립트) 이며 어떠한 오류도 없이 실행된다. 그러나 루프 내의 state.capitol은 의도한 코드가 아니다. 이런 경우에 타입스크립트 타입 체커는 추가적인 타입 구문 없이도 오류를 찾아낸다.
for (const state of states){
console.log(state.capitol);
// 'capitol' 속성이 ... 형식에 없습니다.
// 'capital' 을(를) 사용하시겠습니까?
}
p5 예제 참고
타입스크립트는 타입 구문 없이도 오류를 잡을 수 있지만, 타입 구문을 추가하면 더 많은 오류를 찾아낼 수 있다.
코드의 '의도' 가 무엇인지 타입 구문을 통해 타입스크립트에게 알려줄 수 있기 때문에 코드의 동작과 의도가 다른 부분을 찾을 수 있다.
=> 명시적으로 states를 선언하여 의도를 분명하게 하는 것이 좋다.
interface State {
name: string;
capital:string;
}
const states: State[] = [
{name: 'Alabama', capitol: 'Montgomery'},
{name: 'Alaska', capitol: 'Juneau'},
{name: 'Arizona', capitol: 'Phonenix'},
];
// 객체 리터럴은 알려진 속성만 지정할 수 있지만 'State'형식에 'capitol' 이(가) 없습니다.
// 'capital'을(를) 쓰려고 했습니까?
for (const state of states){
console.log(state.capitol);
}
=> 의도를 명확히 해서 타입스크립트가 잠재적 문제점을 찾을 수 있게 했다.
const x = 2 + '3'; // 정상, string 타입 입니다.
const y = '2' + 3; // 정상, string 타입 입니다.
작성된 프로그램이 타입 체크를 통과하더라도 여전히 런타임에 오류가 발생할 수 있다.
const names = ['Alice', 'Bob'];
console.log(names[2].toUpperCase());
프로그램을 실행하면 다음과 같은 오류가 발생한다.
TypeError: Cannot read property 'toUpperCase' of undefined
타입스크립트는 앞의 배열이 범위 내에서 사용될 것이라고 가정했지만 실제로는 그렇지 않았고, 오류가 발생했다.
앞서 등장한 오류들이 발생하는 근본 원인은 타입스크립트가 이해하는 값의 타입과 실제 값에 차이가 있기 때문이다. 타입 시스템이 정적 타입의 정확성을 보장해 줄 것 같지만 그렇지 않다.
암시적(implicit) 이라는 단어를 사용한다. 암묵적으로 합의된 정도의 의미로 생각하면 된다.
변수들이 미리 정의된 타입을 가져야하는지 여부를 제어한다.
다음 코드는 noImplicityAny가 해제 되어있을 때에는 유효하다.
function add(a, b){
return a + b;
}
편집기에서 add 부분에 마우스를 올려보면, 타입스크립트가 추론한 함수의 타입을 알 수 있다.
function add(a: any, b:any) :any
any를 코드에 넣지 않았지만, any 타입으로 간주되기 때문에 이를 '암시적 any'라고 부른다.
그런데 같은 코드임에도 noImplicitAny가 설정되었다면 오류가 된다. 이 오류들은 명시적으로 :any라고 선언해주거나 더 분명한 타입을 사용하면 해결할 수 있다.
function add(a:number, b:number){
return a + b;
}
const x: number = null // 정상, null은 유효한 코드이다.
그러나 strictNullChecks를 설정하면 오류가 된다.
const x: number = null // 정상, null은 유효한 코드이다.
// ~ 'null' 형식은 'number' 형식에 할당할 수 없습니다.
null 대신 undefined를 써도 같은 오류가 난다. 만약 null을 허용하려고 한다면, 의도를 명시적으로 드러냄으로써 오류를 고칠 수 있다.
const x:number | null = null;
큰 그림에서 보면, 타입스크립트 컴파일러는 두 가지 역할을 수행한다.
1. 최신 타입스크립트/자바스크립트를 브라우저에서 동작할 수 있도록 구버전의 자바스크립트로 트랜스파일(transpile) 한다.
2. 코드의 타입 오류를 체크한다.
두 가지는 서로 완벽히 독립적이다
=> 타입스크립트가 자바스크립트로 변환될 때 코드 내의 타입에는 영향을 주지 않는다
=> 자바스크립트의 실행 시점에도 타입은 영향을 미치지 않는다.
타입스크립트가 할 수 있는 일과 할수 없는 일을 짐작할 수 있다.
컴파일은 타입 체크와 독립적으로 동작하기 때문에, 타입 오류가 있는 코드도 컴파일이 가능하다.
타입스크립트 오류는 C나 자바 같은 언어들의 경고(warnig)와 비슷하다. 문제가 될 만한 부분을 알려주지만, 그렇다고 빌드를 멈추진 않는다.
만약 오류가 있을 때 컴파일하지 않으려면, tsconfig.json에 noEmitOnError를 설정하거나 빌드 도구에 동일하게 적용하면 된다.
shape 타입을 명확하게 하려면, 런타임에 타입 정보를 유지하는 방법이 필요하다.
string 또는 number 타입인 값을 항상 number로 정제하는 경우를 가정해보자. 다음 코드는 타입 체커를 통과하지만 잘못된 방법을 썼다.
function asNumber(val:number | string): number {
return val as number;
}
변환된 자바스크립트 코드를 보면 이 함수가 실제로 어떻게 동작하는지 알 수 있다.
function asNumber(val){
return val;
}
코드에 아무런 정제 과정이 없다. as number는 타입 연산이고 런타임 동작에는 아무런 영향을 미치지 않는다. 값을 정제하기 위해서는 런타임의 타입을 체크해야하고 자바스크립트 연산을 통해 변환을 수행해야한다.
function asNumber(val:number|string):number {
return typeof(val) === 'string' ? Number(val) : val;
}
다음 함수를 보고 마지막의 console.log까지 실행될 수 있을지 생각해보자
function setLightSwitch(value: boolean){
switch(value){
case true:
turnLightOn();
break;
case false;
turnLightOff();
break;
default:
console.log('실행되지 않을까 봐 걱정');
}
}
타입스크립트는 일반적으로 실행되지 못하는 죽은(dead) 코드를 찾아내지만, 여기서는 strict를 설정하더라도 찾아내지 못한다.
:boolean은 런타임에 제거된다.
api response로 value를 받을때 얼마든지.. 타입이 바뀔 수 있다.
타입스크립트에서는 런타임 타입과 선언된 타입이 맞지 않을 수 있다.
C++ 같은 언어는 동일한 이름에 매개변수만 다른 여러 버전의 함수를 허용한다. 이를 '함수 오버로딩'이라고한다. 그러나 타입스크립트에서는 타입과 런타임의 동작이 무관하기 때문에, 함수 오버로딩을 불가능하다.
타입스크립트가 함수 오버로딩 기능을 지원하지만, 온전히 타입수준에서만 동작한다. 하나의 함수에 대해 여러 개의 선언문을 작성할 수 있지만, 구현체 는 오직 하나다.
타입과 타입 연산자는 자바스크립트 변환 시점에 제거되기 때문에, 런타임의 성능에 아무런영향을 주지 않는다.
타입스크립트의 정적 타입은 실제로 비용을 들지 않는다.
'런타임' 오버헤드가 없는 대신, 타입스크립트 컴파일러는 '빌드타임' 오버헤드가 있다.
컴파일은 일반적으로 상당히 빠른 편이며 특히 증분 빌드 시에 더욱 체감된다.
오버헤드가 커지만, 빌드 도구에서 '트랜스파일만(transpile only)' 을 설정하여 타입 체크를 건너뛸 수 있다.
타입스크립트가 컴파일하는 코드는 오래된 런타임 환경을 지원하기 위해 호환성을 높이고 성능 오버헤드를 감안할지, 호환성을 포기하고 성능중심의 네이티브 구현체를 선택할지의 문제에 맞닥뜨릴 수 있다. 하지만 어떤 경우든지 호환성과 성능 사이의 선택은 컴파일 타깃과 언어 레벨의 문제이며 여전히 타입과는 무관하다.
자바스크립트는 본질적으로 덕 타이핑(duck typing) 기반이다.
덕 타이핑이란, 객체가 어떤 타입에 부합하는 변수와 메서드를 가질 경우 객체를 해당 타입에 속하는 것으로 간주하는 방식이다. 덕 테스트에서 유래되었는데, 다음과 같은 명제로 정의된다. 만약 어떤 새가 오리처럼 걷고, 헤엄치고, 꽥꽥 거리는 소리를 낸다면 나는 그 새를 오리라고 부를 것이다.
자바스크립트가 덕 타이핑(duck typing) 기반이고 타입스크립트가 이를 모델링하기 위해 구조적 타이핑을 사용함을 이해해야한다. 어떤 인터페이스에 할당 가능한 값이라면 타입 선언에 명시적으로 나열된 속성들을 가지고 있다. 타입은 '봉인' 되어있지 않다.
클래스 역시 구조적 타이핑 규칙을 따른다. 클래스의 인스턴스가 예상과 다를 수 있다.
구조적 타이핑을 사용하면 유닛 테스팅을 손쉽게 할 수 있다.
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;
}
NamedVector는 number 타입의 x와 y 속성이 있기 때문에 calculateLength 함수로 호출가능하다.
const v: NamedVector = {x: 3, y: 4, name: 'Zee'};
calculateLength(v); // 정상, 결과는 5
구조적 타이핑 때문에 문제가 발생하기도 한다. 3D 벡터를 만들어보자.
interface Vector3D {
x: number;
y: number;
z: number;
}
벡터의 길이를 1로 만드는 정규화 함수를 작성한다.
function normalize(v: Vector3D){
const length = calculateLength(v);
return {
x: v.x / length,
y: v.y / length,
z: v.z / length,
}
}
그러나 이 함수는 1보다 조금 더 긴 (1.41) 길이를 가진 결과를 출력한다.
normalize({x:3, y:4, z:5})
{x: 0.6, y: 0.8, z:1}
calculateLength는 2D 벡터를 기반으로 연산하는데, 버그로 인해 normalize가 3D 벡터로 연산되었다. z가 정규화에서 무시된 것이다. 그런데 타입 체커가 이 문제를 잡아내지 못했다.
calculateLength가 2D벡터를 받도록 선언되었음에도 불구하고 3D 벡터를 받는 데 문제가 없었던 이유는 무엇일까?
Vector3D와 호환되는 {x,y,z} 객체로 calculateLength를 호출하면, 구조적 타이핑 관점에서 x와 y가 있어서 Vector2D와 호환된다. 따라서 오류가 발생하지 않았고, 타입 체커가 문제로 인식하지 않았다.
타입스크립트는 타입의 확장에 열려있다. 즉, 타입에 선언된 속성 외에 임의의 속성을 추가하더라도 오류가 발생하지 않는다.
가끔 당황스러운 결과가 발생한다.
axis는 Vector3D 타입인 v의 키 중 하나이기 때문에 "x", "y", "z" 중 하나여야한다. 그리고 Vector3D 의 선언에 따르면, 이들은 모두 number이므로 coord의 타입이 number가 되어야 할 것으로 예상된다.
다음 코드 처럼 작성 할 수 있다.
const vec3D = {x: 3, y: 4, z: 1, address: '123 Broadway'};
calculateLengthL1(vec3D); //정상, NaN을 반환합니다.
v는 어떤 속성이든 가질 수 있기 때문에, axis의 타입은 string이 될 수도 있다. 그러므로 타입스크립트는 v[axis]가 어떤 속성이 될지 알 수 없기 때문에 number라고 확정할 수 없다.
정확한 타입으로 객체를 순회하는 것은 까다로운 문제이다.
결론은 루프보다는 모든 속성을 각각 더하는 구현이 더 낫다.
function calculateLengthL1(v: Vector3D){
return Math.abs(v.x) + Math.abs(v.y) + Math.abs(v.z);
}
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 인터페이스를 구현하는지 명확히 선언할 필요가 ㅇ벗다.
테스트를 작성할 때 , 더 간단한 객체를 매개변수로 사용할 수도 있다.
test('getAuthors', () => {
const authors = getAuthors({
runQuery(sql:string){
return [['Toni','Morrison'], ['Maya', 'Angelou']]
}
expect(authors).toEqual([{first: 'Toni', last: 'Morrison'},{first:'Maya', last: 'Angelou'}])
}
타입스크립트는 테스트 DB가 해당 인터페이스를 충족하는지 확인한다.
테스트 코드에는 실제 환경의 데이터베이스에 대한 정보가 불필요하다.
모킹 라이브러리도 필요없다.
추상화(DB) 함으로써, 로직과 테이스를 특정한 구현(PostgresDB)로 부터 분리한 것이다.
타입스크립트의 타입 시스템은 점진적이고 선택적이다. 이 기능들의 핵심은 any 타입이다.
일부 특별한 경우를 제외하고는 any를 사용하면 타입스크립트의 수많은 장점을 누릴 수 없다. any를 사용하더라도 그 위험성을 알고 있어야한다.
let age: number;
age = '12' as any; //OK
age는 number 타입으로 선언되었으나 as any를 사용하면 string 타입으로 할당할 수 있다. 타입 체커는 선언에 따라 number 타입으로 판단할 것이고 혼돈은 걷잡을 수 없다.
age += 1; //런타임에 정상, age는 "121"
함수를 작성할 때는 시그니처를 명시해야 한다. 호출하는 쪽은 약속된 타입의 입력을 제공하고, 함수는 약속된 타입의 출력을 반환한다.
그러나 any 타입을 사용하면 이런 약속을 어길 수 있다.
function calculateAge(birthDate: Date): number {
//...
}
let birthDate: any = '1990-01-19';
calculateAge(birthDate); //정상
birthdate 매개변수는 string이 아닌 Date 타입이어야한다. any 타입을 사용하면 calculateAge의 시그니처를 무시한다.
어떤 아이템을 선택할 수 있는 웹 애플리케이션을 만든다. 애플리케이션에는 onSelectItem 콜백이 있는 컴포넌트가 있다. 선택하려는 아이템의 타입이 무엇인지 알기 어려우니 any를 ㅅ사용한다.
interface ComponentProps {
onSelectItem: (item:any) => void;
}
다음과 같이 onSelectItem 콜백이 있는 컴포넌트를 사용하는 코드도 있다.
function renderSelector(props: ComponentProps) {...}
let selectedId: number = 0
function handleSelectItem(item:any){
selectedId = item.id
}
rederSelector({onSelectItem: handleSelectItem})
onSelectItem에 아이템 객체를 필요한 부분만 전달하도록 컴포넌트를 개선해보자. 여기서는 id만 필요하다. ComponentProps의 시그니처를 다음처럼 변경한다.
interface ComponentProps {
onSelectItem: (id: number) => void;
}
타입체커를 통과했지만 런타임에는 오류가 발생한다.
any 쓰지 맙시다.