원문 : https://dev.to/zenstack/11-tips-that-help-you-become-a-better-typescript-programmer-4ca1
타입스크립트를 배우는 것은 종종 재발견의 여정입니다. 여러분들의 타입스크립트에 대한 첫인상은 꽤 미심쩍을 수 있습니다. 타입스크립트는 단순히 컴파일러가 잠재적인 버그를 찾는 것을 돕기 위해 자바스크립트에 주석을 다는 방법 아닌가요?
비록 이 말이 일반적으로 맞지만, 계속 알아가다 보면 타입스크립트의 가장 놀라운 힘은 타입을 구성, 추론, 조작하는 데에 있다는 것을 알게 될 것입니다.
이 글에서는 언어를 최대한 활용하는 데 도움이 되는 몇가지 팁을 요약합니다.
타입은 프로그래머들에게는 일상적인 개념이지만, 그것을 간결하게 정의하는 것은 놀라울 정도로 어렵습니다. 대신 집합을 개념 모델로 사용하는 것이 도움이 됩니다.
예를 들어, 새롭게 배우는 사람들은 타입스크립트의 타입 작성 방식이 직관적이지 않다고 생각합니다. 매우 간단한 예시를 보겠습니다.
type Measure = { radius: number };
type Style = { color: string };
// typed { radius: number; color: string }
type Circle = Measure & Style;
논리적 AND의 의미로 연산자 &
를 해석하면, Circle
은 겹치는 필드가 없는 두 가지 타입의 결합인 더미 타입이라고 예상할 수 있습니다. 하지만 타입스크립트는 논리적 의미로 동작하는 방식이 아닙니다. 대신에 타입을 집합이라고 생각하는 것이 동작을 이해하기 쉬울 것입니다.
unknown
은 모든 값을 포함하는 범용 집합이고, never
는 아무 값을 포함하지 않는 빈 집합입니다.Measure
타입은 radius
이라는 숫자 필드를 포함하는 모든 객체의 집합니다. Style
도 마찬가지입니다.Measure & Style
은 radius
와 color
필드를 모두 포함하는 객체 집합을 나타냅니다. 이것은 사실상 더 작은 집합이지만, 더 공통적으로 사용할 수 있는 필드를 포함합니다.|
연산자는 더 큰 집합을 만들지만 잠재적으로 공통적으로 사용 가능한 필드가 더 적습니다. (두 객체 타입이 합쳐져 있다면).집합은 또한 할당 가능성을 이해하는 데 도움이 됩니다. 할당은 값의 타입이 대상 타입의 하위 집합인 경우에만 허용됩니다.
type ShapeKind = 'rect' | 'circle';
let foo: string = getSomeString();
let shape: ShapeKind = 'rect';
// string은 ShapeKind의 하위 집합이 아니므로 허용되지 않습니다.
shape = foo;
// ShapeKind는 string의 하위 집합이므로 허용됩니다.
foo = shape;
다음 글에서는 타입을 집합으로 생각하는 것에 대한 훌륭한 설명을 제공합니다.
타입스크립트의 매우 강력한 특징 중 하나는 제어 흐름에 따라 자동으로 타입을 좁히는 것입니다. 이것은 변수가 코드 위치의 특정 지점에서 연관된 두 가지 타입, 즉 선언 타입과 좁혀진 타입을 가짐을 의미합니다.
function foo(x: string | number) {
if (typeof x === 'string') {
// x'의 타입은 string타입으로 좁혀졌습니다. 따라서 .length가 가능합니다.
console.log(x.length);
// 할당을 하게되면 좁혀진 타입이 아닌 선언한 타입이 됩니다.
x = 1;
console.log(x.length); // x는 지금 number 타입이므로 불가능합니다.
} else {
...
}
}
Shape와 같은 다형성 타입 집합을 정의할 때에는 다음과 같이 쉽게 시작할 수 있습니다.
type Shape = {
kind: 'circle' | 'rect';
radius?: number;
width?: number;
height?: number;
}
function getArea(shape: Shape) {
return shape.kind === 'circle' ?
Math.PI * shape.radius! ** 2
: shape.width! * shape.height!;
}
radius
, width
, height
필드에 접근할 때 null이 아니라는 단언(assertion)이 필요합니다. 왜냐하면, kind
와 다른 필드들 사이에 확립된 관계가 없기 때문입니다. 이때는 대신에 유니온으로 구분하는 것이 더 좋은 방법입니다.
type Circle = { kind: 'circle'; radius: number };
type Rect = { kind: 'rect'; width: number; height: number };
type Shape = Circle | Rect;
function getArea(shape: Shape) {
return shape.kind === 'circle' ?
Math.PI * shape.radius ** 2
: shape.width * shape.height;
}
타입을 좁혔기 때문에 강제할 필요가 없어졌습니다.
올바른 방식으로 타입스크립트를 사용한다면, 명시적 타입 단언(value as SomeType
과 같은)을 사용하는 경우는 거의 없을 것입니다. 하지만 가끔 다음과 같은 충동을 느낄 수 있습니다.
type Circle = { kind: 'circle'; radius: number };
type Rect = { kind: 'rect'; width: number; height: number };
type Shape = Circle | Rect;
function isCircle(shape: Shape) {
return shape.kind === 'circle';
}
function isRect(shape: Shape) {
return shape.kind === 'rect';
}
const myShapes: Shape[] = getShapes();
// 타입스크립트가 필터링 된 것을 모르기 때문에 에러가 발생합니다.
// 타입 좁히기
const circles: Circle[] = myShapes.filter(isCircle);
// 다음과 같은 단언을 추가하고 싶을 수 있습니다.
// const circles = myShapes.filter(isCircle) as Circle[];
보다 우아한 해결책은 isCircle
과 isRect
를 타입 명제를 반환하도록 변경하여 filter
호출 후 타입스크립트가 타입을 좁힐 수 있도록 도와주는 것입니다.
function isCircle(shape: Shape): shape is Circle {
return shape.kind === 'circle';
}
function isRect(shape: Shape): shape is Rect {
return shape.kind === 'rect';
}
...
// 이제 Circle[] 타입을 올바르게 유추합니다.
const circles = myShapes.filter(isCircle);
타입 추론은 타입스크립트의 본능입니다. 대부분의 경우 자동으로 작동합니다. 하지만, 애매한 경우에는 여러분들이 개입해야할 수 있습니다. 분포 조건 타입은 이 경우 중 하나입니다.
만약 인풋 타입이 아직 배열이 아닌 경우 배열 유형을 반환하는 ToArray
헬퍼 타입이 있다고 가정해봅시다.
type ToArray<T> = T extends Array<unknown> ? T: T[];
다음 타입이 어떻게 추론될까요?
type Foo = ToArray<string|number>;
정답은 string[] | number[]
입니다. 그러나 애매합니다. 대신 (string | number)[]
는 어떨까요?
기본적으로, 타입스크립트에서 제네릭 매개변수(여기서는 T
)가 유니온 타입(여기서는 string | number
)을 만나면 각 구성요소로 분배되므로 string[] | number[]
로 표시됩니다. 이 동작은 특수 구문을 사용하여 다음과 같이 T
를 한 쌍의 []
로 감싸면 변경될 수 있습니다.
type ToArray<T> = [T] extends [Array<unknown>] ? T : T[];
type Foo = ToArray<string | number>;
이제 Foo
는 (string | number)[]
타입으로 추론됩니다.
열거형(enum)을 스위치-케이스로 활용할 때, 다른 프로그래밍 언어에서처럼 예상치 못한 경우를 무시하지 않고 적극적으로 오류를 처리하는 것이 좋습니다.
function getArea(shape: Shape) {
switch (shape.kind) {
case 'circle':
return Math.PI * shape.radius ** 2;
case 'rect':
return shape.width * shape.height;
default:
throw new Error('Unknown shape kind');
}
}
타입스크립트에서 never
타입을 사용하면 정적 타입 검사시에 오류를 조기에 발견할 수 있습니다.
function getArea(shape: Shape) {
switch (shape.kind) {
case 'circle':
return Math.PI * shape.radius ** 2;
case 'rect':
return shape.width * shape.height;
default:
// 아래에서 타입 확인 오류가 발생합니다.
// 만약 shape.kind가 위에서 처리되지 않았다면.
const _exhaustiveCheck: never = shape;
throw new Error('Unknown shape kind');
}
}
이를 통해 새로운 shape에 kind를 추가할 때 getArea
기능을 업데이트 하는 것을 잊지 않을 수 있습니다.
이 기술의 근거는 never
형은 never
타입 이외에는 할당할 수 없다는 것입니다. shape.kind
의 모든 후보가 케이스 상태에 의해 소진되면 default
에 도달할 수 있는 타입은 없습니다. 하지만 어떤 후보가 커버되지 않은 경우 default
지점으로 유출되어 잘못된 할당이 발생합니다.
interface
보다 type
을 사용타입스크립트에서 type
과 interface
는 객체를 선언할 때 매우 유사한 구조입니다. 논란의 여지가 있지만 대부분의 경우 일관되게 type
을 사용하고 다음 중 하나에 해당하는 경우에만 interface
를 사용하는 것이 좋습니다.
interface
의 "merging" 기능을 활용하고 싶을 때.위의 경우가 아니면, 항상 더 다용도적인 type
을 사용하면 코드가 더 일관성 있게 됩니다.
객체 타입은 구조화된 데이터를 선언하는 일반적인 방법이지만, 가끔 여러분이 간결한 표현을 원하면 대신 단순 배열을 사용할 수도 있습니다. 예를 들어, Circle
은 다음과 같이 정의될 수 있습니다.
type Circle = (string | number)[];
const circle: Circle = ['circle', 1.0]; // [kind, radius]
하지만 이 선언은 불필요하게 느슨하고 ['circle', '1.0']
등을 만들어 쉽게 오류를 만들 수 있습니다. 대신 튜플을 사용하여 보다 엄격하게 만들 수 있습니다.
type Circle = [string, number];
// 아래에서 오류가 발생합니다.
const circle: Circle = ['circle', '1.0'];
React의 useState
는 튜플의 좋은 사용 예시입니다.
const [name, setName] = useState('');
간결하면서 안전한 타입입니다.
타입스크립트는 타입 추론을 만들 때 합리적인 기본 동작을 사용하며, 이는 일반적인 경우에 코드를 쉽게 작성할 수 있도록 돕는 것을 목표로 합니다(타입에 명시적인 주석을 달 필요가 없음). 이 기본 동작을 조정할 수 있는 몇 가지 방법이 있습니다.
const
를 사용합니다.let foo = { name: 'foo' }; // typed: { name: string }
let Bar = { name: 'bar' } as const; // typed: { name: 'bar' }
let a = [1, 2]; // typed: number[]
let b = [1, 2] as const; // typed: [1, 2]
// typed { kind: 'circle; radius: number }
let circle = { kind: 'circle' as const, radius: 1.0 };
// circle이 const 키워드를 사용해 초기화 되지 않았다면
// 다음이 동작하지 않습니다.
let shape: { kind: 'circle' | 'rect' } = circle;
satisfies
를 사용합니다.type NamedCircle = {
radius: number;
name?: string;
};
const circle: NamedCircle = { radius: 1.0, name: 'yeah' };
// circle.name는 undefined일 수도 있기 때문에 오류가 발생했습니다.
console.log(circle.name.length);
변수에 문자 값을 제공했음에도 불구하고 circle
의 선언 타입인 NamedCircle
에 따르면 name
필드는 undefined
일수도 있기 때문에 오류가 발생합니다. 물론 우리는 :NameCircle
타입 주석을 삭제할 수 있지만 circle
객체의 유효성을 검사하지 못합니다. 정말 딜레마입니다.
다행히도 Typescript 4.9는 추론된 타입을 변경하지 않고 타입을 확인할 수 있는 새로운 satisfies
키워드를 도입했습니다.
type NamedCircle = {
radius: number;
name?: string;
};
// radius가 NamedCircle을 위반하여 오류가 발생합니다.
const wrongCircle = { radius: '1.0', name: 'ha' }
satisfies NamedCircle;
const circle = { radius: 1.0, name: 'yeah' }
satisfies NamedCircle;
// circle.name는 undefined가 될 수 없습니다.
console.log(circle.name.length);
수정된 버전은 객체 리터럴이 NamedCircle
타입과 일치하도록 보장되며 추론된 타입에는 null이 불가능한 name
필드가 있습니다.
infer
를 사용유틸리티 함수 및 타입을 설계할 때는 지정된 타입 매개 변수에서 추출된 타입을 사용해야할 필요성을 느끼는 경우가 많습니다. 이런 상황에서 infer
키워드는 유용합니다. 새로운 타입 매개변수를 즉시 추론할 수 있도록 도와줍니다. 다음은 두 가지 간단한 예시입니다.
// Promise에서 포장되지 않은 타입을 가져옵니다.
// T가 Promise가 아니라면 결과는 달라지지 않습니다.
type ResolvedPromise<T> = T extends Promise<infer U> ? U : T;
type t = ResolvedPromise<Promise<string>>; // t: string
// 배열 T의 평탄화된 타입을 가져옵니다.
// T가 배열이 아니라면 결과는 달라지지 않습니다.
type Flatten<T> = T extends Array<infer E> ? Flatten<E> : T;
type e = Flatten<number[][]>; // e: number
T extends Promise<infer U>
에서 infer
키워드가 작동하는 방법은 다음과 같이 이해할 수 있습니다. T가 일부 인스턴스화된 일반 Promise 타입과 호환된다고 가정하면 타입 매개 변수 U를 임시로 적용하여 작동합니다. 따라서 T
가 Promise<string>
으로 인스턴스화 되면 U
의 값은 string
이 됩니다.
타입스크립트는 코드 중복을 최소화 하는 데 도움이 되는 강력한 타입 조작 구문과 매우 유용한 유틸리티 도구를 제공합니다. 다음은 몇 가지 사례입니다.
type User = {
age: number;
gender: string;
country: string;
city: string
};
type Demographic = { age: number: gender: string; };
type Geo = { country: string; city: string; };
, Pick
유틸리티를 사용하여 새로운 타입을 추출합니다.
type User = {
age: number;
gender: string;
country: string;
city: string
};
type Demographic = Pick<User, 'age'|'gender'>;
type Geo = Pick<User, 'country'|'city'>;
function createCircle() {
return {
kind: 'circle' as const,
radius: 1.0
}
}
function transformCircle(circle: { kind: 'circle'; radius: number }) {
...
}
transformCircle(createCircle());
, ReturnType<T>
을 사용하여 타입을 추출합니다.
function createCircle() {
return {
kind: 'circle' as const,
radius: 1.0
}
}
function transformCircle(circle: ReturnType<typeof createCircle>) {
...
}
transformCircle(createCircle());
type ContentTypes = 'news' | 'blog' | 'video';
// 사용할 수 있는 콘텐츠 타입을 표시하기 위한 config입니다.
const config = { news: true, blog: true, video: false }
satisfies Record<ContentTypes, boolean>;
// 콘텐츠를 생성하는 factory 입니다.
type Factory = {
createNews: () => Content;
createBlog: () => Content;
};
, Mapped Type과 Template Literal Type을 사용하여 config의 모양을 기반으로 적절한 factory 타입을 자동으로 유추합니다.
type ContentTypes = 'news' | 'blog' | 'video';
// 메서드 목록이 추출된 제네릭 factory 타입입니다.
// 주어진 Config의 모양을 기반으로 합니다.
type ContentFactory<Config extends Record<ContentTypes, boolean>> = {
[k in string & keyof Config as Config[k] extends true ? `create${Capitalize<k>}` : never]: () => Content;
};
// 사용할 수 있는 콘텐츠의 타입을 표시하기 위한 config입니다.
const config = { news: true, blog: true, video: false }
satisfies Record<ContentTypes, boolean>;
type Factory = ContentFactory<typeof config>;
// Factory: {
// createNews: () => Content;
// createBlog: () => Content;
// }
여러분들의 상상력을 사용하면 탐험할 수 있는 무한한 잠재력을 발견할 수 있습니다.
이 글은 타입스크립트 언어로 된 비교적 고급 주제들을 다루었습니다. 실제로는 직접 적용하는 것이 일반적이지 않을 수도 있습니다. 그러나 이러한 기술은 Prisma, tRPC와 같은 타입스크립트 용으로 특별히 설계된 라이브러리에서 많이 사용됩니다. 요령을 알면 이러한 도구가 어떻게 동작하는지 더 잘 알 수 있습니다.
P.S. 우리는 Next.js + 타입스크립트를 사용하여 안전한 CRUD 애플리케이션을 구축하기 위한 툴킷인 ZenStack을 개발하고 있습니다. 우리의 목표는 여러분들이 상용 코드를 작성하는 시간을 절약하고 중요한 사용자 경험을 개발할 수 있는 데 집중할 수 있도록 하는 것입니다.
Can you please share us an image depicting the issue? https://www.emorypatient-portal.com/