TypeScript 제네릭 제약과 keyof 안전한 helper 만들기

··설명 약 7분·작성: 해비·블로그 소개

제네릭 helper에서 필요한 속성과 허용할 키를 어떻게 제한하나요?

제네릭 함수가 아무 값이나 받으면 실제로 필요한 속성을 사용할 수 없습니다. T extends HasId처럼 최소 구조를 요구하거나 K extends keyof T로 존재하는 키만 받게 합니다.

적용 조건·범위: src/core/collection.ts에 findById와 getValue를 추가합니다. 호출부의 selectedId가 null이면 먼저 분기하고, 결과가 undefined가 아닌 경우에만 property를 읽으세요.

본문 근거: TypeScript Generic Constraints

직접 확인: 오류에서 수정까지

TypeScript 제네릭 제약과 keyof 안전한 helper 만들기 · 독립 실습

얻을 결과: K extends keyof T로 입력 객체의 실제 키만 허용합니다.

예제 크게 보기 (새 탭)

단독 검사는 tsconfig.json이 없는 새 폴더에서 example.ts 한 파일씩 교체하며 진행합니다. TypeScript가 없다면 설치 글에서 Node.js/npm·TypeScript 준비를 확인하세요. 타입 검사 성공은 코드 실행 성공과 구분합니다.

독자 질문: 존재하지 않는 객체 키를 helper 인수에서 막으려면?

얻을 결과: K extends keyof T로 입력 객체의 실제 키만 허용합니다.

오류 코드를 별도 example.ts에 복사합니다.

function getValue<T, K extends keyof T>(item: T, key: K): T[K] { return item[key]; }
getValue({ id: '1' }, 'title');

예상 타입 오류 (TS2345): ‘title’은 객체의 키 ‘id’에 해당하지 않습니다. 존재하는 key를 전달합니다.

위 코드를 다음 수정 예제로 교체합니다.

function getValue<T, K extends keyof T>(item: T, key: K): T[K] { return item[key]; }
const id = getValue({ id: '1' }, 'id');
console.log(id);
npx tsc --strict --noEmit example.ts

기존 최소 예제 확인 기록: TypeScript 7.0.2의 별도 최소 예제에서 오류 TS2345, 수정 후 exit 0 확인. TaskFlow 전체 체크포인트·브라우저 실행은 미확인.

다음 학습: 12회차에서 Partial·Pick·Omit·Record로 입력 타입을 파생합니다.

제약 없는 generic

function getId<T>(
  value: T,
) {
  return value.id;
}

T가 id를 가진다는 보장이 없기 때문에 오류가 발생합니다.

extends constraint

type HasId = {
  id: string;
};


function getId<
  T extends HasId
>(
  value: T,
): string {
  return value.id;
}

T는 최소한 id:string을 가져야 합니다.

추가 property가 있어도 괜찮습니다.

getId({
  id: '1',
  title: 'Task',
});

findById

function findById<
  T extends {
    id: string;
  }
>(
  items: T[],
  id: string,
): T | undefined {
  return items.find(
    (item) =>
      item.id === id,
  );
}

Task뿐 아니라 User, Project 등 id를 가진 타입에 사용할 수 있습니다.

keyof constraint

객체의 실제 key만 받게 만들 수 있습니다.

function getValue<
  T,
  K extends keyof T,
>(
  object: T,
  key: K,
): T[K] {
  return object[key];
}
const task = { id: '1', title: '학습' };
const title =
  getValue(
    task,
    'title',
  );

title은 string으로 추론됩니다.

존재하지 않는 key는 오류입니다.

getValue(
  task,
  'name',
);

constraint는 inheritance와 다릅니다

T extends HasId는 T가 HasId 구조를 만족해야 한다는 타입 제약입니다.

클래스 상속처럼 runtime 객체 관계를 만드는 문법이 아닙니다.

과도한 constraint

function findTaskLike<
  T extends {
    id: string;
    title: string;
    completed: boolean;
    createdAt: Date;
    ownerId: string;
  }
>(...)

실제로 필요한 것이 id뿐이라면 제약이 너무 강합니다.

T extends {
  id: string;
}

최소 구조만 요구합니다.

제약이 맞는다는 것은 배열에서 해당 ID가 발견된다는 보장이 아닙니다. findById는 찾지 못하면 undefined를 반환하므로 결과를 확인한 뒤 속성을 읽습니다. 구조를 만족하는 추가 속성은 허용하면서 검색 성공 여부는 별도 분기로 다룹니다.

원리 확인: TypeScript Generic Constraints (2026-10-12). 아래 기대 결과와 기존 실행 기록은 구분해서 읽습니다.

학습 완료 체크리스트

getValue가 허용하는 키와 findById가 요구하는 구조를 비교해 보세요. id만 읽는 함수에 title·completed까지 요구하지 않고, 없는 ID의 undefined 결과는 호출부에서 따로 좁혀야 합니다.


작은 변형으로 확인하기

getValue({ id: '1', title: '공부' }, 'title')은 통과합니다. 존재하지 않는 name 키는 오류가 나야 합니다.

선택 실습: 할 일 앱에 적용하기

TaskFlow는 작업을 추가·완료·삭제하는 할 일 앱입니다. 이 선택 실습에서는 JavaScript 코드를 TypeScript로 확장합니다.

TaskFlow의 findById와 property helper를 안전한 generic으로 만듭니다.

선택: 연재 프로젝트와 전체 자료

연재 프로젝트를 이어 만드는 분을 위한 자료입니다. 이 글의 개념과 예제만 찾으셨다면 필요한 파일과 실행 방법만 펼쳐보세요.

TaskFlow 프로젝트 적용

TypeScript 제네릭 제약과 keyof 안전한 helper 만들기 · 단계별 TaskFlow

타입을 적용한 TaskFlow에서 작업을 추가·완료 전환·삭제하세요. 타입만 바뀐 단계는 이전과 화면이 같습니다.

예제 크게 보기 (새 탭)

시작·완료 파일과 적용 순서에서 이번 변경 파일의 전체 내용과 적용 순서를 확인합니다.

시작본과 이번 변경

TypeScript 제네릭 쉽게 이해하기: props와 API 응답 타입으로 감 잡기의 완료 체크포인트를 기준으로 이어갑니다. 변경하지 않는 파일은 그 시작 상태를 유지합니다.

src/core/collection.ts에 findById와 getValue를 추가합니다. 호출부의 selectedId가 null이면 먼저 분기하고, 결과가 undefined가 아닌 경우에만 property를 읽으세요.

이전 체크포인트

typescript-step-10-generics

이번 목표

TaskFlow의 findById와 property helper를 안전한 generic으로 만듭니다.

전체 파일·코드 보기

src/core/collection.ts

export function findById<
  T extends {
    id: string;
  }
>(
  items: T[],
  id: string,
): T | undefined {
  return items.find(
    (item) =>
      item.id === id,
  );
}


export function getValue<
  T,
  K extends keyof T,
>(
  object: T,
  key: K,
): T[K] {
  return object[key];
}

사용:

const task =
  findById(
    tasks,
    selectedId,
  );


if (task) {
  const title =
    getValue(
      task,
      'title',
    );
}

검증

npx tsc --noEmit

중간부터 시작하는 전체 파일과 적용 순서

이번 글 시작 ZIP · 이번 글 완료 ZIP

시작 ZIP은 직전 프로젝트 단계 typescript-step-10-generics의 실제 완료 파일입니다. 프로젝트 순서를 이어가는 기준이며 사이트의 기술 읽기 순서와는 구분합니다.

  1. 시작 ZIP을 새 폴더에 풉니다. 이 폴더가 작업 루트이며 기존 체크포인트 파일을 별도로 찾을 필요가 없습니다.
  2. 기존 first·last를 유지하고 findById와 getValue를 같은 collection 파일에 추가합니다.
  3. 아래 변경 파일을 같은 상대 경로에 추가하거나 전체 교체합니다. 표시하지 않은 파일과 images는 시작본 그대로 유지합니다. package-lock.json은 완료 ZIP에 있는 파일을 함께 적용합니다.
  4. 작업 루트에서 명령을 실행하고 예상 결과를 확인합니다. 완료 ZIP은 직접 적용한 결과를 비교하는 기준입니다.

추가·교체 파일

  • CHANGES.md — 전체 교체
  • README.md — 전체 교체
  • tests/contracts.ts — 전체 교체
  • src/core/collection.ts — 전체 교체

실행과 확인

npm ci
npm run dev
npm run typecheck
npm test
npm run build

개발 서버는 실행 상태로 두고 다른 터미널에서 검사 명령을 실행하세요. Node.js 24.18.0에서 검증했습니다. JavaScript는 http://127.0.0.1:4173, TypeScript는 Vite 실행 출력의 주소로 접속합니다.

예상 결과: 없는 ID는 undefined이며 존재하지 않는 객체 키는 컴파일에서 거부됩니다.

기존 ZIP 검증 기록: TypeScript strict 검사·긍정/부정 타입 계약·런타임 검사·Vite 빌드 통과. 시작 ZIP에 이 글의 변경 파일만 적용한 결과와 완료 ZIP의 모든 파일 내용이 일치했습니다. 브라우저 화면 배치·실제 기기·외부 CDN 가용성은 이 검사에 포함하지 않았습니다.

이 절의 전체 파일은 ZIP과 일치합니다. 위쪽 원문의 짧은 예제나 생략 표시가 있는 부분 코드는 개념·변경 위치 설명용입니다. 서버 저장과 인증은 제공하지 않으며 JSON fixture는 로컬 읽기 실습용입니다.

복사할 변경 파일 전체 코드

tests/contracts.ts — 전체 파일

import type { Task } from '../src/types/task.js';
const valid: Task = {id:'1',title:'공부',completed:false};
// @ts-expect-error completed must be boolean
const invalid: Task = {id:'1',title:'공부',completed:'no'};
void valid; void invalid;
import { readTitle } from '../src/core/selection.js';
const nullable = readTitle(null);
// @ts-expect-error null result must be narrowed
const definite: string = nullable;
void definite;
import type { TaskKey } from '../src/types/task-key.js';
// @ts-expect-error nonexistent property
const badKey: TaskKey = 'missing';
void badKey;
import { TASK_FILTER_LABELS } from '../src/constants/task.js';
// @ts-expect-error missing is not a filter key
const badLabel = TASK_FILTER_LABELS.missing;
void badLabel;
import { getValue } from '../src/core/collection.js';
// @ts-expect-error nonexistent key must fail
getValue({title:'ok'}, 'missing');

src/core/collection.ts — 전체 파일

export function first<T>(
  items: T[],
): T | undefined {
  return items[0];
}


export function last<T>(
  items: T[],
): T | undefined {
  return items[
    items.length - 1
  ];
}
export function findById<
  T extends {
    id: string;
  }
>(
  items: T[],
  id: string,
): T | undefined {
  return items.find(
    (item) =>
      item.id === id,
  );
}


export function getValue<
  T,
  K extends keyof T,
>(
  object: T,
  key: K,
): T[K] {
  return object[key];
}

완료 체크포인트

typescript-step-11-generic-constraints

다음 학습

다음 글에서는 기존 Task 타입에서 CreateTaskInput, UpdateTaskInput 같은 타입을 Partial, Pick, Omit, Record로 파생합니다.

다음 글: TypeScript Utility Types Partial Pick Omit Record 실전

글 소개·학습 준비·전체 목차
TypeScript 제네릭 제약과 keyof 안전한 helper 만들기

TypeScript · 11 / 22 · 필수 학습 · Generic Constraints

제네릭 helper가 필요한 속성과 실제 키만 받게 하려면? T extends HasId로 함수가 사용하는 최소 구조를 요구하고 K extends keyof T로 키 인수를 입력 객체에 연결합니다. 반환 타입 T[K]도 함께 유지해 존재하지 않는 키를 호출 단계에서 막습니다.

이번 글에서 배우는 내용

  • generic constraint
  • T extends …
  • K extends keyof T
  • property 접근
  • 반환 타입 T[K]
  • 과도한 제약을 피하는 기준

단독 학습 선수 지식

제네릭·keyof·인덱스 접근 타입을 알면 됩니다. 이전 TaskFlow 파일 없이 직접 확인 예제로 학습할 수 있습니다.

목차

이 글이 도움이 되었나요?

조회 중

읽음 기록

읽음 — / 전체 22개 · 필수 21개

이 글을 쓴 해비

웹사이트를 제작·운영하고, 개인 서비스와 자동화·AI 도구를 만듭니다. 직접 만들며 배운 내용과 문제 해결 과정을 BlogFlow에 기록합니다.

새 글 받아보기

RSS 리더에서 BlogFlow의 새 글을 확인할 수 있습니다.

RSS 피드 구독하기

댓글 남기기