sungyup's.

typescript / Advanced Concepts / 2.1 Advanced Types

2.1Advanced Types

타입스크립트 심화 개념들-Type Guard, Discriminated Unions, instanceof, index types, as const, satisfies

추억의 쪽지 시험

Intersection Types

타입들을 &로 여러개 합친, 교집합 타입을 만들 수 있다.

typescript
type FileData = {
path: string;
content: string;
};
type Status = {
isOpen: boolean;
errorMessage?: string;
}
type AccessedFileData = FileData & Status;

이 방식은 이전 포스팅에서 살펴본 interface에서 extends를 활용한 문법과 거의 비슷한 효과다.

typescript
interface FileData {}; interface Status {}; interface AccessedFiledData extends FileData, Status;
대부분의 경우엔 비슷하지만, 충돌 속성이 있을 때 차이가 있다. A & B는 충돌 속성의 타입이 교집합으로 좁혀져 never가 된다. 즉, 구현 불가 타입이 된다. 반면, interface C extends A, B {}컴파일 오류로 거부하기 때문에 보다 즉각적으로 오류를 알아차릴 수 있게 된다.

Type Guard

함수에 전달될 수 있는 데이터가 여러 종류의 타입을 다 받을 수 있다면, 함수 안의 로직은 적절한 분기를 통해 각 타입에 대한 코드를 따로 작성해야 한다.

아래는 if 조건문을 활용해 해당 타입 내부의 속성을 점검하는 방식이다.

typescript
type FileSource = { path: string };
type DBSource = { connectionUrl: string };
type Source = FileSource | DBSource;
function loadData(source: Source){
if('path' in source){
// source.path;
return;
}
// source.connectionUrl;
}

Discriminated Unions

위 방식보다 더 자주 쓰이는 패턴은 타입을 구분할 수 있는 구분자 속성을 타입에 포함하는 것이다. 예를 들어, 기존엔 경로만 포함했던 위의 두 다른 Source 타입들에 대해 type이라는 속성을 추가하고, 이 속성에 해당 타입이 어떤 종류의 source인지를 표시하는 것이다. 이러면 똑같이 조건문을 쓰더라도 분기시 의미가 있는 단어를 통해 분기하므로 코드의 가독성이 더 좋아진다.

이런 패턴을 Discriminated Unions라고 부른다.

typescript
type FileSource = { type: 'file', path: string };
type DBSource = { type:'db',connectionUrl: string };
type Source = FileSource | DBSource;
function loadData(source: Source){
if(source.type === 'file'){
// source.path;
return;
}
// source.connectionUrl;
}

보다 안전하게 하기 위해선 switch문으로 각기 case들을 나눠 코딩할 수 있다. 또, default 케이스를 추가해 새로운 케이스가 들어올 시 오류로 잡을 수 있게 한다.

typescript
function loadData(source: Source){
switch(source.type){
case 'file':
return source.path;
case 'db':
return source.connectionUrl;
default: {
const _exhaustive: never = source; // 새 variant 추가 시 오류로 잡아줌
return _exhaustive;
}
}
}

Discriminated unions 방식은 '아웃소싱'이 가능하다. 즉, 타입을 체크하는 별도의 함수를 밖에서 정의한 후 활용하는 것이다.

typescript
function isFile(source: Source){ return source.type === 'file'; }

instanceof

instanceof는 확인하고자 하는 변수가 특정 클래스의 인스턴스인지 확인하는 키워드이다. 런타임에 실제 클래스(생성자)가 있어야만 동작하며, interfacetype에는 쓸 수 없다.

typescript
class User { constructor(public name: string){} join(){ // .... } }; class Admin{ scan(){} } type Entity = User | Admin; function init(entity: Entity){ if(entity instanceof User){ entity.join(); return; } entity.scan(); }

Function Overloads

만약 문자열 또는 배열을 받았을 때, 문자열이면 문자열의 단어 개수, 배열이면 배열의 요소 수를 반환하는 함수를 작성한다고 해보자. 앞서 배운 것처럼 아래와 같은 type guard를 해볼 수 있다.

typescript
function getLength(val: string | any[]){ if(typeof val === 'string'){ const numberOfWords = val.split(' ').length; return `${numberOfWords} words`; } return val.length; }

그런데, 이 방식으로는 문자열은 문자열을 반환, 배열은 숫자를 반환하기에 결과값을 활용하려면 또 분기가 필요해진다.

따라서 이 경우, 아래와 같은 function overload(함수 오버로딩)라고 불리는 기법을 통해 매개변수 별로 다른 타입을 반환하는 함수의 여러 버전을 정의할 수 있다. 함수 오버로딩은 시그니처라고 불리는 여러 개의 타입 지정과 구현부라고 불리는 1개의 실제 구현 코드로 이루어진다.

typescript
function getLength(val: any[]): number;
function getLength(val: string): string;
function getLength(val: string | unknown[]){
if(typeof val === 'string'){
const numberOfWords = val.split(' ').length;
return `${numberOfWords} words`;
}
return val.length;
}

Index Types

인덱스 타입이란, 해당 타입이 가질 수 있는 속성의 키의 타입값의 타입을 정의하는 것이다.

예를 들어, 아래의 DataStore 타입은 문자열로된 키와 숫자 또는 불리언이 값인 타입이다. 이렇게 타입을 지정하면 키와 값의 타입은 제한하면서도 유연한 객체를 만들 수 있다.

typescript
type DataStore = { [prop: string]: number | boolean; } let store: DataStore = {}; store.id = 5; store.isOpen = false;

참고로, 이런 케이스는 Record 타입으로도 동일하게 정의할 수 있다.

typescript
let store: Record<string, number | boolean> = {};

as const

as const는 타입스크립트에 타입 추론을 할 때 최대한 세부적으로 하라는 지시어다. 예를 들어, 문자열들의 배열을 변수에 할당하면 해당 변수의 타입은 자동으로 string[]이 된다. 하지만, 아래와 같이 as const 키워드를 붙이면 배열에 있는 항목들로만 타입을 제한한다. 이렇게 만든 변수는 **읽기 전용 튜플**이 된다.

typescript
const roles = ['admin', 'guest', 'editor'] as const; // roles: readonly ['admin', 'guest', 'editor'] (읽기 전용 튜플) // 읽기 전용 배열을 파라미터로 받으려면 function allow(r: readonly string[]) { /* ... */ } allow(roles); // OK

여기서 roles의 요소들로 이루어진 Role이라는 타입을 추출할 때, roles의 인덱스에 접근하라는 식으로 유니언 타입을 추출할 수 있는데, 이를 유니언 타입 추출이라고 하고 as const의 주요 활용처 중 하나이다.

typescript
const roles = ['admin', 'guest', 'editor'] as const; type Role = typeof roles[number]; // 'admin' | 'guest' | 'editor'

satisfies

아래와 같은 dataEntries 레코드가 있다고 하자.

typescript
const dataEntries: Record<string, number> = { entry1: 0.51, entry2: -1.23 }

언뜻 타입을 잘 제한하고 entry들을 잘 저장한것 같지만, 이렇게 레코드를 만들 경우 없는 속성도 <문자열, 숫자> 조합으로 접근 가능해진다.

typescript
dataEntries.entry3; // ??

만약 타입 지정(Record<string, number>)를 지운다면 타입스크립트가 객체를 추론하지만, 이 경우 내부 데이터에 대한 타입 제한이 되지 않는다. 이럴때 satisfies 키워드를 아래와 같이 활용한다.

typescript
const dataEntries = {
entry1: 0.51,
entry2: -1.23
} satisfies Record<string, number>;
dataEntries.entry3; // 존재하지 않는 속성