타입스크립트는 문법적으로 자바스크립트의 상위집합이다.
자바스크립트 프로그램에 문법 오류가 없다면, 유효한 타입스크립트 프로그램이라고 할 수 있다.
반대로 타입스크립트는 별도의 문법을 가지고 있기 때문에 일반적인 경우, 유효한 자바스크립트 프로그램이 아니다.
function greet(who: string) {
^
SyntaxError: Unexpected token :
타입스크립트는 자바스크립트 런타임 동작을 모델링하는 타입 시스템을 갖고 있기 때문에 런타임 오류를 발생시키는 코드를 찾아내려고 한다.
const names = [`Alice', 'Bob'];
console.log(names[2].toUpperCase());
// TypeError: Cannot read property 'toUpperCase' of undefined
const a = null + 7;
// ~~~~ '+' 연산자를 ... 형식에 적용할 수 없습니다.
function add(a, b) {
return a + b;
}
add(10, null);
이 코드가 타입 체커를 통과할 수 있을까?
이는 설정이 어떻게 되어있는지 모른다면 대답할 수 없다.
설정은 커맨드 라인에서 사용할 수 있으며
$ tsc --noImplicitAny program.ts
tsconfig.json
파일을 이용해서도 가능하다. 보통 이 방법을 추천한다.
타입스크립트의 설정을 제대로 사용하려면 noImplicitAny
와 strictNullChecks
를 제대로 이해해야 한다.
noImplicitAny
noImplicitAny
는 변수들이 미리 정의된 타입을 가져야 하는지 여부를 제어한다.
다음 코드는 noImplicitAny
가 설정되어있다면 오류가 된다.
function add(a, b) {
return a + b;
}
암시적 any
를 사용하지 않겠다는 설정이기 때문에 이를 직접 명시적으로 any
로 선언해주거다 더 분명한 타입을 사용하여 해결할 수 있다.
function add(a: number, b: number) {
return a + b;
}
noImplicitAny
가 있는 타입스크립트와 없는 타입스크립트는 완전히 다른 언어처럼 느껴질 것이며, 자바스크립트 파일을 타입스크립트로 전환하는 상황 외에는 써주는 것이 좋다.
strictNullChecks
strictNullChecks
는 null
과 undefined
가 모든 타입에서 허용되는지 확인하는 설정이다.
strictNullChecks
가 해제되었을 때 다음 코드는 유효한 코드지만, strictNullChecks
를 설정하면 오류가 된다.
const x: number = null;
// ~ 'null' 형식은 'num ber' 형식에 할당할 수 없습니다.
null
대신 undefined
를 써도 같은 오류가 나며, null
을 허용하려면 다음과 같이 의도를 명시적으로 드러내야 합니다.
const x: number | null = null;
strict
위 두 설정은 타입스크립트에서 가장 중요한 설정이며, 이 모든 체크를 설정하고 싶다면 strict
설정을 하면 된다.
타입스크립트에 strict
설정을 하면 대부분의 오류를 잡아낸다.
타입스크립트는 두 가지 역할을 수행한다.
이 두 가지 역할은 완벽히 독립적이며, 타입스크립트가 자바스크립트로 변환될 때 코드 내의 타입에는 영향을 주지 않는다.
또한 자바스크립트의 실행 시점에도 타입은 영향을 미치지 않는다.
$ cat test.ts
let = 'hello';
x = 1234;
$ tsc test.ts
test.ts:2:1 = error TS2322: `1234` 형식은 `string` 형식에 할당할 수 없습니다.
2 x = 1234;
~
$ cat test.js
var x = 'hello';
x = 1234;
이 부분 타입스크립트가 엉성한 언어처럼 보일 수 있지만, 여전히 컴파일된 산출물을 생성하기 때문에, 문제가 된 오류를 수정하지 않더라도 애플리케이션의 다른 부분을 테스트할 수 있다.
만약 오류가 있을 때 컴파일하지 않으려면, tsconfig.json
에 noEmitOnError
를 설정하면 된다.
실제로 자바스크립트로 컴파일되는 과정에서 모든 인터페이스, 타입, 타입 구문은 그냥 제거되어 버린다.
따라서 다음 코드는 별 의미가 없다. Rectangle
은 타입이기 때문에 instanceof
가 실행되는 런타임 시점에 아무런 역할을 할 수 없기 때문이다.
interface Square {
width: number;
}
interface Rectangle extends Square {
height: number;
}
type Shape = Square | Rectangle;
function calculateArea(shape: Shape) {
if (shape instanceof Rectangle) {
// ~~~~~~~~~ 'Rectangle' only refers to a type,
// but is being used as a value here
return shape.width * shape.height;
// ~~~~~~ Property 'height' does not exist
// on type 'Shape'
} else {
return shape.width * shape.width;
}
}
따라서 다음과 같은 코드로 수정할 수 있다.
첫 번째는 height
속성이 존재하는지 체크해 보는 것이다.
function calculateArea(shape: Shape) {
if ('height' in shape) {
return shape.width * shape.height;
} else {
return shape.width * shape.width;
}
}
또는 런타임에 접근 가능한 타입 정보를 명시적으로 저장하는 태그
기법이 있다.
interface Square {
kind: 'square';
width: number;
}
interface Rectangle {
kind: 'rectangle';
height: number;
width: number;
}
type Shape = Square | Rectangle;
function calculateArea(shape: Shape) {
if (shape.kind === 'rectangle') {
return shape.width * shape.height;
} else {
return shape.width * shape.width;
}
}
export default {}
또는 타입을 클래스로 만들 수도 있다.
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) {
return shape.width * shape.height;
} else {
return shape.width * shape.width; // OK
}
}
export default {}
type Shape = Square | Rectangle;
부분에서 Rectangle
은 타입으로 참조되지만, if (shape instanceof Rectangle)
부분에서는 값으로 참조되므로, 오류가 발생하지 않는다.
타입 체커를 통과시키기 위해서 다음과 같은 코드를 짤 수 있다.
function asNumber(val: number | string): number {
return val as number;
}
이 코드를 트랜스파일하면 다음과 같은 코드가 된다.
function asNumber(val) {
return val;
}
아무 의미가 없다.
원하는 목적을 달성하기 위해서는 아래와 같이 자바스크립트 연산을 통해 변환을 수행해야 한다.
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(`I'm afraid I can't do that.`)
}
}
: boolean
는 런타임에 제거되므로, 실수로 setLightSwitch
를 "ON"
으로 호출한다면 console.log
가 실행된다.
순수 TS에서도 네트워크 호출로 받아온 값이면 함수를 실행시킬 수 있는데, 예를 들면 다음과 같다.
interface LightApiResponse {
lightSwitchValue: boolean
}
async function setLight() {
const response = await fetch('/light')
const result: LightApiResponse = await response.json()
setLightSwitch(result.lightSwitchValue)
}
일반적으로 다음과 같은 함수 오버로딩은 불가능하다.
C++과 같은 오버로딩을 기대하면 안된다는 것이다.
function add(a: number, b: number) { return a + b; }
// ~~~ Duplicate function implementation
function add(a: string, b: string) { return a + b; }
// ~~~ Duplicate function implementation
타입스크립트가 함수 오버로딩을 지원하긴 하지만, 온전히 타입수준에서만 동작한다.
따라서 구현체는 오직 하나뿐이어야 한다. (자바스크립트에서 두 구현체를 구분할 수 없으므로)
function add(a: number, b: number): number;
function add(a: string, b: string): string;
function add(a, b) {
return a + b;
}
const three = add(1, 2); // Type is number
const twelve = add('1', '2'); // Type is string
두 선언문은 JS 변환 단계에서 제거되게 된다.
타입과 타입 연산자는 JS 변환 시점에 제거되기 때문에 런타임 성능에 영향을 주지는 않는다.
다만 필연적으로 빌드타임 오버헤드
가 있을 수밖에 없다.
자바스크립트는 본질적으로 Duck typing 기반이고, 타입스크립트 또한 이런 동작을 그대로 모델링 한다.
그러나 타입 체컹의 타입에 대한 이해도가 사람과 조금 다르기 때문에 가끔 예사치 못한 결과가 나온다.
따라서 구조적 타이핑을 제대로 이해한다면 오류인 경우와 오류가 아닌 경우의 차이를 알 수 있고, 더욱 견고한 코드를 작성할 수 있다.
아래 코드로 예를 들어보자.
interface Vector2D {
x: number;
y: number;
}
이때 벡터의 길이를 계산하는 코드는 다음과 같을 것이다.
function calculateLength(v: Vector2D) {
return Math.hypot(v.x, 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); // OK, result is 5
흥미로운 점은 NamedVector
와 Vector2D
의 관계를 전혀 선언하지 않았다는 점이며, NamedVector
를 위한 별도의 calculateLength
를 구현할 필요가 없다는 것이다.
이는 NamedVector
의 구조가 Vector2D
와 호환되기 때문이며, 여기에서 구조적 타이핑이라는 용어가 사용된다.
여기에서 타입스크립트의 타입은 열려있다라는 점을 인식하고 가야한다.
이런 특성때문에 다음과 같은 오류가 발생한다.
interface Vector3D {
x: number
y: number
z: number
}
function calculateLengthL1(v: Vector3D) {
let length = 0
for (const axis of Object.keys(v)) {
const coord = v[axis]
// ~~~~~~~ Element implicitly has an 'any' type because ...
// 'string' can't be used to index type 'Vector3D'
length += Math.abs(coord)
}
return length
}
분명히 코드 자체는 문제가 없어보이지만, 사실은 타입스크립트가 오류를 정확히 찾아낸 것이 맞다.
앞에서 Vector3D
는 마치 봉인되어 있는 것처럼 사용하였지만, 사실은 다음과 같은 코드를 작성할 수도 있다.
const vec3D = { x: 3, y: 4, z: 1, address: '123 Broadway' }
calculateLengthL1(vec3D) // OK, returns NaN
따라서 정확한 타입으로 객체를 순회하는 것은 까다로운 문제이며, 이 경우에는 아래와 같은 구현이 더 낫다.
function calculateLengthL1(v: Vector3D) {
return Math.abs(v.x) + Math.abs(v.y) + Math.abs(v.z);
}
이는 클래스와 관련된 할당문에서도 당황스러운 결과를 보여준다.
class C {
foo: string
constructor(foo: string) {
this.foo = foo
}
}
const c = new C('instance of C')
const d: C = { foo: 'object literal' } // OK.
d
가 C
타입에 할당되는 것이 어색해보이지만, 구조적으로는 필요한 속성과 생성자를 가진다. (생성자는 Object.prototype
에 존재한다.)
따라서 문제가 없으며, 다음 예제를 통해 좀 더 감을 익힐 수 있을 것이다.
class C {
foo: string
constructor(foo: string) {
this.foo = foo
}
method() {}
}
const c = new C('instance of C')
const d: C = { foo: 'object literal' } // error. 'method' 속성이 '{ foo: string; }' 형식에 없지만 'C' 형식에서 필수입니다.
const e: C = { foo: '', method() {} } // foo, method 속성이 모두 있으면 okay.
class E {
method() {}
}
class D extends E {
foo: string
constructor(foo: string) {
super()
this.foo = foo
}
}
const f: C = new D('') // prototype chain 상에 method가 존재하면 okay.
const g = Object.create({ method() {} }, { foo: { value: '' } }) // g: any
const h: C = g // C type 강제(assert)하여 okay.
const i: { foo: string; method: () => void } = Object.create({ method() {} }, { foo: { value: '' } })
const j: C = i // { foo, method } 타입을 강제하여 okay.
구조적 타이핑은 테스트를 작성할 때 특히 유용하다.
데이터베이스에 쿼리를 날리고 결과를 처리하는 함수를 가정해보자.
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
를 테스트하기 위해서는 mocking한 PostgresDB
를 생성해야 한다.
이때 구조적 타이핑을 활용해서 더 구체적인 인터페이스를 정의할 수 있다.
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' },
])
})
이렇게 테스트를 하는 것이 가능함으로써 실제 환경의 데이터베이스에 대한 정보가 불필요하며, 심지어 모킹 라이브러리도 필요없다.
추상화를 함으로써, 로직과 테스트를 특정한 구현으로부터 분리한 것이다.
any
타입 지양하기타입 시스템은 점진적이고, 선택적이며, 이 기능들의 핵심은 any
타입이다.
따라서 가끔은 아래처럼 as any
를 이용해서 오류를 해결하고 싶을 때도 있을 것이다.
let age: number
age = '12'
// ~~~ Type '"12"' is not assignable to type 'number'
age = '12' as any // OK
그러나 일부 특별한 경우를 제외하고는 any
를 사용하면 타입스크립트의 수많은 장점을 누릴 수 없게 된다.
any
타입의 위험성에 대해서 살펴보자.
any
타입에는 타입 안전성이 없다.앞선 예제에서 타입체커는 선언에 따라 age
를 계속 number
로 판단할 것이고, 혼돈은 걷잡을 수 없이 커진다.
age += 1; // 런타임에 정상, age는 "121"
any
는 함수 시그니처를 무시한다.함수를 작성할 때는 호출하는 쪽에서는 약속된 타입의 입력을 제공하고, 함수는 약속된 타입의 출력을 반환해야 한다.
그러나 any
를 사용하면 이런 약속을 어길 수 있다.
function calculateAge(birthDate: Date): number {
// ...
}
let birthDate: any = '1990-01-19'
calculateAge(birthDate) // OK
any
타입에는 언어 서비스가 적용되지 않는다.어떤 심벌에 타입이 있다면, 타입스크립트는 자동완성 기능과 적절한 도움말을 제공한다.
그러나 any
를 사용하면 아무 도움도 받지 못한다.
이 외에도
any
타입은 코드 리팩터링 때 버그를 감추며,따라서 any
의 사용은 가급적 지양하는 것이 좋다.
Reference
- 댄 밴더캄, 『이펙티브 타입스크립트』, 장원호, 서울:도서출판 인사이트, PP.1-32, 2021,
978-89-6626-334-9