LogoSEO Jing
  • All Posts
  • SEO Jing
  • okayJing
  • KD Team
  • CLAB Coreteam
  • Study

Contact Me

© 2026 SEOJing. All rights reserved.

이펙티브 타입스크립트 2판 Day 7: 문자열보다 도메인 의미를 좁히기

2026년 6월 28일·9분 읽기
범위: Effective TypeScript 2판 Item 35–41
오늘의 질문: “string이면 충분한 값을 왜 굳이 더 좁혀야 할까?”

먼저 보는 코드

ts
type Toast = {
  kind: string;
  message?: string;
  duration?: number;
};

function showToast(toast: Toast) {
  if (toast.kind === "error") {
    console.error(toast.message?.toUpperCase());
  }
}

겉보기에는 유연합니다. 하지만 리뷰할 때는 오히려 물어볼 것이 많습니다.

kind에는 어떤 문자열이 들어올 수 있나? error인데 message가 없어도 되나? duration: -1은 무슨 뜻인가? 빈 문자열 ""은 정상 메시지인가, 누락인가?

Item 35–41 구간의 핵심은 “값의 원시 타입”보다 도메인에서 가능한 의미를 타입으로 표현하는 감각입니다.

도메인 의미를 좁히는 타입 설계

틀리기 쉬운 직감: 문자열은 충분히 구체적이다

kind: string은 타입스크립트 입장에서는 거의 아무 약속도 아닙니다. 오타도, 새 상태도, 아직 합의되지 않은 서버 값도 모두 통과합니다.

ts
type Toast =
  | { kind: "success"; message: string; durationMs?: number }
  | { kind: "error"; message: string; retryable: boolean }
  | { kind: "loading" };

이렇게 바꾸면 리뷰 질문이 바뀝니다. “message가 있을까?”가 아니라 “이 상태에서 message가 필요한가?”를 봅니다. 상태마다 필요한 필드가 타입에 붙어 있으니 UI 조건문도 덜 방어적으로 됩니다.

optional은 편하지만 의미를 흐릴 수 있다

선택적 필드는 “정말 없어도 되는 값”에 좋습니다. 하지만 서로 다른 상태를 optional 하나로 뭉개면 타입이 느슨해집니다.

ts
type UserCardProps = {
  userId?: string;
  loading?: boolean;
  error?: string;
};

이 타입은 loading: true이면서 userId와 error가 동시에 있는 상태도 표현합니다. 실제 UI에서는 하나만 유효할 수 있는데 타입은 모든 조합을 허용합니다.

ts
type UserCardProps =
  | { status: "loading" }
  | { status: "error"; message: string }
  | { status: "ready"; userId: UserId };

type UserId = string;

UserId = string은 런타임 검증을 추가하지는 않습니다. 그래도 “아무 문자열”이 아니라 “사용자 식별자 역할의 문자열”이라는 이름을 붙여 리뷰 대화를 선명하게 만듭니다. 더 강한 구분이 필요하면 brand 타입이나 경계 검증을 추가할 수 있습니다.

특수 값은 타입으로 끌어올리기

프론트엔드 코드에는 이런 값이 자주 나옵니다.

ts
const selectedId = ""; // 선택 없음
const page = -1; // 아직 계산 전
const status = "N"; // 비활성

짧게 쓰기는 편하지만, 다음 사람이 의미를 다시 추리해야 합니다. 특수 값이 비즈니스 의미를 갖는다면 이름 있는 타입으로 끌어올리는 편이 낫습니다.

ts
type Selection = { kind: "none" } | { kind: "selected"; id: UserId };

type PageState = { kind: "pending" } | { kind: "ready"; page: number };

이렇게 하면 "", -1, null의 의미를 코드 밖 문서나 관습에 맡기지 않아도 됩니다. Day 6에서 봤던 “유효한 상태만 표현하기”가 여기서 다시 등장합니다. 값 하나가 여러 의미를 겸하면 타입은 짧아지지만, 렌더링 branch와 리뷰 질문은 늘어납니다.

optional은 정말 선택인가, 아직 모르는 값인가

optional은 “없어도 되는 필드”를 표현할 때 좋습니다. 문제는 실제로는 아직 로드되지 않음, 서버가 보내지 않음, 사용자가 일부러 비움, 권한 때문에 숨김이 모두 ? 하나로 합쳐질 때입니다.

ts
type Profile = {
  nickname?: string;
  avatarUrl?: string;
};

이 타입만 보면 nickname이 빠진 이유를 알 수 없습니다. 사용자가 닉네임을 비운 것인지, API 응답이 아직 오지 않은 것인지, 예전 서버가 필드를 보내지 않는 것인지 컴포넌트마다 다시 해석해야 합니다.

도메인 의미가 렌더링을 바꾼다면 상태를 분리하는 편이 낫습니다.

ts
type Nickname =
  | { kind: "not-loaded" }
  | { kind: "empty" }
  | { kind: "hidden"; reason: "privacy" | "blocked" }
  | { kind: "present"; value: string };

물론 모든 optional 필드를 union으로 바꾸라는 뜻은 아닙니다. middleName?: string처럼 정말 없어도 되는 값은 optional이 자연스럽습니다. 리뷰 기준은 “이 값이 없을 때 UI branch, 저장 로직, 추적 이벤트, 접근성 문구가 달라지는가?”입니다. 달라진다면 ?보다 이름 있는 상태가 낫습니다.

타입 별칭과 brand는 목적이 다르다

type UserId = string은 런타임 안전을 만들지 않습니다. 구조적으로는 여전히 string입니다.

ts
type UserId = string;
type PostSlug = string;

function openUser(id: UserId) {}

const slug: PostSlug = "study/effective-typescript/day7";
openUser(slug); // 구조적으로는 통과할 수 있다

그래도 단순 별칭은 쓸모가 있습니다. 함수 시그니처에서 값의 역할을 읽히게 하고, 리뷰 대화를 “문자열 하나”에서 “사용자 식별자”로 끌어올립니다. 하지만 서로 섞이면 실제 장애가 되는 식별자라면 brand나 경계 검증을 고려해야 합니다.

ts
type UserId = string & { readonly __brand: "UserId" };

function parseUserId(value: unknown): UserId | null {
  return typeof value === "string" && /^user_[a-z0-9]+$/.test(value)
    ? (value as UserId)
    : null;
}

brand는 남용하면 코드가 무거워집니다. 외부 입력, 결제/권한/라우팅처럼 잘못 섞이면 비용이 큰 값에 제한적으로 쓰는 편이 좋습니다. 단순 화면 문구나 CSS class 이름까지 brand로 막으려 하면 타입 설계가 오히려 읽기 어려워집니다.

외부 입력은 좁히는 게 아니라 확인한다

도메인 타입을 좁히는 일과 외부 값을 믿는 일은 다릅니다. 아래 코드는 컴파일러를 설득하지만, 서버 응답을 검증하지는 않습니다.

ts
const response = (await fetch("/api/toast").then((r) => r.json())) as Toast;
showToast(response);

as Toast는 “내가 이미 확인했다”고 말하는 문법에 가깝습니다. 실제 확인은 별도로 해야 합니다.

ts
function isToast(value: unknown): value is Toast {
  if (!value || typeof value !== "object") return false;
  const record = value as Record<string, unknown>;

  if (record.kind === "loading") return true;
  if (record.kind === "success") return typeof record.message === "string";
  if (record.kind === "error") {
    return (
      typeof record.message === "string" &&
      typeof record.retryable === "boolean"
    );
  }
  return false;
}

이 정도 검증 함수를 매번 손으로 쓰라는 뜻은 아닙니다. 중요한 건 경계의 위치입니다. API wrapper, loader, form parser처럼 외부 값이 앱 내부로 들어오는 지점에서 한 번 정리하고, 컴포넌트 내부에서는 이미 좁혀진 도메인 타입을 다루는 구조가 읽기 쉽습니다.

타입 이름은 문서가 아니라 설계다

Item 35–41 구간은 기술적으로 어려운 타입 트릭보다 이름 짓기에 가깝습니다. string, number, boolean만 보이면 값의 의미가 호출자 머릿속에 있습니다. ToastKind, UserId, Selection, Nickname, RequestStatus처럼 이름을 붙이면 그 의미가 코드 안으로 내려옵니다.

나쁜 이름도 있습니다.

ts
type Data = string;
type Info = { value?: string };
type Flag = boolean;

이런 이름은 원시 타입보다 거의 낫지 않습니다. 좋은 이름은 가능한 값, 소유한 경계, 실패했을 때의 영향 중 하나를 드러냅니다.

값의 모양약한 이름더 나은 이름
success/error 같은 상태 값StatusToastStatus, RequestStatus
식별자 문자열IdUserId, ArticleSlug, SessionId
없을 수 있는 이름 값NameNicknameState 또는 OptionalName
참/거짓 값flagisRetryable, isOwnerVisible
""로 선택 없음 표현selectedIdSelection union

코드 리뷰에서 이름이 애매하면 타입이 맞아도 멈춰야 합니다. “이 값은 어느 도메인의 어떤 약속인가?”가 설명되지 않으면 다음 변경에서 범위가 쉽게 넓어집니다.

프론트엔드 코드 리뷰 체크리스트

string이 도메인 값의 전체 범위를 너무 넓게 열어두고 있지 않은가? optional 필드 여러 개가 사실은 서로 배타적인 상태를 나타내고 있지 않은가? "", -1, null 같은 sentinel 값의 의미가 타입 이름 없이 숨어 있지 않은가? 사용자 ID, 게시글 slug, 라우트 이름처럼 역할이 다른 문자열을 같은 string으로 섞고 있지 않은가? 외부 입력을 as로 믿고 앱 내부 도메인 타입으로 바로 들여오지 않았는가? 단순 별칭으로 충분한 값과 brand/검증이 필요한 값을 구분했는가? 상태 태그와 필드가 함께 움직이도록 discriminated union을 쓸 수 있는가?

짧은 복습

string은 원시 타입일 뿐, 도메인 약속을 설명하지 않는다. optional 필드는 편하지만 불가능한 조합이나 서로 다른 “없음”의 의미를 합치기 쉽다. sentinel 값은 이름 있는 union으로 바꾸면 리뷰 비용이 줄어든다. 타입 별칭은 의도를 읽히게 하지만 강한 구분이 필요하면 brand나 경계 검증이 필요하다. 외부 입력은 좁혀진 타입으로 “주장”하기보다, 경계에서 확인한 뒤 앱 내부 모델로 들여와야 한다.

타입을 좁힌다는 건 멋진 제네릭을 쓰는 일이 아닙니다. 리뷰해야 할 상태 공간을 줄이고, 값의 역할을 코드 안에 남기는 일에 더 가깝습니다.

Day 7 타입 설계 점검

Quiz1 / 4
Q.다음 중 discriminated union으로 바꾸는 이점이 가장 큰 타입은 무엇일까요?

Post Q&A

오케이징에게 물어보기

이펙티브 타입스크립트 2판 Day 7: 문자열보다 도메인 의미를 좁히기 전체를 기준으로 질문과 피드백을 받아요.답을 본 뒤에는 이 내용을 댓글로 달아서 서징에게도 물어볼 수 있어요. 작성자가 직접 볼 수 있어요!

0/500

포스트 목록

/study/effective-typescript
파일 14개, 폴더 0개
이펙티브 타입스크립트 2판 Day 1: 타입스크립트를 믿기 전에 알아야 할 경계이펙티브 타입스크립트 2판 Day 2: 타입을 값의 집합으로 보기이펙티브 타입스크립트 2판 Day 3: 타입 반복을 줄이는 리뷰 감각이펙티브 타입스크립트 2판 Day 4: 추론을 살리는 값 생성 패턴이펙티브 타입스크립트 2판 Day 5: 좁혀진 타입은 언제 다시 넓어지는가이펙티브 타입스크립트 2판 Day 6: 유효한 상태만 표현하는 타입 설계이펙티브 타입스크립트 2판 Day 7: 문자열보다 도메인 의미를 좁히기이펙티브 타입스크립트 2판 Day 8: any를 밖에 세우고 unknown으로 검증하기이펙티브 타입스크립트 2판 Day 9: 제네릭은 관계를 보존할 때만 강하다이펙티브 타입스크립트 2판 Day 10: 타입도 테스트하고 빠지는 분기는 막는다이펙티브 타입스크립트 2판 Day 11: 객체 타입은 열려 있고, 계약은 닫아야 한다이펙티브 타입스크립트 2판 Day 12: 타입은 공개 API의 사용법이다이펙티브 타입스크립트 2판 Day 13: 타입은 런타임과 테스트를 대신하지 않는다이펙티브 타입스크립트 2판 Day 14: JS에서 TS로 옮길 때 먼저 좁혀야 할 경계

같은 섹션의 대표 이미지

14 posts · latest first
런타임 값 검증, DOM 환경, 테스트, 컴파일러 성능으로 이어지는 타입스크립트 경계 다이어그램
Study26. 07. 09.

이펙티브 타입스크립트 2판 Day 13: 타입은 런타임과.

Item 74–78 범위를 바탕으로 런타임 타입 재구성, DOM과 환경 모델, 단위 테스트 관계, 컴파일러 성능을 코드 리뷰 관점에서 정리합니다.

26. 07. 09.SEOJing
JavaScript 현대화에서 @ts-check, allowJs, 모듈별 전환, noImplicitAny로 이어지는 마이그레이션 단계 다이어그램
Study26. 07. 09.

이펙티브 타입스크립트 2판 Day 14: JS에서 TS로 옮길.

Item 79–83 범위를 바탕으로 JS 현대화, @ts-check와 JSDoc, allowJs, 모듈별 마이그레이션, noImplicitAny를 코드 리뷰 관점에서 정리합니다.

26. 07. 09.SEOJing
내부 구현 타입에서 문서화된 public API 타입으로 넘어가는 경계 다이어그램
Study26. 07. 08.

이펙티브 타입스크립트 2판 Day 12: 타입은 공개 API의.

Item 67–73 범위를 바탕으로 public API 타입, TSDoc, this callback, module augmentation, TypeScript 기능 선택, source map을 코드 리뷰 관점에서 정리합니다.

26. 07. 08.SEOJing
Object iteration, Record, tuple/rest, XOR, brand, @types 버전 관리를 연결한 타입 리뷰 다이어그램
Study26. 07. 07.

이펙티브 타입스크립트 2판 Day 11: 객체 타입은 열려.

Item 60–66 범위를 바탕으로 Object iteration, Record, tuple/rest, XOR, brand, @types 버전 경계를 코드 리뷰에서 다루는 방법을 정리합니다.

26. 07. 07.SEOJing
타입 계약, 타입 테스트, never exhaustiveness, 코드 생성 경계를 보여주는 다이어그램
Study26. 07. 06.

이펙티브 타입스크립트 2판 Day 10: 타입도 테스트하고.

Item 55–59 범위를 바탕으로 type test, 타입 표시, 꼬리 재귀 한계, 코드 생성, never exhaustiveness를 코드 리뷰에서 다루는 방법을 정리합니다.

26. 07. 06.SEOJing
외부 경계, 제네릭, 조건부 타입, 템플릿 리터럴 타입의 역할과 과한 타입 계산의 위험을 보여주는 다이어그램
Study26. 07. 05.

이펙티브 타입스크립트 2판 Day 9: 제네릭은 관계를 보존할.

Item 49–54 범위를 바탕으로 type coverage, 제네릭, 조건부 타입, 템플릿 리터럴 타입을 코드 리뷰에서 어떻게 판단할지 정리합니다.

26. 07. 05.SEOJing
외부 JSON을 unknown으로 받고 검증 뒤 도메인 타입으로 넘기는 경계와 any가 내부로 번지는 위험을 보여주는 다이어그램
Study26. 07. 04.

이펙티브 타입스크립트 2판 Day 8: any를 밖에 세우고.

Item 42–48 범위를 바탕으로 any, unknown, 타입 단언, monkey patching, soundness 함정을 외부 입력 경계와 코드 리뷰 관점에서 정리합니다.

26. 07. 04.SEOJing
넓은 문자열과 선택적 필드를 도메인 이름과 유효 상태 유니온으로 좁히는 다이어그램
Study26. 06. 29.

이펙티브 타입스크립트 2판 Day 7: 문자열보다 도메인.

Item 35–41 범위를 바탕으로 string 남용, optional 필드, 특수 값, 도메인 이름 설계를 코드 리뷰 관점에서 정리합니다.

26. 06. 29.SEOJing
API 응답을 유효한 상태 유니온으로 변환하는 흐름 다이어그램
Study26. 06. 28.

이펙티브 타입스크립트 2판 Day 6: 유효한 상태만 표현하는.

Item 28–34 범위를 바탕으로 추론 위치, 유효 상태 모델링, API 입출력, null 경계, union 설계를 프론트엔드 코드 리뷰 관점에서 확장 정리합니다.

26. 06. 28.SEOJing
타입스크립트 추론이 값에서 API 경계로 흐르는 과정을 보여주는 다이어그램
Study26. 06. 27.

이펙티브 타입스크립트 2판 Day 5: 좁혀진 타입은 언제.

Item 22–27 범위를 바탕으로 narrowing, alias, context inference, evolving type, async/type flow를 프론트엔드 코드 리뷰 관점에서 다시 정리합니다.

26. 06. 27.SEOJing
TypeScript에서 넓은 타입으로 먼저 담으면 key와 literal 정보가 사라지고 리뷰 체크가 약해지는 흐름 다이어그램
Study26. 06. 26.

이펙티브 타입스크립트 2판 Day 4: 추론을 살리는 값 생성.

Effective TypeScript 2판의 Item 16–21을 바탕으로 index signature, 타입 추론 기본, 변수/객체 생성 패턴을 프론트엔드 코드 리뷰 관점에서 정리합니다.

26. 06. 26.SEOJing
반복된 타입 선언을 원본 타입에서 파생한 타입으로 바꿔 리뷰 체크를 강하게 만드는 흐름 다이어그램
Study26. 06. 25.

이펙티브 타입스크립트 2판 Day 3: 타입 반복을 줄이는 리뷰.

Effective TypeScript 2판의 Item 11–15를 바탕으로 excess property check, 함수식 타입, type vs interface, readonly, 타입 반복 제거를 프론트엔드 코드 리뷰 관점에서 정리합니다.

26. 06. 25.SEOJing
TypeScript 타입을 값의 집합, type space, value space, assertion 경계로 정리한 다이어그램
Study26. 06. 24.

이펙티브 타입스크립트 2판 Day 2: 타입을 값의 집합으로.

Effective TypeScript 2판의 Item 6–10을 바탕으로 타입 시스템을 탐색하는 법, 타입을 값의 집합으로 보는 관점, type/value space, 타입 단언의 경계를 코드 리뷰 관점에서 정리합니다.

26. 06. 24.SEOJing
TypeScript를 JavaScript 위의 정적 타입 계층, 타입 제거, 구조적 타이핑, any의 구멍으로 정리한 다이어그램
Study26. 06. 23.

이펙티브 타입스크립트 2판 Day 1: 타입스크립트를 믿기.

Effective TypeScript 2판의 Item 1–5를 바탕으로 TypeScript와 JavaScript의 관계, tsconfig, 타입 제거, 구조적 타이핑, any의 위험을 프론트엔드 코드 리뷰 관점에서 정리합니다.

26. 06. 23.SEOJing