본문으로 건너뛰기
Kreath Archive
TechProjectsBooksAbout
TechProjectsBooksAbout

내비게이션

  • Tech
  • Projects
  • Books
  • About
  • Tags

카테고리

  • AI / ML
  • 웹 개발
  • 프로그래밍
  • 개발 도구

연결

  • GitHub
  • Email
  • RSS
© 2026 Kreath Archive. All rights reserved.Built with Next.js + MDX
홈TechProjectsBooksAbout
//
  1. 홈
  2. 테크
  3. 12장: 실전 프로젝트 — 타입 안전 유틸리티 라이브러리 설계
2026년 2월 13일·프로그래밍·

12장: 실전 프로젝트 — 타입 안전 유틸리티 라이브러리 설계

TypeScript 5.x의 고급 타입 기법을 총동원하여 타입 안전 유틸리티 라이브러리를 처음부터 설계하고 구현하는 실전 프로젝트입니다.

18분1,599자9개 섹션
typescriptperformancedevtools
공유
typescript-deepdive12 / 12
123456789101112
이전11장: 프로젝트 설정과 모노레포 타입 전략

11장까지 TypeScript 5.x의 타입 시스템, 프로젝트 설정, 모노레포 전략을 학습했습니다. 이 마지막 장에서는 시리즈 전체에서 배운 내용을 총동원하여 타입 안전 유틸리티 라이브러리를 처음부터 설계하고 구현합니다. 이 프로젝트를 통해 각 기법이 실제 코드에서 어떻게 결합되는지 체험합니다.

프로젝트 개요

type-forge라는 유틸리티 라이브러리를 만들겠습니다. 이 라이브러리는 다음 모듈을 제공합니다.

  1. object — 타입 안전 객체 조작 유틸리티
  2. string — 타입 수준 문자열 변환
  3. result — 에러 처리를 위한 Result 타입
  4. schema — 런타임 타입 검증

프로젝트 설정

tsconfig.json
json
{
  "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"]
}
package.json
json
{
  "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" }
  }
}

모듈 1: 타입 안전 객체 유틸리티

pick과 omit

TypeScript의 내장 Pick/Omit은 타입 수준에서만 동작합니다. 런타임에서도 타입 안전하게 동작하는 pick/omit 함수를 구현합니다.

src/object.ts — pick & omit
typescript
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 }

deepGet: 점 표기법 경로 접근

5장과 7장에서 배운 조건부 타입과 템플릿 리터럴 타입을 결합하여, 점 표기법 경로로 중첩 객체에 안전하게 접근하는 함수를 구현합니다.

src/object.ts — deepGet
typescript
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: 자동완성에 없음

mapValues: 값 변환

src/object.ts — mapValues
typescript
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 }

모듈 2: 타입 수준 문자열 유틸리티

6장과 7장의 매핑 타입, 템플릿 리터럴 타입을 활용한 타입 수준 유틸리티입니다.

src/string.ts
typescript
// === 타입 유틸리티 ===
 
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;
}

사용 예시:

문자열 유틸리티 사용
typescript
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 }

모듈 3: Result 타입

에러 처리를 위한 타입 안전 Result 패턴입니다. 예외 대신 반환 타입으로 성공/실패를 표현합니다.

src/result.ts
typescript
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의 추론을 오염시키지 않도록 합니다.

사용 예시:

Result 패턴 사용
typescript
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>)
  );
}

모듈 4: 스키마 기반 런타임 검증

2장의 satisfies와 const 타입 파라미터, 9장의 NoInfer를 결합하여, 런타임 검증과 타입 추론을 동시에 제공하는 스키마 시스템을 구현합니다.

src/schema.ts — 기본 스키마 타입
typescript
// 스키마 정의 타입
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;
src/schema.ts — 스키마 빌더
typescript
// 스키마 빌더 함수들
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 };
}
src/schema.ts — 검증 함수
typescript
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 };
}

사용 예시:

스키마 검증 사용
typescript
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 기법들을 정리합니다.

1. const 타입 파라미터로 리터럴 보존 (2장)

스키마의 object() 함수에서 프로���티 이름이 리터럴로 보존되어야 정확한 타입 추론이 가능합니다.

2. satisfies로 검증과 추론 동시 달성 (2장)

라이브러리 사용자가 설정 객체를 정의할 때, satisfies를 통해 타입 검증과 리터럴 타입 보존을 동시에 달성할 수 있습니다.

3. 조건부 타입으로 타입 수준 변환 (5장)

Infer<S> 타입은 조건부 타입의 중첩으로 스키마 AST를 TypeScript 타입으로 변환합니다.

4. 매핑 타입으로 객체 타입 생성 (6장)

ObjectSchema의 Infer는 매핑 타입으로 각 프로퍼티를 변환하고, 키 재매핑으로 optional 프로퍼티를 분리합니다.

5. infer로 제네릭 타입 추출 (8장)

ArraySchema<infer T>와 OptionalSchema<infer T>에서 내부 타입을 추출합니다.

6. NoInfer로 추론 방향 제어 (9장)

unwrapOr의 기본값이 Result의 타입 추론을 오염시키지 않도록 합니다.

7. isolatedDeclarations로 빌드 최적화 (11장)

모든 공개 함수에 명시적 반환 타입을 지정하여 .d.ts 생성을 타입 검사 없이 수행할 수 있게 합니다.

테스트 전략

타입 수준 테스트와 런타임 테스트를 모두 포함합니다.

타입 수준 테스트
typescript
// 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 }
>>;
Tip

타입 수준 테스트는 tsc --noEmit만으로 검증됩니다. 타입이 올바르지 않으면 컴파일 에러가 발생하므로, CI 파이프라인에 통합하기 쉽습니다. @type-challenges/utils나 expect-type 같은 라이브러리도 활용할 수 있습니다.

시리즈 마무리

이 시리즈를 통해 TypeScript 5.x의 핵심을 체계적으로 학습했습니다.

  • 1장: 5.0~5.8 전체 변경사항 조감
  • 2~4장: 새로운 언어 기능 — const 타입 파라미터, satisfies, 데코레이터, using 선언
  • 5~8장: 타입 시스템 심층 분석 — 조건부 타입, 매핑 타입, 템플릿 리터럴 타입, infer
  • 9장: NoInfer와 유틸리티 타입
  • 10장: 타입 수준 프로그래밍
  • 11장: 프로젝트 설정과 모노레포 전략
  • 12장: 실전 프로젝트

TypeScript의 타입 시스템은 단순한 에러 방지 도구를 넘어, 코드의 의도를 정확하게 표현하고 개발자 경험�� 향상시키는 설계 도구입니다. 이 시리��에서 다룬 기법들을 실제 프로젝트에 적용하며, 타입이 코드를 더 안전하고 표현력 있게 만드는 경험을 쌓아가시길 바랍니다.

이 글이 도움이 되셨나요?

관련 주제 더 보기

#typescript#performance#devtools

관련 글

프로그래밍

11장: 프로젝트 설정과 모노레포 타입 전략

TypeScript 프로젝트의 tsconfig.json 최적화, 프로젝트 참조, Isolated Declarations, 모노레포에서의 타입 전략을 실전 중심으로 다룹니다.

2026년 2월 11일·14분
프로그래밍

10장: 타입 수준 프로그래밍 — 타입으로 로직 작성하기

TypeScript 타입 시스템을 프로그래밍 언어로 활용하는 고급 기법 — 산술 연산, 문자열 파서, 상태 머신 등을 타입만으로 구현하는 패턴을 다룹니다.

2026년 2월 9일·14분
프로그래밍

9장: NoInfer와 새로운 유틸리티 타입

TypeScript 5.4의 NoInfer 유틸리티 타입과 5.x에서 추가된 새로운 타입 도구들을 활용한 라이브러리 설계 패턴을 다룹니다.

2026년 2월 7일·14분
이전 글11장: 프로젝트 설정과 모노레포 타입 전략

댓글

목차

약 18분 남음
  • 프로젝트 개요
  • 프로젝트 설정
  • 모듈 1: 타입 안전 객체 유틸리티
    • pick과 omit
    • deepGet: 점 표기법 경로 접근
    • mapValues: 값 변환
  • 모듈 2: 타입 수준 문자열 유틸리티
  • 모듈 3: Result 타입
  • 모듈 4: 스키마 기반 런타임 검증
  • 라이브러리 설계 원칙 정리
    • 1. const 타입 파라미터로 리터럴 보존 (2장)
    • 2. satisfies로 검증과 추론 동시 달성 (2장)
    • 3. 조건부 타입으로 타입 수준 변환 (5장)
    • 4. 매핑 타입으로 객체 타입 생성 (6장)
    • 5. infer로 제네릭 타입 추출 (8장)
    • 6. NoInfer로 추론 방향 제어 (9장)
    • 7. isolatedDeclarations로 빌드 최적화 (11장)
  • 테스트 전략
  • 시리즈 마무리