[이펙티브 타입스크립트] 런타임 타입 체크 & 구조적 타이핑 (아이템 3~5)

Gyuhan Park·2024년 2월 11일
0

공식문서

목록 보기
1/1
post-thumbnail

💭 TMI

타입스크립트를 사용하면서 사용하는 방법은 되게 쉬운데 잘 쓰고 있는지 판단하기 어렵다. 나는 처음부터 잘 쓰고 싶었는지 첫 책으로 이펙티브 타입스크립트를 선택했다.

사실 TS 첫 책으로 이 책을 선택한 건 약간 후회가 되기도 한다. 내용 자체가 첫 공부를 시작할 때 받아들이기에는 난해하다고 느꼈기 때문이다. 그래서 중간에 읽다가 안읽다가를 반복했는데 어떻게 해야 타입스크립트를 잘 쓰고 있다고 말할 수 있을까 라는 고민이 들 때 이 책을 다시 읽기 시작했다. 1회독을 한 후에 다시 읽어보고 있는데 책 내용의 전체를 담지 않고 중요하다고 느껴지는 부분만 정리한 내용이다.

[ 요약 ]

  • 자바스크립트로 컴파일되는 과정에서 모든 인터페이스, 타입, 타입 구문은 제거된다.
  • 런타임에 타입 체크가 불가능하지만 타입 정보를 유지하는 방법 3가지 존재
    • 속성값 체크, 태그된 유니온, 클래스
  • 타입 연산은 런타임에 영향을 주지 않기 때문에 JS 연산을 통해 변환해야함
  • 구조적 타이핑의 특징으로 인한 오류의 원인을 알고 타입 시스템 이해하기

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

[ 타입스크립트 컴파일러의 2가지 역할 ]

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

이 두가지는 서로 완벽히 독립적
TS → JS로 변환될 때 코드 내의 타입에는 영향을 주지 않음
JS의 실행 시점에도 타입은 영향을 미치지 않음

✅ 타입 오류가 있는 코드도 컴파일이 가능합니다

컴파일은 타입 체크와 독립적으로 동작하기 때문에, 타입 오류가 있는 코드도 컴파일 가능
타임 체크와 컴파일이 동시에 이루어지는 C나 자바와 다름
타입스크립트 코드에 오류가 있다 = 타입 체크에 문제가 있다

✅ 런타임에는 타입 체크가 불가능합니다 + 런타임 타입 유지 방법

instanceof 는 런타임에 실행되지만, Rectangle은 타입이기 때문에 런타임 시점에 아무런 역할을 할 수 없음

자바스크립트로 컴파일되는 과정에서 모든 인터페이스, 타입, 타입 구문은 제거된다.

interface Square {
	width: number;
}

interface Rectangle extends Square {
	height: number;
}

type Shape = Square | Rectangle;

function calculateArea(shape: Shape){
  	// Rectangle은 형식만 참조하지만, 여기서는 값을 사용되고 있습니다.
	if (shape instanceof Rectangle) { 
      	// Shape 형식에 height 속성이 없습니다.
		return shape.width * shape.height;
	} else {
		return shape.width * shape.width;
	}
}

하지만 런타임에 타입을 체크해야하는 경우 존재
아래 3가지 기법을 통해 런타임에도 타입 구분 가능

[ 런타임에 타입 정보를 유지하는 방법 3가지 ]

  1. 속성값 체크
  2. 태그된 유니온 기법 (런타임에 접근 가능한 타입 정보를 명시적으로 저장하는 태그기법)
  3. 클래스 (타입과 값을 모두 사용)

🚨 속성값 체크

그 타입에만 있는 속성값이 변수에 존재하는지 체크
→ Rectangle 타입에만 있는 height 속성 체크

속성 체크는 런타임에 접근 가능한 값에만 관련되지만. 타입 체커 역시도 shape의 타입을 Rectangle로 보정

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

🚨 태그된 유니온 기법

런타임에 접근 가능한 타입 정보를 명시적으로 저장하는 태그기법
→ kind 속성값을 추가해 타입 구분

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'){ // Rectangle
	return shape.width * shape.height;
	} else { // Square
		return shape.width * shape.width;
	}
}

🚨 클래스

타입(런타임 접근 불가)과 값(런타임 접근 가능)을 둘다 사용하는 기법
→ 인터페이스는 타입으로만 사용 가능하지만, 클래스로 선언하면 타입과 값으로 모두 사용할 수 있음

type Shape = Square | Rectangle → Rectangle 타입 으로 참조
shape instanceof Rectangle → Rectangle 으로 참조
어떻게 참조되는지 구분하는 건 매우 중요 → 아이템 8에서 다룸

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

✅ 타입 연산은 런타임에 영향 ❌

string 또는 number 타입인 값을 항상 number로 정제하는 경우 가정

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

as number는 타입 연산이므로 런타임 동작에는 아무런 영향 ❌
값을 정제하기 위해서는 런타임의 타입을 체크 해야 하고 JS 연산을 통해 변환을 수행해야함

function asNumber(val: number | string): number {
	return typeof(val) === 'string' ? Nummber(val) : val;
}

✅ 런타임 타입은 선언된 타입과 다를 수 있습니다

타입스크립트의 타입은 런타임에 제거됨
따라서 런타임 타입과 선언된 타입이 맞지 않을 수 있음
타입체커를 통과하더라도 선언된 타입이 언제든지 달라질 수 있다는 것을 기억하기

✅ 타입스크립트 타입으로는 함수 오버로드 ❌

오버로딩 : 동일한 함수명에 매개변수만 다른 여러 버전의 함수
타입과 런타임의 동작이 무관하기 때문에 함수 오버로딩 불가능
함수 오버로딩 기능을 지원하긴 하지만, 타입 수준에서만 동작
→ 타입 정보를 제공할 뿐, 컴파일 후에는 구현체만 남음

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); // number
const twelve = add('1', '2'); // string

✅ 타입스크립트 타입은 런타임 성능에 영향 ❌

타입과 타입 연산자는 JS 변환 시점에 제거되기 때문에, 런타임의 성능에 아무런 영향을 주지 않음

  • 런타임 오버헤드 대신, 빌드타임 오버헤드 존재
    • 컴파일은 빠른 편이며, 오버헤드가 커지면 트랜스파일만 설정하여 타입체크를 생략할 수 있음
  • 호환성 vs 성능
    • 오래된 런타임 환경을 지원하기 위해 호환성을 높이고 성능 오버헤드 감안
    • 호환성을 포기하고 성능 중심의 네이티브 구현체 선택
    • 컴파일 타깃과 언어 레벨의 문제이며 타입과는 무관

📘 아이템 4 : 구조적 타이핑에 익숙해지기

덕 타이핑(duck typing)
객체가 어떤 타입에 부합하는 변수와 메서드를 가질 경우 객체를 해당 타입에 속하는 것으로 간주하는 방식

어떤 함수의 매개 변수 값이 제대로 주어진다면, 그 값이 어떻게 만들어졌는지 신경 쓰지 않고 사용
자바스크립트가 덕 타이핑 기반인데, 타입스크립트는 이를 그대로 모델링

✅ 구조적 타이핑 예시

Vector2D와 NamedVector의 관계를 전혀 선언하지 않았는데 동작한다는 점이 중요

calculateLength는 Vector2D 타입의 매개변수를 인수로 받음
NamedVector의 구조가 Vector2D와 호환되기 때문에 calculateLength 함수 호출 가능
구조적 타이핑(structural 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

✅ 구조적 타이핑으로 인한 오류

[ 문제 상황 ]
벡터의 길이를 1로 만드는 normalize 함수가 있다고 가정
아래 예시는 1 대신 1.41 을 출력

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

function calculateLength(v: Vector2D){
	return Math.sqrt(v.x * v.x + v.y * v.y);
}

function normalize(v: Vector3D){
	const length = calculateLength(v);
	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는 Vector2D 기반으로 연산하는데 normalize는 Vector3D로 연산

Vector3D와 호환되는 {x, y, z} 객체로 calculateLength를 호출하면 구조적 타이핑 관점에서 x와 y를 가지므로 Vector2D에 호환됨

봉인된 타입 또는 정확한 타입 : 함수 호출에 사용되는 매개변수의 속성들이 매개변수의 타입에 선언된 속성만을 가짐
타입스크립트 타입시스템에서는 봉인된 타입을 표현할 수 없으며, 타입은 열려있음 (open)

✅ 열려있는 타입으로 인한 오류

[ 문제 상황 ]
Vector3D 의 x, y, z 값을 가져와 계산
axis는 Vector3D 타입인 v의 키 중 하나인데 오류 발생

function calculateLength(v: Vector3D){
	let length = 0;
	for (const axis of Object.keys(v)) {
		const coord = v[axis]; // string은 Vector3D의 인덱스로 사용할 수 없음
		length += Math.abs(coord);
	}
	return length;
}

[ 문제점 ]
Vector3D는 열려 있음
v는 어떤 속성이든 가질 수 있기 때문에 axis의 타입은 string

const vec3D = {x: 3, y: 4, z: 1, address: '123 boardway'};
calculateLength(vec3D); // 정상, NaN을 반환.

[ 해결 방안 ]
정확한 타입으로 객체를 순회하는 것은 까다로움
→ 위 예시에서는 루프보다는 모든 속성을 각각 더하는 구현이 더 나음

function calculateLength(v: Vector3D){
	return Math.abs(v.x) + Math.abs(v.y) + Math.abs(v.z);
}

✅ 클래스와 관련된 할당문 오류

[ 문제 상황 ]
string 하나를 필드값으로 받는 class C
인스턴스로 생성하지 않아도 C 타입으로 할당이 됨

class C {
	foo: string;
	constructor(foo: string){
		this.foo = foo;
	}
}

const c = new C('instance of C');
const d: C = { foo: 'object literal' }; // 정상

[ 문제점 ]
d는 string 타입의 foo 속성을 가짐
하나의 매개변수로 호출되는 생성자를 가짐 (Object.prototype 으로부터 가져옴)
구조적으로 필요한 속성과 생성자가 존재하기 때문에 문제 없음

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

✅ 테스트를 작성할 때 구조적 타이핑 유리

getAuthors 함수를 테스트하기 위해서는 모킹한 PostgresDB를 생성해야함
→ 모킹 대신 구조적 타이핑을 활용하여 더 구체적인 인터페이스 정의

interface DB {
	runQuery: (sql: string) => any[];
}

function getAuthors(database: DB): Author[] {
	const res = database.runQuery();
	return ...;
}

구조적 타이핑 덕분에 PostgresDB가 DB 인터페이스를 구현하는지 명확히 선언할 필요 ❌
테스트 DB가 해당 인터페이스를 충족하는지 확인
테스트 코드에는 실제 환경의 DB 정보 불필요
→ 추상화(DB)를 함으로써, 로직과 테스트를 특정한 구현(PostgresDB)으로부터 분리

test('getAuthors', () => {
	const authors = getAuthors({
		runQuery(sql: string) {
			return [['Toni', 'Morrison']];
		}
	});
	expect(authors).toEqual([{first: 'Toni', last: 'Morrison'}]);
});

또다른 장점으로 라이브러리 간의 의존성을 완벽히 분리 가능

profile
단단한 프론트엔드 개발자가 되고 싶은

0개의 댓글