타입스크립트 몰랐던 것 (1) :: 마이구미
이 글은 타입스크립트 예제 중심으로 다뤄지고 동작 이해를 위해 정리한 글이다.
타입스크립트가 무엇인지? 왜 사용하는지? 에 대한 글은 아니다.
"이펙티브 타입스크립트" 를 통해 알게되었거나 스스로 조금 더 다듬어놓기 위한 것들이다.
이펙티브 타입스크립트 - http://www.yes24.com/Product/Goods/102124327
타입스크립트를 사용한 시점부터 깊게 공부하지 않고 기본적인 개념만 가지고 사용했다.
그 이유는 단순 타입 시스템 목적으로 그때그때 습득하면서 사용해도 크게 문제될 것이 없다고 판단했다.
그 과정중 느꼈던 건, 다른 라이브러리들의 코드 분석이 필요할 때, 생각한대로 타입 선언과 추정이 되지 않을 때 조금 난감했다.
그럴 경우에는 any 를 쓰거나 특정 부분은 타입스크립트를 비활성화 시켜버리곤 했다.
complicatedFn(params: any) { ... }
/* eslint-disable-next-line */
/* eslint-disable */
미루고 미루다가 타입스크립트 관련 책을 보게 되었다.
역시나 이 책을 읽으면서 더 혼란을 느끼는 부분이 종종 발생했다.
이것이 의미하는 것은 타입스크립트를 정확히 이해하고 있지 않고 사용했다는 것이 증명되었다.
이번에 새로 알게되거나 조금 더 이해하고 있어야하는 것들을 정리해보았다.
TS Playgroud 와 함께 확인해보면 좋다. (https://www.typescriptlang.org/play)
자세한 내용은 더 많은 내용이 담겨져있는 책을 읽어보길 추천한다.
제네릭 타입에서 매개변수에 extends 를 사용하는 것은 매개변수에 특정 타입을 확장한다고 선언할 수 있다.
function getKey1<K extends string>(val: any, key: K) {
return key;
}
function getKey2(val: any, key: string) { return key }
getKey1({}, Math.random() < 0.5 ? 'a': 'b'); // 반환 타입 'a' | 'b'
getKey2({}, Math.random() < 0.5 ? 'a': 'b'); // 반환 타입 string
getKey1 의 매개변수 제네릭 타입에 string 을 확장함으로써, 반환 타입은 string 이 아닌 더 직관적으로 'a' | 'b' 로 확인할 수 있다.
typeof 는 타입스크립트, 자바스크립트에 따라 사용 목적이 다를 수 있다.
// javascript typeof
if (typeof fn === 'fucntion') {}
// typescirpt typeof
const person = { name: 'mygumi', gender: 'male' };
type Person = typeof person;
const p: Person = { name: '', gender: '' }
타입의 관점에서 타입스크립트의 타입을 반환한다.
값의 관점에서 런타임의 typeof 연산자가 반환한다.
객체 래퍼 타입을 지양하고 기본형 타입을 지향해야한다.
우리는 자바스크립트에서 원시값 형태의 문자열 리터럴인 "string" 과 객체 래퍼 형태의 "String" 은 다르다는 것을 알고 있다.
참고로 string 타입에서 String 에서 제공해주는 메소드들을 사용할 수 있는 이유는 문자열 리터럴(string)에서 메소드 사용 시 객체 타입 String 으로 래핑하여 메소드 호출 후 래핑한 객체 버리는 과정이 진행되기 때문이다.
결론은 타입스크립트에서도 잘 인지해서 타입 선언을 해야한다.
function isGreeting(phrase: String) {
return [
'hello',
].includes(phrase) // TypeError
}
객체 리터럴은 잉여 속성 체크를 수행한다.
타입스크립트의 타입 시스템은 덕 타이핑(구조적 타이핑)을 따른다.
덕 타이핑 - https://mygumi.tistory.com/367
interface Room {
numDoors: number;
ceilingHeightFt: number;
}
const hotel = {
numDoors: 1,
ceilingHeightFt: 10,
elephant: 'present'
}
const r: Room = hotel;
Room 인터페이스에 elephant 가 없어도 hotel 객체는 타입 에러 없이 타입 체크를 통과한다.
객체 리터럴 방식의 코드라면 잉여 속성 체크를 통해 elephant 타입 에러가 발생한다.
const hotel: Room = {
numDoors: 1,
ceilingHeightFt: 10,
elephant: 'present' // TypeError
}
함수 선언식보다는 함수 표현식에 타입 적용하기
declare function fetch(input: RequestInfo, init?: RequestInit): Promise<Response>
// 함수 선언식 => 전달 인자에 타입 설정
async function checkedFetch(input: RequestInfo, init?: RequestInit) { ... }
// 함수 표현식 => 전달 인자에 타입 설정없이 타입 추론 가능
const checkedFetch: typeof fetch = async (input, init) => {
const response = await fetch(input, init);
if (!response.ok) {
throw new Error()
}
return response;
}
함수 표현식은 전체 타입에 typeof fetch 를 명시해주면 Input, init 의 타입을 추론할 수 있게 해준다.
위와 같은 상황을 보면 매개변수나 반환 값에 타입을 명시하기보다는 함수 표현식 전체에 타입 구문을 적용하는 것이 좋아보인다.
type, interface 의 차이
타입스크립트에서는 type 과 interface 키워드를 통해 타입을 정의할 수 있다.
일반적인 경우에서는 별 차이없이 정의되고 동작되기에 두 개의 차이를 이해하지 않고도 사용할 수 있다.
하지만 interface 는 유니온 타입의 확장이 불가능하다.
interface Input { input:number }
interface Output { output: number }
type NamedVariable = (Input | Output) & { name: string }
interface NamedVariable = ... // 표현 불가능
const v: NamedVariable = { input: 1, name: '' };
결국 복잡한 타입, 고급 기능을 위해서는 type 을 사용할 수밖에 없다.
튜플의 경우에도 interface 를 통해 선언하기 위해서는 부자연스러운 모습을 볼 수 있다.
type Pair = [number, number];
interface Tuple {
0: number;
1: number;
length: 2
}
하지만 interface 는 유용한 보강 기법이 존재한다.
interface IState {
name: string;
capital: string;
}
interface IState {
population: number;
}
const s: IState = {
name: 'Korea',
capital: 'Seoul',
population: 50000000
}
타입을 명시하지 않으면, 타입 체커는 타입을 유추하여 결정해야한다.
타입이 명시되지 않았을 경우, 타입 체커가 타입을 유추하는 과정을 "widening" 이라고 한다.
interface Vector3D {x: number, y: number, z: number;}
function getComponent(vector: Vector3D, axis: 'x' | 'y' | 'z') { return vector[axis] }
const x1 = 'x'; // 반환 타입 'x'
let x2 = 'x'; // 반환 타입 string
let x3 = 'x' as const; // 반환 타입 'x'
let obj1 = { x: 'x' } // 반환 타입 { x: string }
let obj2 = { x: 'x' as const } // 반환 타입 { x: 'x' }
const vec = { x: 10, y: 20, z: 30 };
getComponent(vec, x1);
getComponent(vec, x2); // TypeError
getComponent(vec, x3);
getComponent(vec, obj1.x); // TypeError
getComponent(vec, obj2.x);
const 활용하면 더 좁은 타입이 된다. (let 변수에도 as const 를 선언해줄 수 있다.)
객체의 경우 각 요소들은 let 으로 할당된 것처럼 다뤄지기 때문에, as const 를 통해 타입을 강제 추론하게 해야한다.
유니온의 인터페이스보다는 인터페이스의 유니온을 사용하기
유니온의 인터페이스의 예를 보자.
interface Layer {
layout: FillLayout | LineLayout | PointLayout
paint: FillPaint | LinePaint | PointPaint
}
위의 문제점은 { FillLayout, FillPaint }, { LineLayout, LinePaint }, { Paintayout, PointPaint } 와 같이 1:1 매칭이 되어야한다.
하지만 위의 인터페이스 상에는 { FillLayout, LinePaint } 와 같은 형태로 매칭될 가능성이 존재한다.
이를 위해 인터페이스의 유니온 + 태그된 유니온(tagged union) 을 활용한 예제이다.
interface FillLayer {
type: 'fill';
layout: FillLayout;
paint: FillPaint
}
interface LineLayer {
type: 'line';
layout: LineLayout;
paint: LinePaint
}
interface PointLayer {
type: 'point';
layout: PointLayout;
paint: PointPaint
}
type Layer = FillLayer | LineLayer | PointLayer
function drawLayer(layer: Layer) {
if (layer.type === 'fill') {
const { layout, paint } = layer; // FillLayer 타입 추론
} else if (layer.type === 'line') {
const { layout, paint } = layer; // LineLayer 타입 추론
} else {
const { layout, paint } = layer; // PointLayer 타입 추론
}
}
any 타입은 가능한 한 좁은 범위에서만 사용하기
any 타입을 지양해야하겠지만 사용해야한다면 다른 곳에 영향이 없어야한다.
interface Foo { foo: any }
interface Bar { bar: any }
function expressionReturingFoo (): Foo { return { foo: '' } }
function processBar (value: Bar) { return {} }
function fn() {
const x = expressionReturingFoo();
processBar(x); // TypeError
return x;
}
x 값은 expressionReturingFoo 에 의해 Foo 타입이다.
하지만 processBar 의 전달인자는 Bar 로 전달해야하기 때문에 타입 에러가 발생한다.
이것을 해결하기 위해 x 의 타입을 any 로 선언해보자.
function fn() {
const x: any = expressionReturingFoo();
processBar(x);
return x;
}
fn(); // 반환 타입 any
타입 에러는 피했지만, fn() 반환 타입이 any 로 설정되었다.
x 에 대한 타입은 processBar() 를 호출할 시점에만 변환하고 다른 곳에서는 영향이 가지 않도록 설정할 수 있다.
function fn() {
const x = expressionReturingFoo();
processBar(x as any);
return x;
}
fn(); // 반환 타입 Foo
any 타입의 진화 이해하기
noImplicitAny = true 설정 시에는 암시적 any 상태인 변수에 어떠한 할당도 하지 않고 사용하려고 하면 암시적 any 오류 발생한다.
function range(start: number, limit: number) {
const out = []; // TypeError
return out; // TypeError
}
암시적 any 타입에 어떠한 값을 할당할 때 any 타입의 진화가 발생한다.
function range(start: number, limit: number) {
const out = [];
for (let i = start; i < limit; i++) {
out.push(i);
}
return out; // 타입 추론 number[]
}
out 변수는 암시적으로 any[] 타입을 가지고 있지만, 루프를 통해 number[] 타입으로 진화하게 되었다.
다만, 아래 예제처럼 함수 호출을 거치는 것은 진화가 일어나지 않는다.
function range(start: number, limit: number) {
const out = []; // TypeError
[1,2,3].forEach((v => { out.push(v) }));
return out; // TypeError
}
any 대신 unknown 사용하기
any 타입의 특징은 다음과 같다.
- 어떠한 타입이든 any 타입에 할당 가능하다.
- any 타입은 어떠한 타입으로도 할당 가능하다.
unknown 타입의 경우 1번은 만족하지만, 2번은 만족하지 못한다.
let unknownValue: unknown = 123;
let anyValue: any = 123;
let newValue = true;
anyValue = '';
unknownValue = '';
newValue = anyValue;
newValue = unknownValue; // TypeError
제공되지 않는 타입 export 하기
외부 라이브러리를 사용할 경우에 대부분의 타입을 제공해주겠지만 그 중 특정 타입을 제공해주지 않을 수 있다.
본인은 그냥 무시하거나 직접 만들어서 사용하곤 했다.
단순하게 생각하니 그 타입을 직접 다른 뽑는 것도 방법일 수 있다.
interface SecretName {}
interface SecretSanta {}
export function getGift(name: SecretName, gift: string): SecretSanta {}
위의 예제처럼 SecretName, SecretSanta 인터페이스가 export 로 선언되어있지않아 외부 라이브러리의 타입을 사용할 수 없다.
이런 경우 export 할 수 있는 getGift 를 활용해서 뽑아낼 수 있다.
type MySanta = ReturnType<typeof getGift>;
type MyName = Parameters<typeof getGift>[0];
타입도 조건부 연산자를 활용할 수 있다.
function double(x: string | number): number | string;
function double(x: any) { return x + x; }
double('1'); // 반환 타입 number | string
위 예제의 경우 반환 값은 '11' 로써, 우리가 원하는 반환 타입은 string 이다.
하지만 number | string 으로 추론된다.
이를 대응하기 위해 타입에도 조건부 연산자를 활용할 수 있다.
function double<T extends number|string>(x: T): T extends string ? string : number;
function double(x: any) { return x + x };
double('1'); // 반환 타입 string
double(1); // 반환 타입 number
enum 보다는 문자열을 유니온 타입으로 사용하기
타입스크립트에서 enum 의 경우 import 하고 문자열 대신 사용해야한다.
enum Flavor {
Vanilla = 'vanilla',
Choclate = 'choclate',
Strawberry = 'strawberry'
}
function scoop(v: Flavor) {}
scoop('choclate'); // TypeError
scoop(Flavor.Choclate);
위 코드는 구조적 타이핑이 아닌 명목적 타이핑이 되어버린다.
유니온 타입으로 선언하면 타입 추론과 자동 완성이 동일하게 지원되기에 아래의 코드가 더 자연스러워보인다.
// 유니온 타입 1
type Flavor = 'vanilla' | 'choclate' | 'strawberry'
function scoop(v: Flavor) {}
scoop('choclate');
// 유니온 타입 2
const FlavorType = {
Vanilla: 'vanilla',
Choclate: 'choclate',
Strawberry: 'strawberry'
} as const
type Values<T> = T[keyof T];
type Flavor = Values<typeof FlavorType>
function scoop(v: Flavor) {}
scoop("choclate")
private 를 위한 기능을 3.8 버전부터 private field 를 제공해준다
아시다시피 타입스크립트는 런타임 시점이 아닌 컴파일 시점이다.
타입스크리븥에서 private 키워드를 제공해주더라도 런타임 시점에는 없어지기 때문에 원래의 목적인 private 목적을 이룰 수 없다.
3.8 버전부터 private field 라는 용어로 제공해주고 있다.
class Animal {
#name: string;
constructor(theName: string) {
this.#name = theName;
}
}
new Animal("Cat").#name; // #name 접근 불가 에러