타입스크립트의 타입 시스템

젼이·2024년 2월 1일
1

스터디 이주차 내용.
이펙티브 타입스크립의 2장 중 아이템 6부터 아이템 11까지 공부하고 정리한 내용이다.
읽다가 모르는 개념이 생기면 그것들도 개념을 정리했다.

📚 단어 정리
LSP: language server protocol
할당: A가 B에 할당 가능하다. → A가 B의 부분집합이다.
상속: A가 B를 상속한다. → A가 B의 부분집합이다.
할당(assign): 변수나 속성에 값을 대입하는 작업을 의미한다. 타입스크립트는 정적 타입 언어이기 때문에 변수나 속성에 할당되는 값은 해당 값의 타입과 일치하여야 합니다.


타입스크립트는 코드를 자바스크립트로 변환하는 역할도 하지만, 가장 중요한 역할은 타입 시스템에 있다.

[ Item 6 ] 편집기를 사용해 타입 시스템 탐색하기

타입스크립트를 설치하면, 다음 두 가지를 실행할 수 있다.

  • 타입스크립트 컴파일러(tsc)

    npx tsc {파일명}.ts

    위의 명령어로 타입스크립트를 자바스크립트 파일로 컴파일 할 수 있다.

    npx tsc --strict {파일명}.ts

    --strict 옵션으로 엄격한 기준으로 타입 검사를 수행 가능하다.

  • 단독으로 실행할 수 있는 타입스크립트 서버(tsserver)


📌 tsserver란?

  • 타입스크립트를 위한 language server의 구현
  • 타입스크립트 컴파일러와 언어 서비스 를 캡슐화한 실행 가능한 노드
  • 타입스크립트 패키지에 포함
  • LSP를 지원하는 편집기 및 IDE에서 자동으로 사용
  • 코드 완성, 코드 탐색, 구문 강조 표시 등 다양한 언어 기능을 코드 편집기 및 LSP 을 지원하는 기타 개발 환경에 제공하는 도구
  • tsserver는 LSP를 통해 에디터 또는 개발 환경과 통신하는 독립형 프로세스로 작동하므로 다양한 에디터 및 플랫폼에서 일관된 사용자 경험을 제공할 수 있다.

이 프로세스는 TypeScript 코드를 분석하고 개발자에게 오류 및 경고에 대한 실시간 피드백을 제공합니다

📌 VSCode에서 tsserver가 어떻게 동작할까?

  1. VSCode에서 타입스크립트 파일을 열면 편집기가 해당 파일에 대한 프로젝트를 초기화하도록 tsserver에 요청(파일 경로, 버전 및 구성 옵션)을 보냄.

  2. tsserver는 tsconfig.json 파일에서 프로젝트 구성을 읽고 해당 파일에 대한 타입스크립트 프로젝트를 설정.

  3. 편집기에서 입력하면 VSCode는 코드 완성, 코드 탐색 및 심볼 검색과 같은 기능을 위해 tsserver에 요청을 보냄.

  4. tsserver는 타입스크립트 코드를 분석하고 요청된 정보가 포함된 응답을 편집기로 다시 보냄.

  5. 파일을 저장하면 tsserver가 코드를 다시 확인하고 오류나 경고를 편집기에 보고.

  6. 리팩토링 또는 모든 참조 찾기와 같은 타입스크립트 관련 기능을 사용하는 경우 VSCode는 해당 작업을 수행하도록 tsserver에 요청을 보냄.

전반적으로 tsserver는 백그라운드에서 작동하여 VSCode에 TypeScript 언어 기능을 제공 및 실시간 피드백 제공

📖 참고 - https://velog.io/@dessin/tsserver


그래서 언어 서비스란 뭘까?

  • 코드 자동 완성, 명세(사양, specification) 검사, 검색, 리팩토링 포함.
  • 편집기를 통해 서비스를 제공.
  • 편집기는 타입스크립트가 언제 타입 추론을 수행할 수 있는지에 대한 개념을 잡게 해준다.

📌 타입 추론이란 뭘까?

타입스크립트는 코드에서 변수를 선언하거나, 할당 할때 추론이 일어난다.

let x = 3; // 선언함으로 x는 number라는 추론 일어남

또는 함수를 선언하고, 파라미터에 기본값을 넣으면 추론이 일어난다.

// 파라미터에 기본값을 선언함으로 b는 number라는 추론이 일어남
function test(b = 10) {
  return b;
}

[ Item 7 ] 타입은 값들의 집합이라 생각하기

타입은 할당 가능한 값들의 집합 또는 범위 라고 불린다.

타입의 종류

never
가장 작은 타입. 아무 값도 포함하지 않는 공집합. 아무런 값도 할당 할 수 없다. 재할당 불가하기 때문에 대체 불가한 값을 만들 때 사용한다.

unknown
전체(universal) 집합

유닛(unit)타입 / 리터럴(literal) 타입
never 다음으로 작은 타입. 한 가지 값만 포함한다.

type A = 'A';

유니온(union)
값 집합들의 합집합을 일컫는다.
타입을 두 개 이상 묶은 타입이다.
여러 타입을 지정하고 싶은 경우 | 를 사용 한다.

type AB = 'A' | 'B';

📌 union 인터셉션

| 는 "또는", & 는 "and".
| 를 쓰면 함수 호출시 두개의 인터페이스 중 1개만 보장해주면 되나, &를 쓰면 함수 호출시 두개의 인터페이스 타입을 다 보장해줘야 한다.

interface Test {
  name: string;
  skill: string;
}
interface Test2 {
  name: string;
  age: string;
}

function ask(someone: Test | Test2) {
  console.log(someone.name); // interface의 공통 속성으로 접근 가능
  // someone.skill, age는 공통속성이 아니므로 접근 불가능

  // 접근하고 싶다면 타입 가드로, 하나의 타입만 필터링 한 경우만 활용 가능
}

// &를 이용하면 3개의 속성 활용 가능 (인터섹션)
function ask(someone: Test & Test2) {
  // Test와 Test2 두개의 interface를 포함하게 타입 정의
  console.log(someone.name);
  console.log(someone.skill);
  console.log(someone.age);
}

📌 유니온의 단점
유니온 타입의 경우 두 타입의 공통된 메소드만 타입 추적을 해준다는 단점이 있고, 받은 값을 그대로 리턴시, 리턴 받은 값고 하나의 타입이 아닌 유니온 타입으로 지점되는 문제가 있다

function logText(text: string | number) {
  
  return text;
}


const a = logText("a");
// error: split does not exist on type string | number
a.split("");

stringnumber의 공통된 메소드만 사용 가능하다.
a의 타입은 string | number 이다. 그렇기 때문에 split 이용 불가하다.

위 처럼 유니온은 타입 가드를 한다 해도 return되는 값이 명확하지 않으므로 제네릭을 쓰는 것이 더 좋다.


다양한 타입스크립트 오류에서 '할당 가능한'이라는 문구를 볼 수 있다. 이 문구는 집합의 관점에서, '~의 원소(값과 타입의 관계)' 또는 '~의 부분 집합(두 타입의 관계)'을 의미한다.

타입 체커

집합의 관점에서 타입 체커는 하나의 집합이 다른 집합의 부분 집합인지 검사하는 역할을 가졌다.

const ab: AB = Math.random() < 0.5 ? 'A' : 'B'
const back: AB = 1; // '1' 형식은 'AB' 형식에 할당할 수 없습니다.
interface Identified {
     id: string;
}

// 어떤 객체가 string으로 할당 가능한 id 속성을 가지고 있다면, 
// 그 객체는 Identified이다.

구조적 타이핑와 잉여 속성 체크

구조적 타이핑 규칙을 따른다면, 어떤 값이 다른 속성도 가질 수 있음을 의미한다. 심지어 함수의 매개변수에서도 다른 속성을 가질 수 있다.
잉여 속성 체크 특정 상황에서 추가 속성을 허용하지 않는 타입 체크이다.

& 연산자의 인터섹션(intersection, 교집합)

interface Person {
  name: string;
}

interface Lifespan {
  birth: Date;
  death?: Date;
}

type PersonSpan = Person & Lifespan;

언뜻 보기에 PersonLifespan interface는 공통으로 가지는 속성이 없기 때문에, PersonSpan 타입을 never 타입으로 예상하기 쉽다.

그러나 타입 연산자는 인터페이스의 속성이 아닌, 값의 집합(타입의 범위)에 적용된다.

그리고 추가적인 속성을 가지는 값도 여전히 그 타입에 속한다.
그래서 PersonLifespan을 둘 다 가지는 값은 인터섹션 타입에 속하게 된다.

const ps: PersonSpan = {
    name: 'Alan Turing',
    birth: new Date('1912/06/23'),
    death: new Date('1954/06/07'),
} // 정상

앞의 세 가지보다 더 많은 속성을 가지는 값도 PersonSpan 타입에 속한다. 인터섹션 타입의 값은 각 타입 내의 속성을 모두 포함하는 것이 일반적인 규칙이다.

규칙이 속성에 대한 인터섹션에 관해서는 맞지만, 두 인터페이스의 유니온에서는 그렇지 않다.

type K = keyof (Person | Lifespan); //타입이 never

앞의 유니온 타입에 속하는 값은 어떠한 키도 없기 때문에,
유니온에 대한 keyof는 공집합(never)이어야만 한다.

서브타입 extends 키워드

  • 서브타입은 어떤 집합의 부분집합이라는 뜻이다.
  • 인터페이스의 재활용성을 높이기 위해 확장 기능을 사용한다.
  • 확장시 대상이 된 인터페이스의 속성을 모두 사용할 수 있다.
  • 상속받은 값을 또 상속 가능하다.

제너릭 타입에서 한정자로도 쓰인다.

제네릭 타입에서 extends

function getKey<K extends string>(val: any, ket: K) {
    // ...
}

K extends string에서 Kstring부분 집합 범위를 가지는 어떤 타입이 된다.

keyof T

keyof는 객체의 키타입을 반환한다.
즉 객체의 키값의 집합이다.

interface Point{
  x: number;
  y: number;
}

type PointKey = keyof Point; // "x" | "y"

타입들이 엄격한 상속 관계가 아닐 때는 집합 스타일이 더욱 바람직하다.

타입이 집합이라는 관점은 배열과 튜플의 관계 역시 명확하게 만든다.

tuple

튜플은 배열의 길이가 고정되고 각 요소의 타입이 지정되이 있는 배열 형식이다.

const arr: [string, number] = ["string", 10];
// 배열의 길이 및 타입이 고정된다.
// 정의 되지 않은 타입, 인덱스로 접근시 오류.

아래 코드에서 숫자 배열을 숫자들의 쌍(pair)이라고 할 수는 없습니다. number[]는 [number, number]의 부분 집합이 아니기 때문에 할당할 수 없다.

const list = [1, 2]; //타입은 number[]
const tuple: [number, number] = list;
//~~ 'number[]'타입은 '[number, number] 타입의 0,1 속성에 없습니다.'

// 반면 그 반대로 할당하면 동작한다.
const tuple2: [number, number] = [1, 2]
const list2: number[] = tuple2

[ Item 8 ] 타입은 값들의 집합이라 생각하기

타입스크립트의 심벌(symbol)은 타입 공간이나 값 공간 중의 한 곳에 존재한다. 심벌은 이름이 같더라도 속하는 공간에 따라 다른 것을 나타낼 수 있다.

interface Props { // 타입
  age: number;
}

const Props = 1; // 변수

타입 Props와 값 Props는 서로 아무런 관련이 없다.

type vs const

모든 값은 타입을 가지지만, 타입은 값을 가지지 않는다.
type과 interface 같은 키워드는 타입 공간에만 존재한다.

type T1 = 'string literal';
const v1 = 'string literal';

위에서 type 으로 선언한 T1 은 타입, const로 선언한 v1 는 값이다.
타입은 컴파일 하는 과정에서 제거된다.


[ Item 9 ] 타입 단언보다는 타입 선언을 사용하기

타입스크립트에서 변수에 값을 할당하고 타입을 부여하는 방법은 두 가지이다.

interface Person {name: string};

const alice:Person = {name: 'Alice'};
const bob = {name: 'Bob'} as Person;
  • alice:Person → 변수에 '타입 선언'을 붙여서 그 값이 선언된 타입임을 명시
  • as Person → '타입 단언'을 수행. 타입스크립트가 추론한 타입이 있더라도 Person 타입으로 간주한다.

타입 단언이 꼭 필요한 경우가 아니라면, 안전성 체크도 되는 타입 선언을 사용하는 것이 좋다

화살표 함수 타입 선언

화살표 함수로 추론된 타입이 모호한 경우를 대응하기 위해 타입 선언을 해주어야 한다.

타입 단언을 사용하는 경우엔 런타임에서 문제가 발생할 수 있으므로 지양한다.

const people = ['alice','bob','jan'].map(name =>({} as Person));

단언문을 쓰지 않고, 다음과 같이 화살표 함수 안에서 타입과 함께 변수를 선언하는 것이 가장 직관적이다.

const people = ['alice','bob','jan'].map(name =>{
 const person: Person = {name};
 return Person
}); //타입은 Person[]

타입 단언이 필요한 경우

타입스크립트에서 추론하는 값보다 개발자가 해당 변수의 타입을 더 잘 알고있을때, 변수에 원하는 타입을 강제로 부여한다.

  • DOM 조작을 하는 경우
  • 단언문으로 null 이 아님을 단언

[ Item 10 ] 객체 래퍼 타입 피하기

  • 기본형 값에 메서드를 제공하기 위해 객체 레퍼 타입이 어떻게 쓰이는지 이해해야한다. 직접 사용하거나 인스턴스를 생성하는 것은 피해야한다.

  • 타입스크립트 객체 래퍼 타입(null과 undefined는 객체 래퍼가 없다)은 지양하고, 대신 기본형 타입을 사용해야 한다.

  • 타입스크립트는 이러한 원시 값과 래퍼 객체의 관계를 이해하고, 필요에 따라 자동으로 원시 값을 래퍼 객체로 감싸거나(Boxing), 래퍼 객체를 원시 값으로 풀어주는 기능(Unboxing)을 제공한다.

  • 예를 들어, JavaScript에서 문자열에 대해 메서드를 호출하면 내부적으로 래퍼 객체가 생성되어 메서드가 호출되고, 다시 원시 값으로 돌아온다.

  • TypeScript는 이러한 과정을 개발자에게 노출시키지 않고, 자동으로 처리하여 편리성을 제공한다.

  • 이렇게 TypeScript는 내부적으로 래퍼 객체를 사용하여 메서드를 호출하고, 개발자는 마치 원시 값이 직접 메서드를 가지고 있는 것처럼 사용할 수 있다.


[ Item 11 ] 잉여 속성 체크의 한계 인지하기

잉여 속성 체크

타입이 명시된 변수에 객체 리터럴을 할당하거나 함수에 매개변수로 전달할 때 해당 타입의 속성이 있는지 그리고 '그 외 속성은 없는지'확인한다.

  • 잉여 속성을 체크하는 경우 예시

❗️잉여 속성 체크는 오류를 찾는 효과적인 방법이지만,타입스크립트 타입 체커가 수행하는 일반적인 구조적 할당 가능성 체크와 역할이 다르다.

❗️할당의 개념을 정확히 알아야 잉여 속성 체크와 일반적인 구조 할당 가능성 체크를 구분할 수 있다.

❗️잉여 속성 체크에는 한계가 있다. 임시 변수를 도입하면 잉여 속성 체크를 건너뛸 수 있다는 점을 기억해야한다.

profile
코드도 짜고, 근육도 짜고

0개의 댓글