[번역] 프로그래밍 언어로서의 TypeScript 타입

I
Inkyu Oh

Front-End2025.12.22

Thiery Michel - 2025년 12월 4일


TypeScript가 튜링 완전(Turing complete)하다는 사실을 알고 계셨나요? 이 포스트에서 저는 타입 정의를 하나의 프로그램을 작성하는 것처럼 접근해 보려 합니다. 목표는 TypeScript로 Doom을 구현하거나 수학 연산을 수행하는 것이 아닙니다. 그런 것들은 이미 누군가 해냈습니다:
이러한 시도들은 인상적이지만, "왜?"라는 의문을 갖게 합니다. 우리의 목표는 타입을 프로그램처럼 다룸으로써 타입을 더 잘 작성하는 능력을 갖추는 것입니다.

제네릭 타입(Generic Types): 함수

TypeScript에서는 다른 타입에 의존하는 타입을 정의할 수 있습니다. 이는 함수처럼 작동합니다. 즉, 주어진 입력 타입에 대해 출력 타입을 생성합니다.
// 가장 단순한 함수인 identity:
const identity = (value) => value;
// 는 다음과 같이 변합니다.
type Identity<Type> = Type;
// 그리고 이렇게 호출할 수 있습니다.
type Result = Identity<number>;
// type Result = number

// 조금 더 복잡한 함수
const createObject = (a, b) => ({ a, b });
// 는 다음과 같이 변합니다.
type CreateObject<A, B> = { a: A, b: B };
// 그리고 이렇게 호출할 수 있습니다.
type Result = CreateObject<string, boolean>;
// type Result = { a: string; b: boolean }

제네릭 타입 함수에 타입 제약 조건 추가하기

extends 키워드는 함수 인자에 타입을 지정하는 것처럼, 타입 매개변수가 특정 타입을 확장해야 함을 TypeScript에 알려줍니다.
type CreateObject<A extends string, B> = { a: A, b: B };
// A는 반드시 string이어야 하며, 그렇지 않으면 오류가 발생합니다.
type Errored = CreateObject<number, boolean>;
// 'number' 타입이 'string' 제약 조건을 만족하지 않습니다.
type Result = CreateObject<'name', boolean>;
// type Result = { a: "name"; b: boolean; }

제네릭 타입 함수에 기본 타입 추가하기

함수 매개변수와 마찬가지로 제네릭 타입 함수에도 기본 타입을 제공할 수 있습니다.
type CreateObject<
Key extends string = 'defaultName',
Value = string
> = { [Key]: Value };
// 이제 Key와 Value는 선택 사항입니다.
type Result = CreateObject;
// type Result = { defaultName: string }
type Result2 = CreateObject<'name'>;
// type Result2 = { name: string; }
type Result3 = CreateObject<'name', boolean>;
// type Result3 = { name: boolean; }

실무 예제: 간단한 CRUD 생성기

이것만으로도 이미 제네릭 CRUD 타입 생성기를 만들 수 있습니다.
type Crud<Resource extends { id: string | number }> = {
// create는 id를 제외한 모든 필드를 받습니다.
create: (resource: Omit<Resource, 'id'>) => Resource;
// getOne은 id를 받아 리소스 또는 undefined를 반환합니다.
getOne: (id: Resource['id']) => Resource | undefined;
// update는 id와 (id를 제외한) 부분 리소스를 받아
// 리소스 또는 undefined를 반환합니다.
update: (
id: Resource['id'],
resource: Partial<Omit<Resource, 'id'>>
) => Resource | undefined;
// delete는 id를 받아
// 리소스 삭제 여부를 나타내는 boolean을 반환합니다.
delete: (id: Resource['id']) => boolean;
// getList는 (id를 제외한) 부분 리소스를 필터로 받아
// 리소스 배열을 반환합니다.
getList: (filter: Partial<Omit<Resource, 'id'>>) => Resource[];
}

// 사용 예시
type User = { id: number; name: string; email: string };
type UserCrud = Crud<User>;
// type UserCrud = {
// create: (resource: Omit<User, 'id'>) => User;
// getOne: (id: number) => User | undefined;
// update: (id: number, resource: Partial<Omit<User, 'id' >>) =>
// User | undefined;
// delete: (id: number) => boolean;
// getList: (filter: Partial<Omit<User, 'id'>>) => User[];
// }

조건문: 조건부 타입(Conditional Types)

함수가 있다면 조건문도 가질 수 있을까요? 네, 가능합니다. 타입 함수에서 extends 키워드를 사용하여 조건을 만들 수 있습니다. extends는 한 타입이 다른 타입의 일부인지 테스트합니다. 그런 다음 삼항 연산자 구문인 condition ? trueCase : falseCase를 사용합니다.
type IsNumber<Value extends unknown> =
Value extends number ? true : false;

type Result = IsNumber<7>
// type Result = true
type Result2 = IsNumber<'seven'>;
// type Result = false

실무 예제:

이벤트에서 이벤트 타입 추출하기
type CreateEvent = {
type: "create";
payload: { name: string }
};
type UpdateEvent = {
type: "update";
payload: { id: number; name?: string }
};
type DeleteEvent = {
type: "delete";
payload: { id: number }
};
type UnknownEvent = unknown;

type Event = CreateEvent | UpdateEvent | DeleteEvent | UnknownEvent;

type InferEventType<T extends Event> = T extends CreateEvent
? "create"
: T extends UpdateEvent
? "update"
: T extends DeleteEvent
? "delete"
: never;

// 사용 예시
type EventType = InferEventType<UpdateEvent>; // 'update'
type EventType2 = InferEventType<CreateEvent>; // 'create'
type EventType3 = InferEventType<DeleteEvent>; // 'delete'
type EventType4 = InferEventType<'An event'>; // never

infer 키워드: 변수

infer 키워드는 타입 정의 내부에 변수를 생성합니다. 이는 구조 분해 할당(Destructuring)을 지원합니다.
// 먼저, infer 키워드를 사용하여 배열의 첫 번째 요소를
// 구조 분해한 뒤 반환합니다.
type First<Element> =
Element extends [infer FirstElement, ...any[]] ? FirstElement : never

// 사용 예시
type Result = First<[string, number, boolean]>;
// type Result = string
type Result2 = First<[]>;
// type Result2 = never

실무 예제

infer를 사용하면 조건부 타입 섹션의 이벤트 예제를 다음과 같이 단순화할 수 있습니다.
type InferEventType<
T extends Event
> = T extends { type: infer EventType } ? EventType : never;
// T에서 type 필드를 추출하는 변수 EventType을 정의합니다.
// 각 이벤트 타입을 개별적으로 확인할 필요가 없습니다.

// 사용 예시
type EventType = InferEventType<UpdateEvent>; // 'update'
type EventType2 = InferEventType<CreateEvent>; // 'create'
type EventType3 = InferEventType<DeleteEvent>; // 'delete'
type EventType4 = InferEventType<{}>; // never

재귀 타입(Recursive Types): 루프를 도는 방법

타입은 자기 자신을 호출할 수 있으며, 이를 통해 재귀 타입을 만들 수 있습니다.
재귀를 사용하여 트리와 같은 재귀적 데이터 구조의 타입을 정의할 수 있습니다.
type TreeNode<Value> = {
value: Value;
children?: TreeNode<Value>[];
}

type StringTree = TreeNode<string>;
const tree: StringTree = {
value: 'root',
children: [
{ value: 'child1' },
{ value: 'child2', children: [
{ value: 'grandchild1' }
] }
]
};
또한 재귀를 사용하여 배열을 반복(Iterate)할 수도 있습니다. 배열에서 특정 타입을 검색하는 Find 타입을 구현해 보겠습니다. 찾으면 해당 타입을 반환하고, 그렇지 않으면 never를 반환합니다.
type Find<ArrayType extends unknown[], ValueType> =
// 배열을 첫 번째 요소와 나머지로 구조 분해합니다.
ArrayType extends [infer First, ...infer Rest]
// 첫 번째 요소가 우리가 찾는 타입인지 확인합니다.
? First extends ValueType
// 맞다면 해당 요소를 반환합니다.
? First
// 아니라면 배열의 나머지에 대해 재귀를 수행합니다.
: Find<Rest, ValueType>
: never; // 배열이 비어있으면 never를 반환합니다.

// 이벤트를 사용한 예시
type EventsArray = [{
type: 'create';
payload: { name: string; };
}, {
type: 'update';
payload: { id: number; name?: string; };
}, {
type: 'delete';
payload: { id: number; };
}];

type CreateEvent = Find<
EventsArray,
{ type: 'create'; payload: { name: string; }; }
>;
// type CreateEvent = { type: "create"; payload: { name: string; }; }
type UpdateEvent = Find<
EventsArray,
{ type: 'update'; payload: { id: number; name?: string; }; }
>;
// type UpdateEvent = {
// type: "update";
// payload: { id: number; name?: string | undefined; };
// }
type DeleteEvent = Find<
EventsArray,
{ type: 'delete'; payload: { id: number; }; }
>;
// type DeleteEvent = { type: "delete"; payload: { id: number; }; }
type UnknownEvent = Find<
EventsArray,
{ type: 'unknown'; payload: {}; }
>;
// type UnknownEvent = never

실무 예제: 미들웨어 결과 타입 정의하기

이것이 언제 유용할지 궁금할 수 있습니다. 미들웨어가 작업 인자에 따라 작업 결과를 수정하는 미들웨어 시스템을 상상해 보십시오. 미들웨어 타입을 알고 있고 특정 인자에 대한 결과 타입을 찾고 싶을 때 다음과 같이 할 수 있습니다.
// 미들웨어 타입:
// Arg 타입의 인자를 받아 Result 타입의 결과를 반환합니다.
type Middleware<Arg, Result> = (arg: Arg) => Result;


// 미들웨어 예시
type Middlewares = [
Middleware<
{ type: "getList"; withComments: true },
{ id: number; name: string; comments: string[] }[]
>,
Middleware<{ type: "getList" }, { id: number; name: string }[]>,
Middleware<
{ type: "getOne"; withComments: true },
{ id: number; name: string; comments: string[] }
>,
Middleware<{ type: "getOne" }, { id: number; name: string }>
];

// Target 타입과 일치하는 인자를 가진 미들웨어를 찾습니다.
type FindMiddleware<Target> = Find<Middlewares, Middleware<Target, any>>;

// 미들웨어의 결과 타입을 가져옵니다.
type GetResult<MiddlewareInput extends Middleware<any, any>> =
MiddlewareInput extends Middleware<any, infer Result> ? Result : never;

// 모든 조각을 하나로 합칩니다.
type GetMiddlewareResult<Arg> = GetResult<FindMiddleware<Arg>>;

type ListResult = GetMiddlewareResult<{ type: "getList" }>;
// type ListResult = { id: number; name: string; }[]

type OneResult = GetMiddlewareResult<{ type: "getOne" }>;
// type OneResult = { id: number; name: string; }

type ListWithCommentsResult = GetMiddlewareResult<{
type: "getList";
withComments: true;
}>;
// type ListWithCommentsResult = {
// id: number;
// name: string;
// comments: string[]
// }[]

type OneWithCommentsResult = GetMiddlewareResult<{
type: "getOne";
withComments: true;
}>;
// type OneWithCommentsResult = {
// id: number;
// name: string;
// comments: string[]
// }

문자열 조작: 템플릿 리터럴 타입(Template Literal Types)

TypeScript를 사용하면 템플릿 리터럴을 사용하여 타입 레벨에서 문자열을 다룰 수 있습니다.
단순한 문자열 결합을 수행할 수 있습니다.
type HelloWorld<Greeted extends string = 'world'> = `Hello ${Greeted}`

type DefaultResult = HelloWorld; // 'Hello world'
type Result = HelloWorld<'TypeScript'>; // 'Hello TypeScript'
또한 infer 키워드와 재귀를 사용하여 문자열에서 모든 공백을 제거하는 것과 같은 더 복잡한 문자열 조작도 수행할 수 있습니다.
type RemoveWhitespace<S extends string> =
S extends `${infer First} ${infer Rest}`
? `${First}${RemoveWhitespace<Rest>}`
: S;

type Result = RemoveWhitespace<'Hello World !'>;
// type Result = "HelloWorld!"

실무 예제: 속성 이름으로부터 Getter 메서드 이름 생성하기

type Getter<Key extends string> = `get${Capitalize<Key>}`;
type Result = Getter<'name'>;
// type Result = "getName"
type Result2 = Getter<'firstName'>;
// type Result2 = "getFirstName"
참고: Capitalize는 문자열 타입의 첫 글자를 대문자로 바꿔주는 내장 유틸리티 타입입니다. Uppercase와 재귀를 사용하여 직접 구현할 수도 있습니다.
type Capitalize<
S extends string
> = S extends `${infer First}${infer Rest}`
? `${Uppercase<First>}${Rest}`
: S;
// Uppercase 역시 유틸리티 타입이지만,
// 이는 컴파일러 내부의 마법(intrinsic magic)으로 구현되어 있습니다.

// 사용 예시
type Result = Capitalize<"hello">;
// type Result = "Hello"

맵드 타입(Mapped Types)

재귀를 통해 배열을 루프 돌 수 있었지만, 객체 속성은 어떻게 반복할까요? 맵드 타입을 사용하면 기존 타입의 각 속성을 변환하여 새로운 타입을 만들 수 있습니다. 맵드 타입은 인덱스 접근 타입(Indexed Access Types) 구문과 keyof 연산자를 기반으로 합니다.

인덱스 접근 타입

인덱스 접근 타입을 사용하면 Type[Key] 구문을 통해 객체 타입의 속성 타입을 가져올 수 있습니다.
type User = { id: number; name: string; email: string };
type UserId = User['id'];
// type UserId = number
type UserName = User['name'];
// type UserName = string
또한 키들의 유니온(Union)을 사용하여 해당 키들의 타입 유니온을 가져올 수도 있습니다.
type UserIdOrName = User['id' | 'name'];
// type UserIdOrName = number | string

keyof 연산자

keyof 연산자를 사용하면 타입의 모든 키에 대한 유니온을 가져올 수 있습니다.
type User = { id: number; name: string; email: string };
type UserKeys = keyof User;
// type UserKeys = "id" | "name" | "email"

in 키워드

마지막으로 in 키워드를 추가하여 타입의 모든 키를 반복할 수 있습니다.
type UserPropertiesAsString = {
// 여기서 K는 User의 각 키입니다.
// 이를 맵핑하여 속성의 타입(User[K])을
// 반환하는 함수를 생성합니다.
[K in keyof User]: () => User[K];
};
// type UserPropertiesAsString = {
// id: () => number;
// name: () => string;
// email: () => string;
// }
이를 통해 객체의 모든 메서드가 Promise를 반환하도록 변환하는 타입을 만들 수 있습니다.
type Promisify<R extends Record<string, (...args: any) => any>> = {
[K in keyof R]:
(...args: Parameters<R[K]>) => Promise<ReturnType<R[K]>>;
};

type Input = {
getName: () => string;
getById: (id: string) => { id: string; name: string };
};

type PromisifiedInput = Promisify<Input>;
// type PromisifiedInput = {
// getName: () => Promise<string>;
// getById: (id: string) => Promise<{
// id: string;
// name: string;
// }>;
// }

실무 예제

다음은 맵드 타입의 더 복잡한 예제입니다. 리소스 타입과 필터 키 목록을 받아 각 필터 키에 대한 getByFilterType 메서드를 생성해 보겠습니다.
type Product = {
id: number;
name: string;
quantity: number;
inStock: boolean;
};
type CrudWithFilters<
Resource extends { id: string | number },
FilterKeys extends keyof Resource
> = {
// 각 필터 키에 대한 getByFilter
[K in FilterKeys as `getBy${Capitalize<string & K>}`]:
(value: Resource[K]) => Resource[];
}
type ProductCrudWithFilters = CrudWithFilters<
Product,
'name' | 'quantity' | 'inStock'
>;
// type ProductCrudWithFilters = {
// getByName: (value: string) => Product[];
// getByQuantity: (value: number) => Product[];
// getByInStock: (value: boolean) => Product[];
// }

// 실제 함수를 사용한 예시
const productCrud: CrudWithFilters<
Product,
'name' | 'quantity' | 'inStock'
> = {
getByName: (name: string) => [
{ id: 1, name, quantity: 10, inStock: true }
],
getByQuantity: (quantity: number) => [
{ id: 2, name: 'Product 2', quantity, inStock: false }
],
getByInStock: (inStock: boolean) => [
{ id: 3, name: 'Product 3', quantity: 5, inStock }
],
};

// productCrud의 타입이 올바르게 추론되며,
// 함수 구현이 기대되는 타입과 일치하지 않으면
// 타입 오류가 발생합니다.
const erroredProductCrud: CrudWithFilters<
Product,
'name' | 'quantity' | 'inStock'
> = {
getByName: (name: number) => [
{ id: 1, name: 'Product 1', quantity: 10, inStock: true }
],
// 타입
// '(name: number) => {
// id: number;
// name: string;
// quantity: number;
// inStock: true;
// }[]'
// 은 '(value: string) => Product[]' 타입에 할당할 수 없습니다.
// 'name'과 'value' 매개변수의 타입이 호환되지 않습니다.
// 'string' 타입은 'number' 타입에 할당할 수 없습니다.
getByQuantity: (quantity: string) =>
[{ id: 2, name: 'Product 2', quantity: 5, inStock: false }],
// 타입
// '(quantity: string) => {
// id: number;
// name: string;
// quantity: number;
// inStock: false;
// }[]'
// 은 '(value: number) => Product[]' 타입에 할당할 수 없습니다.
// 'quantity'와 'value' 매개변수의 타입이 호환되지 않습니다.
// 'number' 타입은 'string' 타입에 할당할 수 없습니다.
getByInStock: (inStock: boolean) => 'not found',
// 타입
// '(inStock: boolean) => string'
// 은 '(value: boolean) => Product[]' 타입에 할당할 수 없습니다.
// 'string' 타입은 'Product[]' 타입에 할당할 수 없습니다.
};

함수 객체를 단일 함수로 축소하기

keyof를 사용하면 객체의 형태를 기반으로 객체뿐만 아니라 무엇이든 반환할 수 있습니다. 예를 들어, 여기 함수 객체를 단일 함수로 변환하는 함수가 있습니다. 이 함수는 객체의 키를 첫 번째 인자로 받고, 함수의 인자들을 나머지 인자로 받습니다.
// 먼저 헬퍼를 정의해 봅시다:
// ObjToFunc는 함수들로 이루어진 임의의 객체를 받아
// 이를 단일 함수로 변환합니다.
type ObjectToFunc<Obj extends Record<string, (...args: any) => any>> = {
// 객체 인자의 키를 맵핑하고
// 각 키에 대해 함수를 정의합니다.
<K extends keyof Obj>(key: K, ...args: Parameters<Obj[K]>): ReturnType<
Obj[K]
>;
};

// 결과적으로 각 객체 키에 대한 함수의 유니온 타입이 생성됩니다.
type Example = ObjectToFunc<
{
getStringLength: (s: string) => number;
isEven: (n: number) => boolean;
}
>
// type Example =
// | (key: getStringLength, s: string) => number
// | (key: isEven, n: number) => boolean;

// 이제 실제 함수를 만들어 봅시다.

const transformObjectToFunction = <
Obj extends Record<string, (...args: any) => any>
>(
obj: Obj
): ObjectToFunc<Obj> => {
return ((key: string, ...args: unknown[]) => {
const func = obj[key];
return func(...args);
}) as ObjectToFunc<Obj>;
};

// 사용 예시: 서로 다른 리소스에 대한 getList 함수 모음을
// 단일 getList 함수로 결합합니다.

const getList = transformObjectToFunction({
authors: (filter: { name?: string }) => [
{ id: 1, name: "Author 1" },
{ id: 2, name: "Author 2" },
],
posts: (filter: { authorId?: number; published?: boolean }) => [
{ id: 1, title: "A post" },
],

comments: (filter: { postId?: number; authorId?: number }) => [
{ id: 1, content: "A comment" },
],
});

getList("posts", { authorId: 1 });
// 오류 없음
// 다음과 같이 추론됨:
// const getList: <"posts">(key: "posts", filter: {
// authorId?: number;
// published?: boolean;
// }) => {
// id: number;
// title: string;
// }[]
getList("posts", { name: 'Author 1' });
// 오류: 객체 리터럴은 알려진 속성만 지정할 수 있으며,
// name은 다음 타입에 존재하지 않습니다:
// { authorId?: number | undefined; published?: boolean | undefined; }
getList("authors", { name: "Author 1" });
// 오류 없음
// 다음과 같이 추론됨:
// const getList: <"authors">(key: "authors", filter: {
// name?: string | undefined;
// }) => {
// id: number;
// name: string;
// }[]

참고: 이전 예제에서는 TypeScript에서 제공하는 유틸리티 타입인 ReturnTypeParameters를 사용했습니다. ReturnType은 주어진 함수 타입이 반환하는 값의 타입을 반환합니다. 궁금하시다면 다음과 같이 직접 구현할 수 있습니다:
type ReturnType<T extends (...args: any) => any> = T extends (
...args: any
) => infer R
? R
: any;
Parameters는 주어진 함수 타입의 인자들을 반환합니다. 다음과 같이 직접 구현할 수 있습니다:
type Parameters<T extends (...args: any) => any> = T extends (
...args: infer P
) => any
? P
: never;

결론

TypeScript 타입 정의를 프로그래밍 언어로 다룸으로써 고급 제네릭 타입을 정의할 수 있습니다. 이러한 제네릭 타입은 결과적으로 더 제네릭한 코드를 가능하게 하여, 코드베이스 전반에서 중복을 줄이고 타입 안전성을 향상시킵니다.
핵심 요점은 다음과 같습니다:
  • 제네릭 타입은 타입을 변환하는 함수입니다.
  • 조건부 타입(extends ? :)은 분기 로직을 가능하게 합니다.
  • infer 키워드는 변수 할당처럼 작동하며 구조 분해처럼 다른 타입에서 값을 추출할 수 있게 해줍니다.
  • 재귀는 배열과 복잡한 타입에 대한 반복을 가능하게 합니다.
  • 템플릿 리터럴은 타입 레벨에서 문자열 조작을 가능하게 합니다.
  • 맵드 타입은 객체 속성에 대한 반복을 제공합니다.
이러한 도구들을 사용하면 필요에 따라 적응하고, 컴파일 타임에 오류를 잡아내며, IDE에서 훌륭한 자동 완성을 제공하는 정교한 타입 유틸리티를 만들 수 있습니다. 다음에 타입 정의를 작성할 때는 프로그램을 작성한다고 생각하십시오. 실제로 여러분은 바로 그 일을 하고 있는 것이기 때문입니다.
0
11

댓글

?

아직 댓글이 없습니다.

첫 번째 댓글을 작성해보세요!

Inkyu Oh님의 다른 글

더보기

유사한 내용의 글