TypeScript 5.x의 고급 타입 기법을 총동원하여 타입 안전 유틸리티 라이브러리를 처음부터 설계하고 구현하는 실전 프로젝트입니다.
11장까지 TypeScript 5.x의 타입 시스템, 프로젝트 설정, 모노레포 전략을 학습했습니다. 이 마지막 장에서는 시리즈 전체에서 배운 내용을 총동원하여 타입 안전 유틸리티 라이브러리를 처음부터 설계하고 구현합니다. 이 프로젝트를 통해 각 기법이 실제 코드에서 어떻게 결합되는지 체험합니다.
type-forge라는 유틸리티 라이브러리를 만들겠습니다. 이 라이브러리는 다음 모듈을 제공합니다.
{
"compilerOptions": {
"strict": true,
"target": "es2024",
"module": "nodenext",
"moduleResolution": "nodenext",
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"outDir": "./dist",
"rootDir": "./src",
"verbatimModuleSyntax": true,
"noUncheckedIndexedAccess": true,
"isolatedDeclarations": true,
"erasableSyntaxOnly": true
},
"include": ["src"]
}{
"name": "type-forge",
"version": "1.0.0",
"type": "module",
"exports": {
"./object": { "types": "./dist/object.d.ts", "default": "./dist/object.js" },
"./string": { "types": "./dist/string.d.ts", "default": "./dist/string.js" },
"./result": { "types": "./dist/result.d.ts", "default": "./dist/result.js" },
"./schema": { "types": "./dist/schema.d.ts", "default": "./dist/schema.js" }
}
}TypeScript의 내장 Pick/Omit은 타입 수준에서만 동작합니다. 런타임에서도 타입 안전하게 동작하는 pick/omit 함수를 구현합니다.
export function pick<T extends Record<string, unknown>, K extends keyof T>(
obj: T,
keys: readonly K[]
): Pick<T, K> {
const result = {} as Pick<T, K>;
for (const key of keys) {
if (key in obj) {
result[key] = obj[key];
}
}
return result;
}
export function omit<T extends Record<string, unknown>, K extends keyof T>(
obj: T,
keys: readonly K[]
): Omit<T, K> {
const result = { ...obj };
for (const key of keys) {
delete (result as Record<string, unknown>)[key as string];
}
return result as Omit<T, K>;
}
// 사용 예시
interface User {
id: string;
name: string;
email: string;
password: string;
}
const user: User = { id: "1", name: "Alice", email: "a@b.com", password: "secret" };
const publicUser = omit(user, ["password"]);
// 타입: Omit<User, "password"> = { id: string; name: string; email: string }5장과 7장에서 배운 조건부 타입과 템플릿 리터럴 타입을 결합하여, 점 표기법 경로로 중첩 객체에 안전하게 접근하는 함수를 구현합니다.
type PathValue<T, P extends string> =
P extends `${infer Key}.${infer Rest}`
? Key extends keyof T
? PathValue<T[Key], Rest>
: undefined
: P extends keyof T
? T[P]
: undefined;
type Paths<T, Prefix extends string = ""> = T extends object
? {
[K in keyof T & string]: T[K] extends object
? `${Prefix}${K}` | Paths<T[K], `${Prefix}${K}.`>
: `${Prefix}${K}`;
}[keyof T & string]
: never;
export function deepGet<T extends Record<string, unknown>, P extends Paths<T>>(
obj: T,
path: P
): PathValue<T, P> {
const keys = (path as string).split(".");
let current: unknown = obj;
for (const key of keys) {
if (current === null || current === undefined) return undefined as PathValue<T, P>;
current = (current as Record<string, unknown>)[key];
}
return current as PathValue<T, P>;
}
// 사용 예시
const config = {
server: { port: 3000, host: "localhost" },
database: { url: "postgres://...", pool: { min: 2, max: 10 } },
};
const port = deepGet(config, "server.port"); // number
const poolMax = deepGet(config, "database.pool.max"); // number
// deepGet(config, "server.invalid") // Error: 자동완성에 없음export function mapValues<T extends Record<string, unknown>, R>(
obj: T,
fn: (value: T[keyof T], key: keyof T) => R
): { [K in keyof T]: R } {
const result = {} as { [K in keyof T]: R };
for (const key in obj) {
if (Object.prototype.hasOwnProperty.call(obj, key)) {
result[key] = fn(obj[key], key);
}
}
return result;
}
// 사용 예시
const prices = { apple: 1000, banana: 2000, cherry: 3000 };
const withTax = mapValues(prices, (price) => (price as number) * 1.1);
// { apple: number; banana: number; cherry: number }6장과 7장의 매핑 타입, 템플릿 리터럴 타입을 활용한 타입 수준 유틸리티입니다.
// === 타입 유틸리티 ===
export type CamelToKebab<S extends string> =
S extends `${infer C}${infer Rest}`
? C extends Uppercase<C>
? C extends Lowercase<C>
? `${C}${CamelToKebab<Rest>}`
: `-${Lowercase<C>}${CamelToKebab<Rest>}`
: `${C}${CamelToKebab<Rest>}`
: S;
export type KebabToCamel<S extends string> =
S extends `${infer Head}-${infer Tail}`
? `${Head}${KebabToCamel<Capitalize<Tail>>}`
: S;
export type Split<S extends string, D extends string> =
S extends `${infer Head}${D}${infer Tail}`
? [Head, ...Split<Tail, D>]
: [S];
export type Join<T extends string[], D extends string> =
T extends [] ? "" :
T extends [infer F extends string] ? F :
T extends [infer F extends string, ...infer R extends string[]]
? `${F}${D}${Join<R, D>}`
: never;
// === 키 변환 매핑 타입 ===
export type CamelToKebabKeys<T extends Record<string, unknown>> = {
[K in keyof T as CamelToKebab<string & K>]: T[K];
};
export type KebabToCamelKeys<T extends Record<string, unknown>> = {
[K in keyof T as KebabToCamel<string & K>]: T[K];
};
// === 런타임 함수 ===
export function camelToKebab(str: string): string {
return str.replace(/[A-Z]/g, (match) => `-${match.toLowerCase()}`);
}
export function kebabToCamel(str: string): string {
return str.replace(/-([a-z])/g, (_, char: string) => char.toUpperCase());
}
export function transformKeys<T extends Record<string, unknown>>(
obj: T,
transform: (key: string) => string
): Record<string, unknown> {
const result: Record<string, unknown> = {};
for (const key in obj) {
if (Object.prototype.hasOwnProperty.call(obj, key)) {
result[transform(key)] = obj[key];
}
}
return result;
}사용 예시:
import type { CamelToKebab, CamelToKebabKeys } from "type-forge/string";
type Kebab = CamelToKebab<"backgroundColor">; // "background-color"
interface ApiResponse {
userName: string;
createdAt: string;
isActive: boolean;
}
type KebabResponse = CamelToKebabKeys<ApiResponse>;
// { "user-name": string; "created-at": string; "is-active": boolean }에러 처리를 위한 타입 안전 Result 패턴입니다. 예외 대신 반환 타입으로 성공/실패를 표현합니다.
export type Result<T, E = Error> =
| { ok: true; value: T }
| { ok: false; error: E };
export function ok<T>(value: T): Result<T, never> {
return { ok: true, value };
}
export function err<E>(error: E): Result<never, E> {
return { ok: false, error };
}
export function isOk<T, E>(result: Result<T, E>): result is { ok: true; value: T } {
return result.ok;
}
export function isErr<T, E>(result: Result<T, E>): result is { ok: false; error: E } {
return !result.ok;
}
export function map<T, U, E>(
result: Result<T, E>,
fn: (value: T) => U
): Result<U, E> {
if (result.ok) {
return ok(fn(result.value));
}
return result;
}
export function flatMap<T, U, E>(
result: Result<T, E>,
fn: (value: T) => Result<U, E>
): Result<U, E> {
if (result.ok) {
return fn(result.value);
}
return result;
}
export function unwrap<T, E>(result: Result<T, E>): T {
if (result.ok) {
return result.value;
}
throw result.error;
}
export function unwrapOr<T, E>(result: Result<T, E>, defaultValue: NoInfer<T>): T {
if (result.ok) {
return result.value;
}
return defaultValue;
}
// tryCatch: 예외를 Result로 변환
export function tryCatch<T>(fn: () => T): Result<T, Error> {
try {
return ok(fn());
} catch (e) {
return err(e instanceof Error ? e : new Error(String(e)));
}
}
export async function tryCatchAsync<T>(
fn: () => Promise<T>
): Promise<Result<T, Error>> {
try {
return ok(await fn());
} catch (e) {
return err(e instanceof Error ? e : new Error(String(e)));
}
}9장에서 배운 NoInfer가 unwrapOr에 사용되었습니다. defaultValue의 타입이 T의 추론을 오염시키지 않도록 합니다.
사용 예시:
import { ok, err, map, flatMap, unwrapOr, tryCatchAsync } from "type-forge/result";
import type { Result } from "type-forge/result";
function parsePort(input: string): Result<number, string> {
const port = parseInt(input, 10);
if (isNaN(port)) return err(`Invalid port: ${input}`);
if (port < 0 || port > 65535) return err(`Port out of range: ${port}`);
return ok(port);
}
function validateHost(host: string): Result<string, string> {
if (!host.includes(".")) return err(`Invalid host: ${host}`);
return ok(host);
}
// 파이프라인으로 결합
const port = parsePort("3000");
const doubled = map(port, (p) => p * 2);
const result = unwrapOr(doubled, 8080); // 6000 or 8080
// 비동기 에러 처리
async function fetchUser(id: string): Promise<Result<User, Error>> {
return tryCatchAsync(() =>
fetch(`/api/users/${id}`).then((r) => r.json() as Promise<User>)
);
}2장의 satisfies와 const 타입 파라미터, 9장의 NoInfer를 결합하여, 런타임 검증과 타입 추론을 동시에 제공하는 스키마 시스템을 구현합니다.
// 스키마 정의 타입
interface StringSchema { readonly _tag: "string" }
interface NumberSchema { readonly _tag: "number" }
interface BooleanSchema { readonly _tag: "boolean" }
interface ArraySchema<T extends Schema> { readonly _tag: "array"; readonly items: T }
interface ObjectSchema<T extends Record<string, Schema>> {
readonly _tag: "object";
readonly properties: T;
}
interface OptionalSchema<T extends Schema> { readonly _tag: "optional"; readonly inner: T }
type Schema =
| StringSchema
| NumberSchema
| BooleanSchema
| ArraySchema<Schema>
| ObjectSchema<Record<string, Schema>>
| OptionalSchema<Schema>;
// 스키마 → TypeScript 타입 변환
export type Infer<S extends Schema> =
S extends StringSchema ? string :
S extends NumberSchema ? number :
S extends BooleanSchema ? boolean :
S extends ArraySchema<infer T> ? Infer<T>[] :
S extends OptionalSchema<infer T> ? Infer<T> | undefined :
S extends ObjectSchema<infer Props> ? {
[K in keyof Props as Props[K] extends OptionalSchema<Schema> ? never : K]: Infer<Props[K]>;
} & {
[K in keyof Props as Props[K] extends OptionalSchema<Schema> ? K : never]?: Infer<Props[K]>;
} :
never;// 스키마 빌더 함수들
export function string(): StringSchema {
return { _tag: "string" };
}
export function number(): NumberSchema {
return { _tag: "number" };
}
export function boolean(): BooleanSchema {
return { _tag: "boolean" };
}
export function array<T extends Schema>(items: T): ArraySchema<T> {
return { _tag: "array", items };
}
export function object<T extends Record<string, Schema>>(
properties: T
): ObjectSchema<T> {
return { _tag: "object", properties };
}
export function optional<T extends Schema>(inner: T): OptionalSchema<T> {
return { _tag: "optional", inner };
}export function validate<S extends Schema>(
schema: S,
data: unknown
): Result<Infer<S>, string[]> {
const errors: string[] = [];
function check(s: Schema, value: unknown, path: string): boolean {
switch (s._tag) {
case "string":
if (typeof value !== "string") {
errors.push(`${path}: expected string, got ${typeof value}`);
return false;
}
return true;
case "number":
if (typeof value !== "number") {
errors.push(`${path}: expected number, got ${typeof value}`);
return false;
}
return true;
case "boolean":
if (typeof value !== "boolean") {
errors.push(`${path}: expected boolean, got ${typeof value}`);
return false;
}
return true;
case "array":
if (!Array.isArray(value)) {
errors.push(`${path}: expected array, got ${typeof value}`);
return false;
}
return value.every((item, i) => check(s.items, item, `${path}[${i}]`));
case "optional":
if (value === undefined) return true;
return check(s.inner, value, path);
case "object": {
if (typeof value !== "object" || value === null) {
errors.push(`${path}: expected object, got ${typeof value}`);
return false;
}
const obj = value as Record<string, unknown>;
let valid = true;
for (const [key, propSchema] of Object.entries(s.properties)) {
if (!check(propSchema, obj[key], `${path}.${key}`)) {
valid = false;
}
}
return valid;
}
default:
return false;
}
}
if (check(schema, data, "$")) {
return { ok: true, value: data as Infer<S> };
}
return { ok: false, error: errors };
}사용 예시:
import { string, number, boolean, array, object, optional, validate } from "type-forge/schema";
import type { Infer } from "type-forge/schema";
// 스키마 정의
const userSchema = object({
name: string(),
age: number(),
email: string(),
isActive: boolean(),
tags: array(string()),
bio: optional(string()),
});
// 스키마에서 타입 추론
type User = Infer<typeof userSchema>;
// {
// name: string;
// age: number;
// email: string;
// isActive: boolean;
// tags: string[];
// bio?: string | undefined;
// }
// 런타임 검증
const result = validate(userSchema, {
name: "Alice",
age: 30,
email: "alice@example.com",
isActive: true,
tags: ["developer"],
});
if (result.ok) {
const user = result.value;
// user는 User 타입으로 안전하게 사용 가능
console.log(user.name);
}이 프로젝트에서 적용한 TypeScript 5.x 기법들을 정리합니다.
스키마의 object() 함수에서 프로���티 이름이 리터럴로 보존되어야 정확한 타입 추론이 가능합니다.
라이브러리 사용자가 설정 객체를 정의할 때, satisfies를 통해 타입 검증과 리터럴 타입 보존을 동시에 달성할 수 있습니다.
Infer<S> 타입은 조건부 타입의 중첩으로 스키마 AST를 TypeScript 타입으로 변환합니다.
ObjectSchema의 Infer는 매핑 타입으로 각 프로퍼티를 변환하고, 키 재매핑으로 optional 프로퍼티를 분리합니다.
ArraySchema<infer T>와 OptionalSchema<infer T>에서 내부 타입을 추출합니다.
unwrapOr의 기본값이 Result의 타입 추론을 오염시키지 않도록 합니다.
모든 공개 함수에 명시적 반환 타입을 지정하여 .d.ts 생성을 타입 검사 없이 수행할 수 있게 합니다.
타입 수준 테스트와 런타임 테스트를 모두 포함합니다.
// type-tests.ts — tsc --noEmit으로 실행
// 타입 동등성 검사 유틸리티
type Expect<T extends true> = T;
type Equal<A, B> =
(<T>() => T extends A ? 1 : 2) extends (<T>() => T extends B ? 1 : 2)
? true
: false;
// 테스트 케이스
type _test1 = Expect<Equal<CamelToKebab<"backgroundColor">, "background-color">>;
type _test2 = Expect<Equal<Split<"a.b.c", ".">, ["a", "b", "c"]>>;
type _test3 = Expect<Equal<
Infer<ReturnType<typeof userSchema>>,
{ name: string; age: number; email: string; isActive: boolean; tags: string[]; bio?: string }
>>;타입 수준 테스트는 tsc --noEmit만으로 검증됩니다. 타입이 올바르지 않으면 컴파일 에러가 발생하므로, CI 파이프라인에 통합하기 쉽습니다. @type-challenges/utils나 expect-type 같은 라이브러리도 활용할 수 있습니다.
이 시리즈를 통해 TypeScript 5.x의 핵심을 체계적으로 학습했습니다.
const 타입 파라미터, satisfies, 데코레이터, using 선언inferNoInfer와 유틸리티 타입TypeScript의 타입 시스템은 단순한 에러 방지 도구를 넘어, 코드의 의도를 정확하게 표현하고 개발자 경험�� 향상시키는 설계 도구입니다. 이 시리��에서 다룬 기법들을 실제 프로젝트에 적용하며, 타입이 코드를 더 안전하고 표현력 있게 만드는 경험을 쌓아가시길 바랍니다.
이 글이 도움이 되셨나요?
관련 주제 더 보기
TypeScript 프로젝트의 tsconfig.json 최적화, 프로젝트 참조, Isolated Declarations, 모노레포에서의 타입 전략을 실전 중심으로 다룹니다.
TypeScript 타입 시스템을 프로그래밍 언어로 활용하는 고급 기법 — 산술 연산, 문자열 파서, 상태 머신 등을 타입만으로 구현하는 패턴을 다룹니다.
TypeScript 5.4의 NoInfer 유틸리티 타입과 5.x에서 추가된 새로운 타입 도구들을 활용한 라이브러리 설계 패턴을 다룹니다.