이펙티브 타입스크립트 | 1장 타입스크립트 알아보기

dev_hee·2022년 7월 25일
0

TypeScript

목록 보기
1/6

본 문은 이펙티브 타입스크립트를 읽고 알게된 내용을 정리한 글입니다.


[아이템1] 타입스크립트와 자바스크립트의 관계 이해하기

타입스크립트는 자바스크립트의 상위 집합(superset)이다.
타입스크립트는 타입이 정의된 자바스크립트의 상위집합이다.

.js 파일에 있는 코드는 이미 타입스크립트이다.
main.jsmain.ts 로 변경해도 동일하다.
이러한 특성은 자바스크립트에서 타입스크립트로 마이그레이션 하는데 엄청난 이점이 된다.

모든 자바스크립트 프로그램은 타입스크립트이다 -> 참
모든 타입스크립트 프로그램은 자바스크립트이다 -> 거짓

타입 시스템은 런타임 오류를 방지한다

타입 시스템의 목표 중 하나는 런타임에 오류를 발생시킬 코드를 미리 찾아내는 것이다.
타입스크립트가 정적 타입 시스템이기 때문에 가능하다.

let city = "seoul";
console.log(city.touppercase()); // touppercase 속성이 string 형식에 없습니다.

타입 체커를 통과한 타입스크립트


이미지 출처

평소 작성하는 타입스크립트 코드가 "타입 체커를 통과한 타입스크립트 프로그램"에 속하게 된다.

타입스크립트의 타입 시스템은 자바스크립트의 런타임 동작을 모델링한다.

const x = 2 + '3'; // 정상. string

하지만 타입스크립트는 추가적인 타입 체크를 하는 경우도 있다. 다음은 자바스크립트 런타임에선 오류가 발생하지 않지만, 타입 체커는 문제점을 표시한다.

const a = null + 7; // 에러. + 연산자를 ... 형식에 적용할 수 없습니다.

타입 체커를 통과 하더라도 런타임 에러가 발생한다.

const name = ['hee', 'jinu'];
console.log(name[2].toUpperCase()); // TypeError : Cannot read property 'toUpperCase' of undefined

타입스크립트는 앞의 배열이 범위 내에서 사용될 것이라고 가정했지만 실제로는 범위를 벗어나서 런타임 오류가 발생했다.

타입스크립트가 이해하는 값의 타입과 실제 값에 차이가 존재하기 때문에 발생하는 오류들이다.
타입 시스템이 정적 타입의 정확성을 보장해줄 것 같지만 그렇지 않기 때문이다.


[아이템2] 타입스크립트 설정 이해하기

cli 로 타입 체커 설정

tsc --noImplicitAny program.ts

cli는 타입스크립트를 어떻게 사용할 계획인지 공유할 수 없어서 지양하는 것이 좋다.

tsconfig.json 설정 파일

{
 "compilerOptions": {
 	"noImplicitAny": true
 }
}

설정 파일은 tsc --init 으로 간단히 생성된다.

noImplicitAny

변수들이 미리 정의된 타입을 가져야 하는지 여부를 제어한다.

noImplicitAny 가 해제되어 있을 경우엔 암시적 any가 타입으로 사용된다.
any는 타입 체커를 무력하게 만드니 매우 주의해서 사용해야한다.

noImplicitAny 를 설정하면 타입을 반드시 명시해야 하므로 다음과 같이 add 함수를 정의해야한다.

function add(a: number, b:number){
 return a + b; 
}

strictNullChecks

nullundefined가 모든 타입에서 허용되는지 확인한다.

  • strictNullChecks 해제
const x: number = null; // 정상
  • strictNullChecks 설정
const x: number = null; // null 형식은 number 형식에 할당할 수 없습니다.

의도적으로 null이나 undefined 를 타입으로 명시하여 위의 오류를 고칠 수 있다.

const x: number | null = null;

undefined 는 객체가 아닙니다. 와 같은 런타임 오류를 방지하기 위해서는 strictNullChecks 를 사용해야 한다.

타입 단언 연산자 A!.

A 가 null, undefined가 아니라고 단언한다.

strictNullChecks를 설정한 경우엔 단언 연산자를 사용해서 값이 null, undefined가 아니라고 단언해주어야 한다.

if (el) el.textContent = 'hello'; // null은 제외됨
el!.textContent = 'hello'; // el이 null이 아님을 단언함

[아이템3] 코드 생성과 타입이 관계 없음을 이해하기

타입스크립트 컴파일러는 두 가지 역할을 수행한다.

  1. 최신 타입스크립트/ 자바스크립트를 브라우저에서 동작할 수 있도록 구버전의 자바스크립트로 트랜스파일(transplie)한다.
  2. 코드의 타입 오류를 체크한다.

이 두 가지는 서로 완벽히 독립적이다.

  • 타입스크립트가 자바스크립트로 변환될 때 코드 내의 타입에는 영향을 주지 못한다.
  • 자바스크립트의 실행 시점에서 타입은 영향을 미치지 않는다.

타입 오류가 있는 코드도 컴파일이 가능하다.

컴파일은 타입 체크와 독립적으로 동작하기 때문에, 타입 오류가 있는 코드도 컴파일이 가능하다.

오류가 있을 때 컴파일하지 않으려면 tsconfig.jsonnoEmitOnError 를 설정하면 된다.

런타임에는 타입 체크가 불가능하다.

타입스크립트의 타입은 '제거 가능(erasable)'하다.
자바스크립트로 컴파일 되는 과정에서 타입 관련 코드들 (인터페이스, 타입, 타입 구문)은 제거된다.

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가 없습니다.
    } else {
       return shape.width * shape.width;
    }
}

위는 타입을 값으로 사용한 경우에 발생한 오류이다.
타입은 런타임 이전에, 값은 런타임 때 사용 가능하기 때문이다.

앞의 코드에서 shape의 타입을 명확하게 하려면 런타임에 타입 정보를 유지하는 방법이 필요하다. 다음 3가지 방법으로 런타임때 타입 정보를 유지할 수 있다.

1. 속성값이 있는지 체크

런타임시 타입 정보를 확인하기 위해, height 속성이 존재하는지 체크할 수 있다.

function calculateArea(shape: Shape){
	if('height' in shape) { // 타입이 Rectangle
      return shape.width * shape.height; 
    } else { // 타입이 Square
       return shape.width * shape.width;
    }
}

2. tagged union

런타임에 접근 가능한 타입 정보를 명시적으로 저장하는 '태그' 기법이다.

interface Square {
  kind: 'square'; // tagged union
  width: number;
}
interface Rectangle {
  kind: 'rectangle';
  width: number;
  height: number; 
}
type Shape = Square | Rectangle;

function calculateArea(shape: Shape){
	if(shape.kind === 'rectangle') {
      return shape.width * shape.height; 
    }
  // ...
}

런타임에 타입 정보를 손쉽게 유지할 수 있기 때문에 유용한 기법이다.

3. class type

타입(런타임 접근 불가)과 값(런타임 접근 가능)을 둘 다 사용하는 기법이다.
타입을 클래스로 만들면 오류를 해결할 수 있다.

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; 
    }
  // ...
}

타입 연산은 런타임에 영향을 주지 않는다.

function adNumber(val: number | string): number{
 return val as number; // number 타입으로 단언한다. 
}

as number 는 타입 연산이고 런타임 동작에는 아무런 영향을 미치지 않는다.
따라서 val 이 string 인 경우 number 로 타입 변환을 하지 못한다.

런타임 타입은 선언된 타입과 다를 수 있다.

타입스크립트에서는 함수의 매개변수 타입을 예측하지만, 실제 자바스크립트 런타임에서는 다른 타입의 값으로 호출될 수 있다.

API 요청으로 받아온 데이터가 이에 대표적인 예시다.

interface LightApiResponse {
 lightSwitchValue: boolean; 
}
async function setLight() {
 const response = await fetch('/light');
  const result: LightApiResponse = await response.json(); // 불리언이 아닐 수 있다!
  setLightSwitch(result.lightSwitchValue);
}

API를 잘못 파악해서 lightSwitchValue 가 불리언이 아닌 문자열일 수 있다. 하지만 타입스크립트는 이를 찾아내지 못한다.

타입스크립트에서 런타임 타입과 선언된 타입이 맞지 않을 수 있기 때문에, 언제든 선언된 타입이 달라질 수 있다는 것을 명시해야한다.

타입스크립트 타입으로는 함수를 오버로드할 수 없다.

  • 오버로드
    동일한 이름에 매개변수만 다른 여러 버전의 함수를 허용함.

타입스크립트는 타입과 런타임의 동작이 무관하기 때문에 함수 오버로딩이 불가능하다.

하나의 함수에 대해 여러 개의 선언문을 작성할 수 있지만 구현체(implementation)은 오직 하나다.

참고 - 타입스크립트 함수 오버로딩

function add(a: number, b:number): number;
function add(a: string, b: string): string;

function add(a, b) { // 단 한개의 구현체
 return a + b; 
}

타입스크립트 타입은 런타임 성능에 영향을 주지 않는다.

타입과 타입 연산자는 자바스크립트 변환 시점에 제거되기 때문에, 런타임의 성능에 아무런 영향을 주지 못한다.

단, 런타임 오버헤드가 없는 대신, 타입스크립트 컴파일러는 빌드타임 오버헤드가 있다.
하지만 컴파일러는 매우 빠르고 증분(incremental) 빌드 시 더욱 빠르다.

타입스크립트가 컴파일하여 생성하는 자바스크립트를 오래된 런타임 환경을 지원하기 위해 호환성을 높이고 성능 오버헤드를 감안할지, 호환성을 포기하고 성능 중심 네이티브 구현체를 선택할지 문제에 마주할 수 있다.
호환성을 높여 헬퍼 코드가 추가되면 오버헤드가 생길것이다.

하지만 이것은 언어 레벨의 문제이고 타입과는 전혀 무관하다.


[아이템4] 구조적 타이핑

덕 타이핑 (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;
}
const v: NamedVector = { x: 3, y:4, name: 'zee'}
calculateLength(v); // 정상. 5

NamedVector의 구조가 Vector2D와 호환되기 때문에 calculateLength 호출이 가능하다.

덕 타이핑의 문제

interface Vector3D {
  x: number;
  y: number;
  z: number;
}
function normalize(v: Vector3D){
  const length = calculateLength(v); // Vector2D 가 아닌 Vector3D 타입을 넣어도 오류가 나지 않는다.
  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 }

calculateLength 가 2D 벡터를 받도록 선언했지만 3D 벡터를 받는데 문제가 없었다.

Vector3D 구조가 Vector2D 와 호환되기 때문이다.

이런 타입은 '열려(open)'있는 타입이라고 한다.

함수에서 매개변수의 속성들이 매개변수 타입에 선언된 속성만 가지는 경우를 봉인된(sealed), 정확한(precise) 타입이라고 부른다. 열린 타입은 이와 반대이다.

즉 타입에 선언된 속성 외의 다른 속성들을 가지더라도 두 타입은 서로 호환된다.

덕타입핑으로 인해 루프를 순회할 때 발생하는 당황스러운 결과

interface Vector3D {
    x: number;
    y: number;
    z: number;
}

function calculateLengthL1(v: Vector3D){
    let length = 0;
    for (const axis of Object.keys(v)) {
      	// axis: string
        const coord = v[axis];
        // Element implicitly has an 'any' type because expression of type 'string' can't be used to index type 'Vector3D'.
        // -> 'string'은 'Vector3D'의 인덱스로 사용할 수 없기에 엘리먼트는 암시적으로 'any' 타입입니다.
        length += Math.abs(coord)
    }
    return length;
}
  • 예측
    axisVector3D 타입인 v의 키 중 하나이기 때문에 x, y, z 중 하나여야 하며, Vector3D의 선언에 따르면 이들은 모두 number 이므로 coord 의 타입이 number 가 되어야 할 것으로 예상된다.

  • 사실
    타입스크립트가 오류를 정확히 찾아낸 것이 많다. 함수 매개변수 v - Vector3D는 열려있기 때문에 다음과 같이 x, y, z 외의 다른 프로퍼티가 추가된 객체를 넘길 수도 있다.

const vec3D = { x: 3, y: 4, z: 1, address: '서울시' };
calculateLengthL1(vec3D) // 정상. 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: '객체 리터럴' }; // 정상

dC 타입에 할당되는 당황스러운 결과가 발생했다. 그 이유는 다음과 같다.

  • dstring 타입의 foo 프로퍼티를 가진다.
  • 하나의 매개변수로 호출되는 생성자(Object.prototype)을 가진다.

c는 생성자로 class C 를 가진다.

d는 생성자로 Object 를 가진다.

따라서 구조적으로는 필요한 속성(foo)와 생성자(constructor) 를 가지기 때문에 문제가 되지 않는다.

만약 C의 생성자가 단순 할당이 아닌 연산 로직이 존제한다면 d 의 경우엔 생성자를 실행하지 않으므로 문제가 발생하게 된다.

이런 부분이 클래스의 서브클래스임을 보장하는 C++이나 자바와 같은 언어와는 매우 다른 특징이다.


[아이템5] any 타입 지양하기

  1. 타입 안전성이 없다
    number 타입 변수를 as any 를 사용하면 string 타입을 할당할 수 있게 된다.

  2. 함수 시그니처(스펙)를 무시한다.
    함수의 매개변수 타입을 무시하고 인수로 전달된다.

function increase(a: number): number {
 return a + 1; 
}
let age: any = '25';
increase(age); // 정상
  1. 언어 서비스가 제공되지 않는다.
    자동완성 기능과 적절한 도움말이 제공되지 않는다.

  2. 코드 리팩토링 때 버그를 감춘다.
    타입 체커를 통과함에도 런타임에 오류가 발생할 수 있게된다.

  3. 타입 설계를 감춘다.
    any 타입을 사용하면 타입 설계가 불분명해져서 설계가 잘 되었는지, 어떻게 되었는지 전혀 알 수가 없다. 설계가 명확히 보이도톡 타입을 일일이 작성하는게 좋다.

  4. 타입 시스템의 신뢰도를 떨어뜨린다.
    any 타입으로 인해 런타임에 타입 오류가 발생될 수 있고 이는 신뢰할 수 없는 코드이다.

profile
🎨그림을 좋아하는 FE 개발자👩🏻‍💻

0개의 댓글