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

Contact Me

© 2026 SEOJing. All rights reserved.

이펙티브 타입스크립트 2판 Day 5: 타입 추론을 방해하지 않는 API 설계

2026년 6월 28일·12분 읽기
범위: Effective TypeScript 2판 Item 22–27
오늘의 질문: “if로 한 번 확인한 값은 그 뒤에서도 계속 안전하다고 봐도 될까?”

이 글의 원천 앵커

이 글은 기존 Day 5를 다시 쓴 개정판입니다. 이전 글은 추론/넓히기와 유니온 설계를 너무 짧게 묶어 설명했고, 원래 Day 5 범위인 narrowing, alias, context inference, evolving type, async/type flow가 충분히 살아 있지 않았습니다.

이번 글에서 잃으면 안 되는 포인트는 이겁니다.

TypeScript의 narrowing은 코드 흐름을 따라가지만, 모든 런타임 변화를 대신 보장하지 않는다. alias를 만들면 원래 객체와 좁혀진 값의 관계가 흐려질 수 있다. 문맥 타입이 있으면 콜백과 객체 literal의 타입을 더 잘 추론할 수 있다. evolving type은 편하지만, 빈 배열/중간 변수의 의도가 늦게 드러날 수 있다. await, callback, closure를 지나면 “아까 확인한 값”이 여전히 같은 값인지 다시 봐야 한다.

먼저 보는 코드

ts
type User = {
  id: string;
  profile?: {
    name?: string;
  };
};

function renderName(user: User) {
  if (user.profile?.name) {
    return user.profile.name.toUpperCase();
  }
  return "익명";
}

이 코드는 자연스럽습니다. if (user.profile?.name) 안에서는 name이 truthy한 문자열로 좁혀집니다. 하지만 조금만 구조가 바뀌면 narrowing이 기대처럼 유지되지 않을 수 있습니다.

ts
function renderName(user: User) {
  const profile = user.profile;

  if (profile?.name) {
    user.profile = undefined;
    return profile.name.toUpperCase();
  }

  return "익명";
}

여기서는 profile이라는 alias가 기존 객체를 붙잡고 있습니다. user.profile을 바꿔도 profile 변수는 이전 객체를 가리킵니다. 이게 의도라면 괜찮지만, 리뷰어는 “지금 좁힌 값이 무엇이고, 이후에 바뀔 수 있는 값은 무엇인가?”를 봐야 합니다.

타입 추론과 API 경계

narrowing은 값의 흐름을 읽는다

TypeScript는 typeof, in, discriminant field, truthy check, equality check 등을 보고 타입을 좁힙니다.

ts
type Result =
  | { status: "loading" }
  | { status: "error"; message: string }
  | { status: "success"; data: User };

function render(result: Result) {
  if (result.status === "success") {
    return result.data.name;
  }
  if (result.status === "error") {
    return result.message;
  }
  return "로딩 중";
}

이런 코드는 리뷰하기 좋습니다. status 하나가 branch를 나누고, 각 branch에서 필요한 필드가 선명합니다. 반대로 truthy check만으로 도메인 의미를 표현하면 헷갈릴 수 있습니다.

ts
if (user.name) {
  render(user.name);
}

이 코드는 undefined와 빈 문자열을 같이 제외합니다. 빈 문자열이 유효한 이름인지, “이름 없음”인지 도메인 규칙에 따라 판단이 달라집니다. narrowing이 편하다고 해서 도메인 의미까지 자동으로 맞아지는 것은 아닙니다.

alias는 읽기 비용을 만든다

alias 자체가 나쁜 건 아닙니다. 오히려 긴 표현을 줄이고, 한 번 좁힌 값을 안정적으로 잡아둘 수 있습니다.

ts
const name = user.profile?.name;

if (typeof name === "string") {
  return name.toUpperCase();
}

이 코드는 좋습니다. 이후에는 user.profile.name을 다시 따라가지 않고, name이라는 지역 값을 다룹니다.

문제는 alias가 원본 객체와 섞일 때입니다.

ts
const options = props.options;

if (options?.theme) {
  updateProps();
  applyTheme(options.theme);
}

updateProps()가 실제로 props.options를 바꾸는지, options가 이전 객체를 붙잡는지, 둘 중 무엇이 의도인지 읽어야 합니다. React 코드에서는 props/state가 렌더 사이에 새 값으로 들어오고, callback은 이전 render의 값을 닫아둘 수 있습니다. JS Quizbook Day 7의 stale closure와 같은 문제입니다.

리뷰 질문은 이겁니다.

좁힌 값은 지역 primitive인가, 객체 참조인가? 원본 객체를 이후에 다시 읽는가? callback/async 경계를 지나도 같은 값을 기대하는가? alias가 의도를 선명하게 하는가, 원본과 복사본처럼 착각하게 하는가?

문맥 타입은 콜백을 살린다

TypeScript는 값이 놓인 문맥을 보고 타입을 추론합니다.

ts
type ButtonProps = {
  onClick: (event: MouseEvent) => void;
};

const props: ButtonProps = {
  onClick(event) {
    console.log(event.clientX);
  },
};

event에 직접 annotation을 쓰지 않아도 ButtonProps의 문맥 덕분에 MouseEvent로 추론됩니다. 이게 context inference입니다.

프론트엔드에서는 컴포넌트 props, event handler, array method callback, form resolver에서 자주 드러납니다.

ts
const names = users.map((user) => user.name);

users가 User[]라면 user는 자연스럽게 User입니다. 콜백마다 user: User를 반복할 필요가 없습니다. 하지만 문맥이 사라지면 추론도 약해집니다.

ts
const handlers = {
  click(event) {
    console.log(event.clientX);
  },
};

이 객체가 어떤 계약을 만족해야 하는지 없으면 event는 암묵적 any가 될 수 있습니다. 해결은 “모든 콜백에 타입을 쓰자”가 아니라, 객체 전체가 놓인 계약을 알려주는 것입니다.

ts
type Handlers = {
  click: (event: MouseEvent) => void;
};

const handlers: Handlers = {
  click(event) {
    console.log(event.clientX);
  },
};

evolving type은 편하지만 의도가 늦게 보인다

빈 배열에서 시작해 값을 넣으면 TypeScript가 타입을 진화시키는 경우가 있습니다.

ts
const ids = [];
ids.push("a");
ids.push("b");

작은 함수 안에서는 괜찮습니다. 하지만 중간에 조건이 많아지면 “이 배열에는 결국 무엇이 들어가는가?”가 늦게 드러납니다.

ts
const items = [];

if (includeUsers) {
  items.push({ type: "user", id: user.id });
}

if (includeTeams) {
  items.push({ type: "team", id: team.id });
}

이럴 때는 의도를 먼저 세우는 편이 낫습니다.

ts
type SearchItem = { type: "user"; id: string } | { type: "team"; id: string };

const items: SearchItem[] = [];

지역 계산에서는 추론이 좋지만, 도메인 리스트나 공개 반환값에서는 타입을 먼저 잡는 게 리뷰에 유리합니다.

async 경계는 narrowing을 다시 의심하게 만든다

가장 위험한 착각은 “확인했으니까 나중에도 안전하다”입니다.

ts
let currentUser: User | null = null;

async function saveProfile() {
  if (!currentUser) return;

  await saveDraft();
  return currentUser.id;
}

TypeScript가 이 코드를 어떻게 좁히는지와 별개로, 런타임에서는 await saveDraft() 동안 다른 코드가 currentUser = null로 바꿀 수 있습니다. 모듈 스코프 mutable state, React ref, 외부 store를 다룰 때 특히 그렇습니다.

더 안전한 방식은 좁힌 값을 지역 상수로 붙잡는 것입니다.

ts
async function saveProfile() {
  const user = currentUser;
  if (!user) return;

  await saveDraft();
  return user.id;
}

이 코드는 “저장 시작 시점의 user로 처리한다”는 의도를 드러냅니다. 반대로 항상 최신 user가 필요하다면 await 뒤에 다시 읽고 다시 확인해야 합니다.

Item 22–27을 코드 리뷰 언어로 다시 묶기

이번 범위는 각각 따로 보면 작은 팁처럼 보입니다. 그런데 리뷰에서는 하나의 질문으로 합쳐집니다.

이 타입은 어디서 좁혀졌고, 그 좁혀진 사실은 어디까지 믿을 수 있는가?

책의 Item 흐름을 프론트엔드 코드 리뷰 언어로 바꾸면 이렇게 볼 수 있습니다.

Item 범위책에서 보는 축리뷰에서 던질 질문
Item 22narrowing이 분기가 실제 도메인 상태를 정확히 나누는가?
Item 23alias와 제어 흐름좁힌 값과 원본 객체가 뒤섞여 있지 않은가?
Item 24문맥 타입타입 annotation을 줄인 게 추론 덕분인가, 아니면 계약이 사라진 것인가?
Item 25evolving type값이 누적되는 동안 배열/객체의 최종 모양이 너무 늦게 드러나지 않는가?
Item 26async와 callback 경계await나 callback 뒤에도 같은 값을 기대하는 근거가 있는가?
Item 27함수형 구성과 타입 흐름helper로 뺀 코드가 narrowing 정보를 보존하는가, 지워버리는가?

예를 들어 이런 helper는 보기에는 깔끔하지만, 타입 정보를 충분히 되돌려주지 않습니다.

ts
type Admin = { role: "admin"; permissions: string[] };
type Member = { role: "member"; teamId: string };
type Account = Admin | Member;

function isAdminLike(account: Account) {
  return account.role === "admin";
}

function render(account: Account) {
  if (isAdminLike(account)) {
    return account.permissions.join(", ");
  }
}

사람은 isAdminLike라는 이름을 보고 admin으로 좁혀졌다고 느낍니다. 하지만 TypeScript 입장에서는 boolean을 돌려주는 함수일 뿐입니다. 이 helper가 타입 흐름의 일부라면 type predicate가 필요합니다.

ts
function isAdmin(account: Account): account is Admin {
  return account.role === "admin";
}

function render(account: Account) {
  if (isAdmin(account)) {
    return account.permissions.join(", ");
  }
}

여기서 중요한 건 “타입 가드를 쓰자”라는 암기가 아닙니다. helper를 만들 때 런타임 판정만 숨길지, 타입 정보까지 호출자에게 돌려줄지 결정해야 한다는 점입니다.

나쁜 narrowing보다 위험한 건 애매한 상태 모델이다

Day 5는 다음 Day 6의 유효 상태 모델링으로 이어집니다. narrowing을 아무리 잘해도 상태 자체가 애매하면 리뷰는 계속 어려워집니다.

ts
type FormState = {
  loading: boolean;
  error?: string;
  data?: User;
};

function renderForm(state: FormState) {
  if (state.loading) return "불러오는 중";
  if (state.error) return state.error;
  if (state.data) return state.data.profile?.name ?? "이름 없음";
  return "대기";
}

이 코드는 narrowing을 쓰지만, 동시에 loading: false, error: "...", data: 가 함께 존재할 수도 있습니다. TypeScript가 각 branch에서 타입을 좁혀도 “이 상태 조합이 가능한가?”라는 질문은 남습니다.

ts
type FormState =
  | { status: "idle" }
  | { status: "loading" }
  | { status: "error"; message: string }
  | { status: "success"; data: User };

이렇게 바꾸면 narrowing이 단순한 방어 코드가 아니라 상태 모델의 결과가 됩니다. Day 5에서 배울 점은 “확인하고 쓰기”이고, Day 6에서 이어질 점은 “확인이 덜 필요한 상태로 설계하기”입니다.

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

변화확인할 질문
if narrowing 추가truthy check가 도메인 의미를 잘 표현하는가?
alias 추가원본 객체와 alias가 섞여 읽기 비용을 만들지 않는가?
콜백 타입 생략문맥 타입이 충분해서 생략된 것인가, 암묵적 any인가?
빈 배열/객체 누적evolving type이 의도를 늦게 숨기지 않는가?
await 뒤 값 사용좁힌 값이 async 경계 뒤에도 같은 값이어야 하는가?
React callback/effectstale closure가 타입상 안전해 보이는 값을 오래 붙잡지 않는가?

Day 5의 결론은 “타입스크립트의 narrowing을 믿지 말자”가 아닙니다. 오히려 narrowing을 제대로 쓰려면 값의 수명, alias, 문맥, async 경계를 같이 읽어야 한다는 쪽에 가깝습니다.

Day 5 타입 리뷰 퀴즈

Quiz1 / 4
Q.truthy check로 `if (user.name)`을 쓸 때 주의할 점은 무엇일까요?

Post Q&A

오케이징에게 물어보기

이펙티브 타입스크립트 2판 Day 5: 타입 추론을 방해하지 않는 API 설계 전체를 기준으로 질문과 피드백을 받아요.답을 본 뒤에는 이 내용을 댓글로 달아서 서징에게도 물어볼 수 있어요. 작성자가 직접 볼 수 있어요!

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