예제로 알아보는 TypeScript 유틸리티 타입

 

 

유틸리티 타입이 뭐지?

 

유틸리티 타입은 TypeScript 내장 타입으로, 기존 타입에서 새로운 타입을 생성하는 데 사용됩니다.

 

라이브러리를 만들 때 자주 사용하지만, 유틸리티 타입이 뭔지 모르는 분들도 Partial<Foo>Record<string, string> 같은 건 본 적이 있을 거예요.

 

어떤 종류가 있는지는 문서를 참고하면 되고, 오늘은 어떤 경우에 유용한지, 몇 가지 주요 유틸리티 타입을 분류해서 소개해 드릴게요.

TypeScript가 제공하는 타입: 만드는 것

Record<Keys, Type>

RecordKeys를 키로 하는 프로퍼티를 가지고, 그 프로퍼티 값의 타입은 Type인 타입을 생성합니다.

 

주로 데이터를 저장하는 객체의 타입을 정의할 때 사용합니다.

type FooRecord = Record<"prop1" | "prop2", string>;
// → { prop1: string, prop2: string }

 

위와 같은 경우에는 직접 정의하는 게 더 편할 수 있지만, 키 집합이 이미 정의되어 있는 경우 유용합니다.

interface BarData {
    prop1: string;
    prop2: number;
    prop3: Date;
}

type BarDataErrors = Record<keyof BarData, string>
//  → { prop1: string, prop2: string, prop3: string}

 

string처럼 넓은 타입을 지정할 수도 있습니다.

type StringRecord = Record<string, string>;

 

원래 Record

type Record<K extends keyof any, T> = {
    [P in K]: T;
};

 

처럼 간단하기 때문에 직접 작성해도 되지만, 의도를 명확히 표현할 수 있고 간결하게 작성할 수 있습니다.

 

TypeScript가 제공하는 타입: 주로 프로퍼티를 가공하는 데 사용하는 것

 

Pick<Type, Keys> / Omit<Type, Keys>

PickType의 프로퍼티 중 Keys에 해당하는 것만 추출하여 새로운 타입을 만듭니다.

 

OmitType의 프로퍼티 중 Keys에 해당하는 것을 제외한 나머지를 추출하여 새로운 타입을 만듭니다.

 

즉, 화이트리스트와 블랙리스트라고 생각하면 됩니다.

 

이러한 유틸리티 타입은 다양하게 사용되지만, 기존 함수의 래퍼를 만들 때 유용합니다.

 

예를 들어, Button이라는 React 컴포넌트가 size라는 prop을 받는다고 할 때, size를 "large"로 고정한 LargeButton이라는 컴포넌트를 만들고 싶은 경우를 생각해 보세요.

 

이때 LargeButton의 props 타입을 Button에서 size 프로퍼티를 제외한 것으로 만드는 것으로, Button과 동일하지만 size는 전달할 수 없다는 것을 명확히 할 수 있습니다.

type LargeButtonProps = Omit<ButtonProps, "size">;

function LargeButton(props: LargeButtonProps) {
    return <Button {...props} size="large" />
}

Extract<Type, Union> / Exclude<UnionType, ExcludedMembers>

 

ExtractType에서 Union에 할당 가능한 것을 추출하여 새로운 타입을 만들고, ExcludeUnionType에서 ExcludedMembers에 할당 가능한 것을 제외한 나머지를 추출하여 새로운 타입을 만듭니다.

 

이것은 discriminated union과 함께 사용합니다. 예를 들어, type: "success"인 경우는 정상, type: "error"인 경우는 에러인 Result 객체를 생각해 보세요.

interface ResultSuccess {
    type: "success";
    data: Record<string, string>;
}

interface ResultError {
    type: "error";
    errorMessage: string;
}

// 기존 라이브러리가 합쳐진 타입만 export되어 있거나
export type Result = ResultSuccess | ResultError;

 

이러한 경우, ExtractExclude를 사용하면 제한된 타입을 얻을 수 있습니다.

type SuccessType = Extract<Result, {type: "success"}>
// ResultSuccess
type NonSuccessType = Exclude<Result, {type: "success"}>
// ResultError

Partial<Type> / Required<Type>

 

Partial은 프로퍼티를 선택적으로 지정 가능하게 만들고, Required는 프로퍼티를 필수로 지정하게 만듭니다.

 

Partial은 예를 들어 프로퍼티를 부분적으로 받아서 객체를 업데이트하는 함수를 만들 때 사용할 수 있습니다.

function updateFoo(foo: Foo, fields: Partial<Foo>) {
    return {...foo, ...fields };
}

 

원래 모든 프로퍼티가 required인 것은 아니기 때문에, Partial과 비교했을 때 Required를 단독으로 사용하는 경우는 적지만, 다른 유틸리티 타입과 함께 사용할 수 있습니다.

 

예를 들어, Pick과 함께 사용하여 명확하게 사용할 수 있습니다.

interface Foo {
    name?: string;
    age?: number;
    description?: string;
}

type Bar = Required<Pick<Foo, "name" | "age">>;

NonNullable

 

NonNullableType에서 nullundefined를 제거합니다.

type NullableType = string | null;
type NonNullType = NonNullable<NullableType>;
// string

 

이것은 매우 간단합니다.

 

TypeScript가 제공하는 타입: 함수 관련

함수 타입에 대한 유틸리티 타입은 함수를 받아서 어떤 식으로든 변경하는 함수에 필수적이지만, 특정 함수가 이미 정의되어 있는 경우에도 다음과 같은 방법으로 사용할 수 있습니다.

 

ReturnType

ReturnType은 함수의 타입에서 반환 값의 타입을 얻습니다.

 

ReturnType은 함수는 알고 있지만 타입에 접근할 수 없거나 정의되지 않은 경우, 예를 들어 factory 같은 함수에서 명시적인 타입이 없는 경우, 해당 값을 중계하는 등의 작업을 위해 타입이 필요할 때 유용합니다.

function createFoo() {
    return {
        a: "abc",
        b: 123
    };
}

type CreateFooReturn = ReturnType<typeof createFoo>;
// { a: string, b: number }

Parameters

 

Parameters는 함수의 인수의 타입을 얻습니다.

 

래퍼 함수를 만들 때, 기존 함수의 타입을 그대로 유지하면서 인수를 추가하고 싶을 때 유용합니다.

function foo(a: string|number) {
    return a;
}

type FooParameters = Parameters<typeof foo>;
function wrappedFoo(a: FooParameters[0], b: string) {
    other(b);
    return foo(a);
}

 

Awaited

비동기 함수의 경우 return typePromise가 되지만, 그대로 사용하기는 까다롭습니다.

 

이럴 때 Awaited를 사용하면 Promise를 풀 수 있습니다.

interface Foo {
    a: string;
    b?: number;
}

export async function createFoo(): Promise<Foo> {
    return {
        a: "bar",
        b: 123
    };
}

///

type RequiredFoo = Required<Awaited<ReturnType<typeof createFoo>>>;
// {a: string, b: number}

직접 만들기

유틸리티 타입도 TypeScript로 정의된 타입일 뿐이므로, 이러한 타입을 직접 만들 수도 있습니다.

 

예를 들어, 부분적으로 선택적으로 지정 가능하게 만드는 PartialPartially라는 유틸리티 타입을 만들면 다음과 같습니다.

// 이미 정의된 Foo
interface Foo {
    name: string;
    description: string;
    startAt: Date;
    finishedAt: Date;
}

type PartialPartially<T, K extends keyof T> = Partial<Pick<T, K>> & Pick<T, keyof Omit<T, K>>;
// 완료되지 않았을 수도 있는(finish되지 않았을 수도 있는) 타입을 만듭니다.
type FooMayUnfinished = PartialPartial<Foo, "finishedAt">;

 

이처럼 타입 조작 방법과 타입 조작 자체를 분리해 놓으면 나중에 봤을 때 훨씬 이해하기 쉽습니다.

마무리

유틸리티 타입은 사용하지 않는 것이 가장 좋지만, 어쩔 수 없는 경우에는 알고 있는지 여부에 따라 타입 처리의 난이도가 달라질 수 있습니다.

 

유틸리티 타입을 사용하여 타입 조작을 쉽게 해 보세요.