Effective Typescript에 대한 스터디 진행 내용을 요약한 것입니다.
TS는 문법상의 오류를 지적하지만, 컴파일을 멈추게 만들지는 않는다. 일반적인 컴파일 언어들은 에러가 있으면 컴파일을 멈추는데 이와는 확연한 차이를 보인다.
그래서 TS에서 오류가 발생할 때는 '컴파일 에러가 났다'는 표현 보다 '타입 에러가 났다'는 표현이 맞는 표현이다. 오류와 무관하게 컴파일은 이루어지니까.
만일 오류가 있을 때 컴파일이 불가하게 만들고 싶다면, noEmitOnError
를 true
로 설정해주어 컴파일을 방지할 수 있다. 일반적인 번들러 설정에 기본적으로 있는 설정이기도 하다.
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
}
}
console.log(calculateArea(new Square(10)))
위의 코드를 살펴보면 Shape의 타입으로 클래스인 Square과 Rectangle이 union으로 오고 있다. 이는 클래스도 타입으로 사용될 수 있음을 말하고 있다.
다만 클래스를 타입으로 지정한 경우 해당 변수에는 타입이 된 클래스의 인스턴스만이 올 수 있음에 주의해야 한다.
다음 코드를 살펴보자.
function asNumber(val: number | string): number {
return val as number
}
이 함수가 받는 인수는 number
와 string
두 타입을 모두 받지만, 그 결과값은 number로 전환하여 반환하고자 하는 의도가 담겨 있다.
물론 TS가 자동 형변환을 통해 string
을 number
로 바꾸겠지만, 우리는 이러한 곳에서 사소한 타입들을 확실하게 지정해주고 작성자의 의도를 온전히 프로그램에 전달할 필요가 있다. 따라서 위의 코드는 아래처럼 바뀌는 것이 좀 더 좋은 코드라고 볼 수 있다.
function asNumber(val: number | string): number {
return Number(val)
}
TS는 함수 선언에 대한 오버로딩은 지원하지 않지만, 타입 선언에 대한 오버로딩은 지원한다. 다음 코드를 살펴보자.
function add(a: number, b: number) {
return a + b
}
// Duplicate function implementation
function add(a: string, b: string) {
return a + b
}
// Duplicate function implementation
이처럼 TS에서는 함수 선언에 대한 오버로딩은 중복 함수 구현으로 처리된다.
다만 함수의 타입을 정의하는 것은 다르다.
function add(a: number, b: number): number
function add(a: string, b: string): string
function add(a: any, b: any) {
return a + b
}
const three = add(1, 2) // Type is number
const twelve = add("1", "2") // Type is string
console.log(three, twelve) // 3 12
위의 코드처럼 함수 타입에 대해서 선언을 오버로딩하는 것은 문제가 되지 않는다.
function add(a: number, b: number): number
function add(a: string, b: string): string
function add<T>(a: T, b: T): T {
return a + b // Operator '+' cannot be applied to types 'T' and 'T'.
}
const three = add(1, 2) // Type is number
const twelve = add("1", "2") // Type is string
console.log(three, twelve) // 3 12
이처럼 제네릭을 사용하면 리턴 값의 +
연산자가 제네릭에 적용될 수 없다고 나오는데... 이를 해결할 방법이 있을까?
구조적 타이핑을 제대로 이해한다면 오류인 경우와 오류가 아닌 경우의 차이를 알 수 있고 견고한 코드를 설계할 수 있게 됩니다.
다음 코드를 살펴보자.
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: 4, y: 3, name: "OH" }
console.log(calculateLength(v))
위 코드에서 분명 calculateLength
함수의 인자 v
는 Vector2D
인터페이스를 타입으로 가졌다. 그러나 구현될 때에는 NamedVector
를 타입으로 가졌는데, 이게 어떻게 가능한 것일까?
TS는 당신의 생각보다 영리하다.
그렇다. TS는 자체적으로 Vector2D
가 NamedVector
의 원소만으로 이루어진 인터페이스임을 알고 위의 코드를 용인한 것이다.
이 덕분에 우리는 Vector2D
와 NamedVector
간의 관계를 명시할 필요도 없고, NamedVector
를 타입으로 사용하는 calculateLength
함수를 새로 정의할 필요도 없다. NamedVector
의 구조가 Vector2D
의 구조와 호환되기 때문이다!
이 부분에서 '구조적 타이핑'이라는 용어가 사용된다.
다만 이러한 '구조적 타이핑'이 마냥 좋은 것은 아니다. 다음 코드를 살펴보자.
interface Vector2D {
x: number
y: number
}
function calculateLength(v: Vector2D) {
return Math.sqrt(v.x * v.x + v.y * v.y)
}
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,
}
}
console.log(normalize({
x: 3,
y: 5,
z: 6
}))
위의 코드를 잘 이해할 수 있어야 한다.
위에서 정의된 함수 calculateLength
는 Vector2D
인터페이스를 가지는 v를
받아 값을 반환한다. 즉, calculateLength
는 기본적으로 2차원에서 사용되는 함수인 것이다.
그런데 아래 normalize
함수를 보면 2차원 함수인 calculateLength
를에Vector3D
를 인터페이스로 가지는 v
를 입력했고, 이것이 에러 없이 계산되었다. 물론 TS 내에서는 Vector3D
가 Vector2D
의 요소들을 모두 포함하고 있기 때문에 문제가 없다고 판단되었겠지만, 이는 우리가 함수를 사용하고자 하는 의도를 완벽히 벗어난 코드가 된다. 3차원 환경에서만 쓰여야 할 normalize
함수가 2차원 환경의 변수와 함께 사용되었기 때문이다. 그래서 결과값도 우리가 의도한 것과 다르게 나옴을 확인할 수 있다.
이처럼 구조적 타이핑은 작성자가 알아차리지 못하면 위험할 특징을 가지고 있다.
다른 예제도 살펴보자.
interface Vector2D {
x: number
y: number
}
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 expression of type 'string' can't be used to index type 'Vector3D'.
length += Math.abs(coord)
}
}
const vec3D = { x: 3, y: 4, z: 1, address: '123 Broadway' }
console.log(calculateLengthL1(vec3D))
위의 코드를 잘 보면 calculateLengthL1
함수는 Vector3D
인터페이스를 가진 인수를 받는다. 여기서 vec3D
를 보면 Vector3D
를 구현하면서도 추가적인 값을 가진다. 이러한 경우 문제없이 함수 calculateLengthL1
의 조건을 통과하여 에러 없이 함수 내부로 들어갈 수 있다. 이 또한 작성자의 의도를 완전히 빗겨간 코드이다. 작성자는 당연히 인수 v
가 Vector3D
를 구현하여 3개의 number
값을 가질 것을 예상하였지만, vec3D
는 이와 완전히 다른 string
의 값이 들어있기 때문이다. 이러한 사소한 문제들은 아키텍쳐의 규모가 커질수록 더 큰 오류를 불러올 가능성이 크다.
그래서 TS 공식문서에서는 위와 같은 경우 아래와 같이 직접 코드를 일일이 짜는 방법을 권장하고 있다.
function calculateLengthL1(v: Vector3D) {
return Math.abs(v.x) + Math.abs(v.y) + Math.abs(v.z)
}
조금 고전적인 방법인 것 같아도 오류를 줄이기 위해 이러한 과정도 필요함을 알 수 있다.
구조적 타이핑은 클래스와 관련된 할당문에서도 당황스러운 결과를 보여준다.
다음 코드를 살펴보자.
class C {
constructor(public foo: string) {}
}
const c = new C("instance of C")
const d: C = { foo: "object literal" }
console.log(c, d)
위의 코드에서 c
에는 클래스 C의 인스턴스를 넣어 C를 값으로 사용하였고, d
에서는 클래스 C를 타입으로 지정하여 C를 타입으로 사용하였다. 여기서 C가 타입으로 사용된다면 클래스 C와 내부 형태만 동일하면 되기 때문에, constructor
의 인수들을 인자로 받는 C의 인스턴스 생성과 모습이 조금 달라진다.
위의 클래스 C에 메서드를 하나 구현해보면 그 차이를 확연하게 알 수 있다.
class C {
constructor(public foo: string) {}
abs() {}
}
const c = new C("instance of C")
const d: C = { foo: "object literal", abs: () => {} }
이제 클래스 C는 abs
라는 메서드를 가진다. 이때 c
는 클래스 C의 인스턴스 이므로 constructor
의 인수만 입력해주면 되고, 나머지 메서드들은 __proto__
에 저장된다는 것을 알고 있다.
그러나 클래스 C를 타입으로 사용한 경우 구현된 메서드들도 그 타입의 구성 요소로 간주되어 d
의 경우는 abs
를 직접 구현하도록 되어있다. 즉, 클래스를 타입으로 지정한 경우는 constructor
의 인자들과 메서드들을 모두 지정해주어야한다는 것을 알 수 있다.
class C {
constructor(public foo: string) {}
abs() {}
}
const c: C = new C("instance of C")
const d: C = { foo: "object literal", abs: () => {} }
위의 코드도 성립을 한다...
아까 C를 타입으로 사용한다면 foo
와 abs
를 모두 알려주어야 함을 알았는데, 왜 클래스 C의 인스턴스인 c
의 타입을 C로 지정하면 abs
를 추가적으로 구현하지 않아도 되는 것일까...? abs
는 인스턴스 내에 prototype으로 구현되어 있지, 타입 속성에 의해 명시적으로 구현되어 있지 않은 상태인데 말이다.
잘 모르겠지만... 이런 일을 방지하기 위해서 클래스의 인스턴스를 할당할 때에는 별도의 타입을 지정해주지 않는 것이 좋은 것 같다.
타입 중 가장 유연한 any는 TS를 JS로 바꾸어주는 타입이라고 여겨진다. 그만큼 any를 타입으로 지정하는 순간 자유도가 높아진다. 이는 그만큼 오류 발생 가능성 또한 높아짐을 의미한다. 엔터프라이즈급의 코드 설계에서 이러한 any를 이용한 코드 작성은 치명적으로 다가온다.
any를 사용하는 대표적인 좋지 못한 예시들은 다음과 같다.
let age: number
age = '12' // Type 'string' is not assignable to type 'number'.
age = '12' as any
age += 1 // OK, at runtime, age is now '121'
위 코드를 보면 number
로 타입이 지정된 age
변수는 당연히 string
값이 들어가지 못할 것이다. 그러나 그 뒤에 as any
를 추가해줌으로써 지정된 타입 규칙이 완전히 무시된다;;;
이러한 경우 age
에 연산을 수행하면 string
타입으로서의 연산이 수행되어 작성자의 의도를 벗어난 예술적인 코드가 탄생한다...
이 외에도 any
타입을 사용하면 TS의 장점인 타입 체커와 타입 추론 기능이 작동하지 못한다. 이는 그냥 JS를 사용하는 것과 마찬가지이므로, 정말 필요한 경우가 아니라면 그냥 사용하지 말자.
any
에 대한 예시를 좀 더 살펴보자.
interface Person {
first: string
last: string
}
const formatName = (p: Person) => `${p.first}${p.last}` // p의 구조를 알고 있으므로 선택지 제공
const formatNameAny = (p: any) => `${p.first}${p.last}` // p의 구조를 추론하지 못해 선택지 미제공
Person
이라는 인터페이스를 만들고 이를 적용한 경우와 그렇지 못한 경우의 차이이다.
함수의 인수 타입을 Person
으로 지정한 경우에는 p
를 함수 내에서 호출했을 때 그 내부 요소들에 대한 선택지를 제공하면서 코드 생산성을 높이고 오류를 사전에 방지한다.
그러나 인수 타입을 any
의 경우에는 그러한 기능이 전혀 제공되지 않는다.
이런 경우 TS를 사용하는 이유가 없으므로 차라리 JS를 사용하는게..