Navigation

1. 타입과 계약

함수 시그니처와 커스텀 타입에 계약을 명시하고, 기존 콜백과 타입을 재사용합니다. 독립된 객체 계약은 interface, 타입 계산과 조합은 type으로 선언합니다. as const 객체로 값과 타입을 함께 정의하고, 타입 단언 대신 unknown의 범위를 좁힙니다.

1.1 새 타입을 선언하기 전에 이미 있는 타입과 스키마를 먼저 씁니다

뜻이 그대로면 기존 타입이나 스키마를 그대로 참조해 같은 형태를 두 번 선언하지 않습니다.

새 타입을 적기 전에 뜻과 수명이 같은 기존 타입이나 스키마를 먼저 찾습니다. 필드 이름, 타입, 선택 여부, 읽기 전용 여부까지 같으면 그 계약을 그대로 참조합니다. 구조가 같아도 단위나 도메인 역할이 다르면 합치지 않습니다.

flowchart LR
	q1{"뜻과 수명이 같은<br>계약이 있는가?"} -- 예 --> q2{"단위, 도메인<br>역할도 같은가?"} -- 예 --> q3{"필드 조건이<br>모두 같은가?"} -- 예 --> r3("그대로 참조")
	q1 -- 아니요 --> r1("새 계약 선언")
	q2 -- 아니요 --> r2("별도 계약")
	q3 -- 아니요 --> r4("새 계약 선언 후<br>원본에서 파생")
찾은 기존 계약처리
뜻이 같고 필드 조건도 모두 같음그대로 참조합니다
뜻은 같지만 필드 일부만 필요하거나 타입, 선택 여부, 읽기 전용 여부가 다름새 계약을 선언하고 필드는 규칙 1.2에 따라 원본에서 파생합니다
원본 입력과 정규화 결과처럼 역할이 다름필드가 같아도 별도 계약을 둡니다

소유자 이동, 이름이나 주석 변경, 그대로인 계약의 새 사용처, 외부나 생성된 형태의 사용은 이 규칙의 대상이 아닙니다. 규칙을 적용하려고 요청에 없는 *Params나 *Input을 만들지 않습니다.

기존 계약과 같은 구조를 다시 선언합니다.

// 이미 있는 계약: UserRecord { id: string; name: string; email: string }
// 필드 이름, 타입, 선택 여부가 그대로인데 새로 선언
interface InviteRecipient {
	id: string;
	name: string;
	email: string;
}

export const sendInvites = (recipients: InviteRecipient[]): Promise<void> => { /* … */ };

형태가 같으면 기존 계약을 그대로 참조합니다.

// 이미 있는 계약: UserRecord { id: string; name: string; email: string }
/**
 * 초대 대상은 사용자 레코드 그대로. 필드가 같아 따로 선언 안 함
 */
export const sendInvites = (recipients: UserRecord[]): Promise<void> => {
	return client.post("/invites", recipients);
};

선택 여부가 다른데 기존 계약을 그대로 써서 없는 값을 빈 문자열로 채웁니다.

// 이미 있는 계약: UserRecord { id: string; name: string; email: string }
export const sendInvite = (draft: UserRecord): Promise<void> => { /* … */ };

// 폼은 이름을 비울 수 있고 id가 아직 없음. 빈 문자열을 채워야 타입이 맞음
sendInvite({ id: "", name: "", email });

선택 여부가 하나라도 다르면 새로 선언하되 필드는 원본에서 파생합니다.

// 이미 있는 계약: UserRecord { id: string; name: string; email: string }
/**
 * 초대 폼 입력. 이름을 비울 수 있고 id가 아직 없어 UserRecord와 필드 조건이 다름
 */
interface InviteDraft {
	/**
	 * 받는 사람 이메일
	 */
	email: UserRecord["email"];
	/**
	 * 표시 이름. 비우면 이메일 표시
	 */
	name?: UserRecord["name"];
}

/**
 * 초대 한 건 발송
 */
export const sendInvite = (draft: InviteDraft): Promise<void> => {
	return client.post("/invites", draft);
};

함께 보기

1.2 부분집합은 Pick 대신 인덱스 접근 interface로 파생합니다

고른 필드의 이름과 출처를 드러내고 선택 여부와 읽기 전용 속성을 보존합니다.

필드를 고르는 방식

기존 계약의 일부 필드는 interface에 원본["필드"]로 적고, Pick은 쓰지 않습니다. 계약 전체를 재사용할지는 규칙 1.1가 정합니다.

원본에 필드가 추가될 때선언 방식예
따라 늘면 안 됨interface + 인덱스 접근UserPreview에는 새 ssn 필드가 들어오지 않습니다
따라 늘어야 함Omit<원본, "뺄 이름">외부 계약의 필드 추가를 그대로 받습니다
전체 필드를 선택 또는 필수로 바꾸면서 원본의 필드 추가도 따라야 함Partial, Required열린 집합에만 씁니다
필드 하나의 타입만 필요함인덱스 접근 별칭type ProductId = ProductRecord["id"]

Omit은 제외한 이름이 원본에서 사라져도 오류가 나지 않으므로 원본 변경 시 이름을 확인합니다. ReturnType, Parameters, Awaited는 필드를 고르는 연산이 아니므로 대상이 아닙니다.

값을 좁혀 받을 때

값을 좁히거나 필수로 바꿀 때도 원시 타입을 다시 적지 않고 원본에서 파생해 출처를 남깁니다.

필드 값을 원본과 다르게 받을 때적는 법예
필드 값 중 일부만 받음Extract<원본["필드"], 좁힌 타입>TableCellProps의 padding 중 normal, none만 받습니다
원본이 비워 두는 필드를 필수로 받음NonNullable<원본["필드"]>align: NonNullable<TableCellProps["align"]>
유니언 계약 중 한 갈래만 받음Extract<원본, 판별 필드>Extract<TextFieldProps, { variant?: "outlined" }>

선택, 읽기 전용 보존

보존할 계약적는 법
선택 필드의 키 생략?를 직접 붙입니다. 없으면 string | undefined여도 필수 필드입니다
읽기 전용 필드readonly를 직접 붙입니다. 인덱스 접근만으로는 복사되지 않습니다
exactOptionalPropertyTypes가 켜진 프로젝트의 선택 필드name?: Required<Src>["name"]으로 undefined 대입을 막습니다. 옵션이 꺼진 프로젝트는 Src["name"]으로 충분합니다

Pick으로 골라 필드 이름과 설명이 사라집니다.

// 원본 계약
interface UserRecord {
	readonly id: string;
	name?: string;
	email: string;
}

type UserPreview = Pick<UserRecord, "id" | "name">;

필드마다 출처를 인덱스 접근으로 가져오고 ?, readonly를 직접 적습니다.

/**
 * 사용자 미리보기 계약
 */
interface UserPreview {
	/**
	 * 사용자 식별자
	 */
	readonly id: UserRecord["id"];
	/**
	 * 목록에 표시할 이름. 원본이 선택 필드라 여기서도 선택
	 */
	name?: UserRecord["name"];
}

인덱스 접근으로 옮기면서 선택 여부와 읽기 전용 속성을 누락합니다.

// 원본: ProductRecord.id는 readonly, UserRecord.name은 선택 필드
/**
 * 상품 목록 한 행의 표시 계약
 */
interface ProductListRow {
	/**
	 * 상품 식별자
	 */
	id: ProductRecord["id"];
	/**
	 * 마지막 수정자 이름
	 */
	ownerName: UserRecord["name"];
}

여러 계약에서 필드를 모으고 ?, readonly를 직접 적습니다.

/**
 * 상품 목록 한 행의 표시 계약
 */
interface ProductListRow {
	/**
	 * 상품 식별자
	 */
	readonly id: ProductRecord["id"];
	/**
	 * 마지막 수정자 이름. 원본이 선택 필드라 여기서도 선택
	 */
	ownerName?: UserRecord["name"];
}

원본을 따라가야 하는 열린 집합을 인덱스 접근으로 닫아 새 필드를 놓칩니다.

/**
 * 내보내기 요청 전송 형태
 */
interface ExportRequestBody {
	/**
	 * 내보낼 문서 ID
	 */
	documentId: GeneratedExportRequest["documentId"];
	/**
	 * 내보내기 형식
	 */
	format: GeneratedExportRequest["format"];
}

원본을 따라가야 하는 열린 집합은 Omit으로 뺍니다.

/**
 * 내보내기 요청 전송 형태. 생성된 계약의 새 필드는 그대로 따라가고 서버가 채우는 시각만 제외
 */
type ExportRequestBody = Omit<GeneratedExportRequest, "requestedAt">;

좁힌 값을 원시 타입으로 다시 적어 원본과의 연결이 사라집니다.

// 원본: TableCellProps.align은 선택 필드, padding은 normal, checkbox, none
/**
 * 주문 내보내기 표 칸 표시 계약
 */
interface OrderExportCell {
	/**
	 * 칸 정렬
	 */
	align: "inherit" | "left" | "center" | "right" | "justify";
	/**
	 * 칸 여백. checkbox 칸 없음
	 */
	padding?: "normal" | "none";
}

원본 필드를 NonNullable, Extract로 파생해 출처와 좁힘을 함께 남깁니다.

/**
 * 주문 내보내기 표 칸 표시 계약. align, padding은 TableCell로 그대로 전달
 */
interface OrderExportCell {
	/**
	 * 칸 정렬. TableCell은 비울 수 있지만 이 표는 칸마다 지정
	 */
	align: NonNullable<TableCellProps["align"]>;
	/**
	 * 칸 여백. checkbox 칸이 없어 normal, none만 허용
	 */
	padding?: Extract<TableCellProps["padding"], "normal" | "none">;
}

함께 보기

1.3 매개변수마다 표기하지 않고 함수를 담는 변수에 타입을 붙입니다

호출 계약을 한곳에서 읽고 같은 시그니처를 반복 선언하지 않습니다.

기존 호출 계약이 있으면 매개변수와 반환 타입을 반복하지 않고 함수를 담는 변수에 붙입니다.

상황타입 표기
interface, 객체 계약, 프레임워크 별칭이 있음기존 호출 계약을 함수 변수에 붙입니다
계약에 콜백 필드가 있음Contract["onSelect"]로 가져옵니다
같은 시그니처를 쓰는 구현이 둘 이상임함수 타입 별칭을 선언합니다
맞는 계약도 없고 구현도 하나뿐임매개변수 타입을 직접 적습니다. 별칭을 새로 만들지 않습니다

문맥으로 추론되는 일회성 인라인 콜백은 대상이 아닙니다. 쓰지 않는 계약 매개변수는 규칙 1.5에 따라 남깁니다.

계약이 있는데 시그니처를 다시 적습니다.

// 이미 있는 계약
/**
 * 사용자 화면 표시 문자열 계약
 */
interface UserFormatters {
	/**
	 * 권한 코드를 화면 문자열로
	 */
	toRoleLabel: (role: UserRole) => string;
	/**
	 * 활성 여부를 화면 문자열로
	 */
	toActiveLabel: (isActive: boolean) => string;
}

/**
 * 권한 코드를 화면 문자열로 변환
 */
const toRoleLabel = (role: UserRole): string => {
	return role === "admin" ? "관리자" : "직원";
};

이미 있는 계약에서 시그니처를 가져와 함수 전체에 타입을 붙입니다.

// 이미 있는 계약
/**
 * 사용자 화면 표시 문자열 계약
 */
interface UserFormatters {
	/**
	 * 권한 코드를 화면 문자열로
	 */
	toRoleLabel: (role: UserRole) => string;
	/**
	 * 활성 여부를 화면 문자열로
	 */
	toActiveLabel: (isActive: boolean) => string;
}

/**
 * 권한 코드를 화면 문자열로 변환
 */
const toRoleLabel: UserFormatters["toRoleLabel"] = (role) => {
	return role === "admin" ? "관리자" : "직원";
};

같은 시그니처를 쓰는 구현마다 매개변수와 반환 타입을 다시 적습니다.

/**
 * 앞뒤 공백을 걷어 낸 요청 문자열
 */
const toRequest = (request: string): string => {
	return request.trim();
};

/**
 * 검색어용으로 공백을 한 칸으로 줄인 요청 문자열
 */
const toSearchRequest = (request: string): string => {
	return request.replaceAll(/\s+/g, " ").trim();
};

같은 시그니처를 쓰는 구현이 둘 이상이면 함수 타입 별칭을 선언합니다.

/**
 * 요청 문자열 변환 계약
 */
type ToRequest = (request: string) => string;

/**
 * 앞뒤 공백을 걷어 낸 요청 문자열
 */
const toRequest: ToRequest = (request) => {
	return request.trim();
};

/**
 * 검색어용으로 공백을 한 칸으로 줄인 요청 문자열
 */
const toSearchRequest: ToRequest = (request) => {
	return request.replaceAll(/\s+/g, " ").trim();
};

함께 보기

1.4 커스텀 타입과 선언형 형태를 문서화합니다

구현을 읽기 전에 도메인 전용 계약을 이해할 수 있습니다.

직접 선언한 형태

직접 선언한 타입과 형태는 헤더와 필드를 구분해 문서화합니다. 주석 내용은 규칙 6.3의 한국어 기준을 따릅니다. 아래 세 선언에는 모두 헤더 주석을 씁니다.

선언필드 주석
커스텀 type, interface, 스키마 최상단원본에서 가져온 필드에도 각각 씁니다
객체형 상수달지 않습니다. constant 폴더와 enum 성격 상수 객체도 같습니다
인덱스 접근 별칭, Omit 결과선언한 필드가 없어 달지 않습니다

기존 형태를 쓸 때

기존 형태를 쓰는 방식문서화 범위
새 입력, 출력 계약 역할을 맡음필드가 그대로여도 기존 선언의 헤더와 필드 주석에 새 역할을 설명합니다
외부, 생성된, 읽기 전용, 공용 형태를 그대로 씀선언을 고치거나 문서화용 지역 별칭을 만들지 않습니다
이름 없이 구현에서 추론되는 익명 객체대상이 아닙니다. select의 익명 반환값도 그대로 둡니다

필드 설명을 생략하거나 예전 방식으로 헤더에 몰아 씁니다.

/**
 * 게시 결과 요약
 * 게시 대상 문서 ID
 */
interface PublishResult {
	documentId: string;
	published: boolean;
}

헤더와 필드별 문서 주석을 씁니다.

/**
 * 게시 결과 요약
 */
export interface PublishResult {
	/**
	 * 게시 대상 문서 ID
	 */
	documentId: string;
	/**
	 * 게시 성공 여부
	 */
	published: boolean;
}

객체형 상수는 헤더만 달고 키에는 달지 않습니다.

/**
 * 상품 상태 코드. 서버 enum과 같은 값
 */
export const product_status = {
	draft: "draft",
	active: "active",
} as const;

함께 보기

1.5 쓰지 않는 매개변수도 밑줄을 붙여 남깁니다

계약에 있는 매개변수를 유지하면서 사용하지 않는 매개변수를 표시합니다.

기존 콜백, 프레임워크 계약의 매개변수는 쓰지 않아도 생략하지 않고 _ 접두사로 남깁니다. 커링한 핸들러의 마지막 콜백과 매개변수를 하나도 쓰지 않는 구현에도 똑같이 적용합니다. MouseEventHandler의 이벤트를 쓰지 않으면 () => 대신 (_event) =>로 받습니다.

계약의 일부인 콜백 매개변수를 생략합니다.

/**
 * 로그 싱크 콜백 계약
 */
type LogSink = (message: string, level: "info" | "error") => void;

const noopLog: LogSink = () => {
	// 아무 일도 하지 않는 싱크
};

계약은 유지하고 쓰지 않는 매개변수만 _로 표시합니다.

/**
 * 로그 싱크 콜백 계약
 */
type LogSink = (message: string, level: "info" | "error") => void;

/**
 * 아무것도 남기지 않는 로그 싱크. 테스트의 출력 끄기용
 */
const noopLog: LogSink = (_message, _level) => {};

1.6 단언으로 넘기지 않고 unknown을 좁혀서 씁니다

컴파일을 통과시키려고 타입 검사를 끄는 자리가 남지 않습니다.

값의 출처

형태를 모르는 값은 unknown으로 받아 좁힙니다. 컴파일 오류를 없애려고 as, !, any, @ts-expect-error로 검사를 우회하지 않습니다. 저장소, 메시지, URL, 검증하지 않은 응답이 앱 밖의 값입니다.

flowchart LR
	q1{"앱 밖에서<br>들어온 값인가?"} -- 아니요 --> q2{"외부 패키지 타입이<br>실제 동작과 다른가?"} -- 예 --> r2("단언 위에<br>확인할 수 있는 이유")
	q1 -- 예 --> r1("스키마로 검증하고<br>결과에서 타입을 얻음")
	q2 -- 아니요 --> r3("분기로 좁히거나<br>계약을 고침")

표기별 보장 범위

표기보장하는 것과 한계
as, !실행 중 값을 검증하지 않습니다
any이후 타입 검사를 약화합니다
@ts-expect-error다음 줄 오류를 억제하며, 해당 오류가 없어지면 오류를 보고합니다
as const리터럴 추론과 읽기 전용 표기를 유지합니다. 금지 대상이 아닙니다
satisfies식이 계약에 맞는지 검사합니다. 대상 타입으로 전체를 넓히지는 않지만 문맥 추론에 영향을 줄 수 있습니다

예외 주석

예외 주석은 규칙 6.5를 따릅니다. “타입이 이상해서”는 확인할 수 있는 근거가 아닙니다. any와 !는 규칙 7.1로 막고, as와 @ts-expect-error는 리뷰합니다.

앱 밖에서 온 값을 단언으로 통과시킵니다.

const storedFilter = JSON.parse(localStorage.getItem("product-filter") as string) as ProductFilter;

앱 밖에서 온 값은 좁히기 함수를 통과한 뒤에 씁니다.

const storedValue = localStorage.getItem("product-filter");
const parsedFilter: unknown = storedValue === null ? undefined : JSON.parse(storedValue);

// 첫 방문이면 저장된 필터 없음, 형태가 다르면 버림. 없음은 그대로 유지
const storedFilter = isProductFilter(parsedFilter) ? parsedFilter : undefined;

!로 없을 수 있다는 사실을 지웁니다.

const firstProduct = products.find((product) => product.status === "active")!;

없을 수 있으면 그대로 드러냅니다.

const firstProduct = products.find((product) => product.status === "active");

if (!firstProduct) {
	throw new NoActiveProductError();
}

함께 보기

1.7 enum 대신 as const 객체를 씁니다

객체로 실행 값을 선언하고 같은 값에서 타입을 추출합니다.

직접 선언하는 값 집합은 enum 대신 객체와 as const로 실행 값과 타입을 함께 둡니다. enum 선언은 규칙 7.1의 style/noEnum이 막습니다.

상황처리
외부 패키지의 enum을 그대로 전달함외부 계약을 유지합니다
기존 enum을 객체로 옮김직렬화 값과 공개 타입을 보존하고 숫자 enum의 역방향 조회 사용처를 확인합니다
기존 계약에 맞는 값 집합인지도 검사함as const satisfies 기존계약을 씁니다
리터럴 추론, 읽기 전용 속성이 필요 없음as const를 불필요하게 붙이지 않습니다

enum을 직접 씁니다.

enum ProductStatus {
	pending = "pending",
	passed = "passed",
	failed = "failed",
}

객체 리터럴과 타입 추출을 조합합니다.

/**
 * 상품 심사 상태 값 집합
 */
const product_status = {
	pending: "pending",
	passed: "passed",
	failed: "failed",
} as const;

/**
 * 상품 심사 상태 타입. product_status에 값을 더하면 함께 넓어짐
 */
type ProductStatus = (typeof product_status)[keyof typeof product_status];

함께 보기

1.8 독립 객체 계약은 interface, 타입 조합은 type으로 선언합니다

선언 형식만 보고도 필드 계약인지 타입 사이의 관계인지 구분할 수 있습니다.

독립된 객체 필드 계약은 interface, 타입 계산이나 조합은 type으로 선언합니다. Draft, State 같은 역할어가 아니라 선언이 나타내는 계약을 기준으로 고릅니다.

선언 대상형식
이름이 있고 필드를 직접 읽는 독립 객체interface
리터럴 유니언, 기본 타입, 튜플 별칭, 함수 시그니처type
매핑, 조건부 타입, 필드가 없는 인덱스 접근 별칭type
Omit, Record 같은 계산, 다른 타입과의 교차type
유니언, 교차 조합에서만 쓰는 객체type

형식을 맞추려고 별칭을 만들거나 객체 형태를 전부 interface로 바꾸지 않습니다. 추론되는 익명 결과와 외부, 생성된 계약은 그대로 둡니다.

독립된 필드 계약을 객체 type 별칭으로 선언합니다.

/**
 * 상품 요약
 */
type ProductSummary = {
	/**
	 * 상품 식별자
	 */
	id: string;
	/**
	 * 목록에 표시할 이름
	 */
	name: string;
};

필드 계약은 interface, 타입 조합은 type으로 구분합니다.

/**
 * 상품 요약
 */
interface ProductSummary {
	/**
	 * 상품 식별자
	 */
	id: string;
	/**
	 * 목록에 표시할 이름
	 */
	name: string;
}

/**
 * 상품 목록 표시 방식
 */
type ProductMode = "list" | "grid";

/**
 * 자식 분류를 편집할 수 있는 노드
 */
type EditableCategoryNode = Omit<ProductCategoryNode, "children"> & {
	/**
	 * 편집 중인 자식 분류
	 */
	children: EditableCategoryNode[];
};

함께 보기