이펙티브 타입스크립트 스터디(2)

오형근·2022년 8월 27일
0

Typescript

목록 보기
9/15
post-thumbnail

Effective Typescript에 대한 스터디 진행 내용을 요약한 것입니다.

코드 생성과 타입이 무관함을 이해하기

TS는 문법상의 오류를 지적하지만, 컴파일을 멈추게 만들지는 않는다. 일반적인 컴파일 언어들은 에러가 있으면 컴파일을 멈추는데 이와는 확연한 차이를 보인다.

그래서 TS에서 오류가 발생할 때는 '컴파일 에러가 났다'는 표현 보다 '타입 에러가 났다'는 표현이 맞는 표현이다. 오류와 무관하게 컴파일은 이루어지니까.

만일 오류가 있을 때 컴파일이 불가하게 만들고 싶다면, noEmitOnErrortrue로 설정해주어 컴파일을 방지할 수 있다. 일반적인 번들러 설정에 기본적으로 있는 설정이기도 하다.

Class도 타입으로 사용될 수 있다.

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
}

이 함수가 받는 인수는 numberstring 두 타입을 모두 받지만, 그 결과값은 number로 전환하여 반환하고자 하는 의도가 담겨 있다.

물론 TS가 자동 형변환을 통해 stringnumber로 바꾸겠지만, 우리는 이러한 곳에서 사소한 타입들을 확실하게 지정해주고 작성자의 의도를 온전히 프로그램에 전달할 필요가 있다. 따라서 위의 코드는 아래처럼 바뀌는 것이 좀 더 좋은 코드라고 볼 수 있다.

function asNumber(val: number | string): number {
    return Number(val)
}

TS 오버로딩

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함수의 인자 vVector2D 인터페이스를 타입으로 가졌다. 그러나 구현될 때에는 NamedVector를 타입으로 가졌는데, 이게 어떻게 가능한 것일까?

TS는 당신의 생각보다 영리하다.

그렇다. TS는 자체적으로 Vector2DNamedVector의 원소만으로 이루어진 인터페이스임을 알고 위의 코드를 용인한 것이다.

이 덕분에 우리는 Vector2DNamedVector간의 관계를 명시할 필요도 없고, 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
}))

위의 코드를 잘 이해할 수 있어야 한다.

위에서 정의된 함수 calculateLengthVector2D 인터페이스를 가지는 v를
받아 값을 반환한다. 즉, calculateLength는 기본적으로 2차원에서 사용되는 함수인 것이다.

그런데 아래 normalize함수를 보면 2차원 함수인 calculateLength를에Vector3D를 인터페이스로 가지는 v를 입력했고, 이것이 에러 없이 계산되었다. 물론 TS 내에서는 Vector3DVector2D의 요소들을 모두 포함하고 있기 때문에 문제가 없다고 판단되었겠지만, 이는 우리가 함수를 사용하고자 하는 의도를 완벽히 벗어난 코드가 된다. 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의 조건을 통과하여 에러 없이 함수 내부로 들어갈 수 있다. 이 또한 작성자의 의도를 완전히 빗겨간 코드이다. 작성자는 당연히 인수 vVector3D를 구현하여 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를 타입으로 사용한다면 fooabs를 모두 알려주어야 함을 알았는데, 왜 클래스 C의 인스턴스인 c의 타입을 C로 지정하면 abs를 추가적으로 구현하지 않아도 되는 것일까...? abs는 인스턴스 내에 prototype으로 구현되어 있지, 타입 속성에 의해 명시적으로 구현되어 있지 않은 상태인데 말이다.

잘 모르겠지만... 이런 일을 방지하기 위해서 클래스의 인스턴스를 할당할 때에는 별도의 타입을 지정해주지 않는 것이 좋은 것 같다.


any 타입 지양하기

타입 중 가장 유연한 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를 사용하는게..

profile
https://nohv.site

0개의 댓글