타입 안전한 i18n은 번역 키를 타입으로 관리 해서, 존재하지 않는 키 사용이나 보간 변수 누락을 컴파일 타임에 잡습니다.

문제: 타입 없는 i18n

TYPESCRIPT
// 일반적인 i18n — 문자열 키로 접근
function t(key: string): string {
  return translations[key] ?? key;
}

t('greeting');      // OK
t('greating');      // ⚠️ 오타인데 에러 없음! 런타임에 키가 그대로 표시됨

번역 파일에서 타입 추출

TYPESCRIPT
// translations/ko.ts
const ko = {
  common: {
    greeting: '안녕하세요, {{name}}님!',
    confirm: '확인',
    cancel: '취소',
  },
  auth: {
    login: '로그인',
    logout: '로그아웃',
    welcome: '{{name}}님, 환영합니다!',
  },
  errors: {
    notFound: '{{resource}}을(를) 찾을 수 없습니다',
    serverError: '서버 오류가 발생했습니다',
  },
} as const;

export type Translations = typeof ko;
export default ko;

점(.) 표기법으로 키 추출

TYPESCRIPT
// 중첩 객체의 모든 경로를 점 표기법으로 추출
type DotPath<T, Prefix extends string = ''> = T extends object
  ? {
      [K in keyof T & string]: T[K] extends object
        ? DotPath<T[K], `${Prefix}${K}.`>
        : `${Prefix}${K}`;
    }[keyof T & string]
  : never;

type TranslationKey = DotPath<Translations>;
// 'common.greeting' | 'common.confirm' | 'common.cancel' |
// 'auth.login' | 'auth.logout' | 'auth.welcome' |
// 'errors.notFound' | 'errors.serverError'

타입 안전한 t 함수

TYPESCRIPT
// 점 경로로 값의 타입을 가져오기
type GetByPath<T, Path extends string> =
  Path extends `${infer Key}.${infer Rest}`
    ? Key extends keyof T
      ? GetByPath<T[Key], Rest>
      : never
    : Path extends keyof T
      ? T[Path]
      : never;

// 보간 변수 추출 ({{name}} 패턴)
type ExtractInterpolations<S extends string> =
  S extends `${string}{{${infer Var}}}${infer Rest}`
    ? Var | ExtractInterpolations<Rest>
    : never;

// 타입 안전한 t 함수
function t<K extends TranslationKey>(
  key: K,
  ...args: ExtractInterpolations<GetByPath<Translations, K> & string> extends never
    ? []
    : [params: Record<ExtractInterpolations<GetByPath<Translations, K> & string>, string>]
): string {
  // 구현 (경로 탐색 + 보간 치환)
  return '';
}

// ✅ 타입 안전한 사용
t('common.confirm');                              // 보간 변수 없음 — params 불필요
t('common.greeting', { name: '홍길동' });          // name이 필수
t('errors.notFound', { resource: '사용자' });       // resource가 필수

// ❌ 컴파일 에러
// t('common.greating');                           // 오타 — Error
// t('common.greeting');                           // name 누락 — Error
// t('common.greeting', { nama: '홍길동' });        // 오타 — Error

next-intl / react-i18next와의 통합

TYPESCRIPT
// next-intl 예시
import { useTranslations } from 'next-intl';

// 네임스페이스별 타입
type Messages = typeof import('../messages/ko.json');

declare module 'next-intl' {
  interface IntlMessages extends Messages {}
}

// 사용 — 타입 안전
function Component() {
  const t = useTranslations('common');
  return <p>{t('greeting', { name: '홍길동' })}</p>;
  // ✅ 키와 보간 변수가 타입 체크됨
}

번역 키 누락 감지

TYPESCRIPT
// 모든 언어가 같은 키를 가지고 있는지 검증
type ValidateTranslations<
  Base extends Record<string, any>,
  Target extends Record<string, any>
> = {
  [K in keyof Base]: K extends keyof Target
    ? Base[K] extends Record<string, any>
      ? Target[K] extends Record<string, any>
        ? ValidateTranslations<Base[K], Target[K]>
        : never // 구조 불일치
      : Target[K] extends string
        ? true
        : never
    : never; // 키 누락
};

// 한국어 기준으로 영어 번역 검증
const en = {
  common: {
    greeting: 'Hello, {{name}}!',
    confirm: 'Confirm',
    cancel: 'Cancel',
  },
  auth: {
    login: 'Login',
    logout: 'Logout',
    welcome: 'Welcome, {{name}}!',
  },
  errors: {
    notFound: '{{resource}} not found',
    serverError: 'Server error occurred',
  },
} as const satisfies Record<keyof Translations, any>;

satisfies로 영어 번역이 한국어와 같은 구조를 가지는지 검증합니다.

정리

  • 번역 파일에 as const를 붙이면 리터럴 타입으로 키를 추출할 수 있다
  • Template Literal Types로 {{name}} 패턴의 보간 변수를 자동 추출한다
  • 점 표기법 경로 타입으로 중첩된 번역 키를 안전하게 접근한다
  • satisfies로 여러 언어의 번역 구조 일치를 컴파일 타임에 검증한다
  • next-intl, react-i18next 등의 라이브러리와 통합할 수 있다
댓글 로딩 중...