TypeScript 5.4의 NoInfer 유틸리티 타입과 5.x에서 추가된 새로운 타입 도구들을 활용한 라이브러리 설계 패턴을 다룹니다.
8장에서 infer 키워드의 고급 패턴을 다뤘습니다. 이 장에서는 infer의 반대 개념이라 할 수 있는 NoInfer를 포함한 TypeScript 5.x의 새로운 유틸리티 타입들을 살펴봅니다. 이 도구들은 라이브러리 설계에서 타입 추론의 방향과 범위를 정밀하게 제어하는 핵심 역할을 합니다.
TypeScript의 타입 추론 엔진은 제네릭 함수의 모든 매개변수에서 타입 정보를 수집하여 타입 파라미터를 결정합니다. 대부분의 경우 이 동작은 직관적이지만, 여러 매개변수가 동일한 타입 파라미터를 공유할 때 의도하지 않은 결과가 발생할 수 있습니다.
function createFSM<S extends string>(config: {
initial: S;
states: S[];
}) {
return config;
}
// 의도: states에 있는 값만 initial로 사용하고 싶음
const fsm = createFSM({
initial: "idle",
states: ["idle", "loading", "success", "error"],
});
// S: "idle" | "loading" | "success" | "error" — OK
// 문제: 오타를 넣어도 에러가 발생하지 않음
const fsm2 = createFSM({
initial: "idel", // 오타!
states: ["idle", "loading", "success", "error"],
});
// S: "idel" | "idle" | "loading" | "success" | "error" — "idel"이 S에 포함됨!initial에서도 S를 추론하기 때문에, 오타가 유니온에 합류해버립니다. TypeScript 5.4 이전에는 이를 해결하기 위해 복잡한 타입 트릭이 필요했습니다.
NoInfer<T>는 특정 위치에서 타입 추론이 일어나지 않도록 차단합니다. 해당 위치의 값은 다른 위치에서 추론된 타입에 대해 검증만 수행됩니다.
function createFSM<S extends string>(config: {
initial: NoInfer<S>; // 이 위치에서는 S를 추론하지 않음
states: S[]; // S는 여기서만 추론
}) {
return config;
}
const fsm = createFSM({
initial: "idle",
states: ["idle", "loading", "success", "error"],
});
// S: "idle" | "loading" | "success" | "error" (states에서만 추론)
// initial: "idle" — S에 할당 가능하므로 OK
const fsm2 = createFSM({
initial: "idel", // Error! "idel"은 "idle" | "loading" | "success" | "error"에 할당 불가
states: ["idle", "loading", "success", "error"],
});NoInfer<T>는 타입 자체를 변경하지 않습니다. NoInfer<string>은 여전히 string입니다. 차이는 타입 추론 과정에서만 나타나며, 컴파일러가 해당 위치를 추론 후보에서 제외합니다.
type A = NoInfer<string>; // string
type B = NoInfer<number>; // number
// 런타임에는 아무 영향 없음
function example<T>(a: T, b: NoInfer<T>) {
// b의 타입은 T — NoInfer가 벗겨진 상태
}제네릭 함수에서 기본값이 추론을 오염시키지 않도록 합니다.
function createSignal<T>(
initialValue: T,
options?: {
fallback: NoInfer<T>;
onChange?: (value: NoInfer<T>) => void;
}
) {
// ...
}
// T는 initialValue에서만 추론
createSignal("hello", {
fallback: "default", // OK: string
onChange: (v) => console.log(v), // v: string
});
createSignal(42, {
fallback: "not a number", // Error: string은 number에 할당 불가
});type EventMap = {
click: { x: number; y: number };
keydown: { key: string; code: string };
resize: { width: number; height: number };
};
function on<K extends keyof EventMap>(
event: K,
handler: (payload: NoInfer<EventMap[K]>) => void
) {
// 이벤트 등록...
}
on("click", (payload) => {
console.log(payload.x, payload.y); // OK
});
on("keydown", (payload) => {
console.log(payload.key); // OK
console.log(payload.x); // Error: 'x'는 존재하지 않음
});function createTheme<T extends Record<string, string>>(config: {
tokens: T;
defaults: { [K in keyof T]?: NoInfer<T[K]> };
}) {
return config;
}
const theme = createTheme({
tokens: {
primary: "#3b82f6",
secondary: "#64748b",
danger: "#ef4444",
},
defaults: {
primary: "#3b82f6", // OK
secondary: "#64748b", // OK
danger: "#invalid", // 이상적으로는 에러이지만, string이므로 통과
// unknown: "#000", // Error: 'unknown'은 keyof T에 없음
},
});type Routes = {
"/users": {};
"/users/:id": { id: string };
"/posts/:postId/comments/:commentId": { postId: string; commentId: string };
};
function navigate<P extends keyof Routes>(
path: P,
params: NoInfer<Routes[P]>
) {
// 네비게이션 로직...
}
navigate("/users", {}); // OK
navigate("/users/:id", { id: "123" }); // OK
navigate("/users/:id", {}); // Error: id가 필요
navigate("/users/:id", { id: "123", extra: true }); // Error: extra는 존재하지 않음TypeScript가 제공하는 내장 유틸리티 타입을 카테고리별로 정리합니다.
// 프로퍼티 수정자
type Partial<T> // 모든 프로퍼티를 선택적으로
type Required<T> // 모든 프로퍼티를 필수로
type Readonly<T> // 모든 프로퍼티를 readonly로
// 프로퍼티 선택/제외
type Pick<T, K> // 특정 프로퍼티만 선택
type Omit<T, K> // 특정 프로퍼티 제외
type Record<K, T> // 키-값 쌍으로 객체 타입 생성type Exclude<T, U> // T에서 U에 할당 가능한 타입 제외
type Extract<T, U> // T에서 U에 할당 가능한 타입만 추출
type NonNullable<T> // null | undefined 제거
type NoInfer<T> // 5.4+ — 추론 차단type ReturnType<T> // 함수 반환 타입
type Parameters<T> // 함수 매개변수 타입 (튜플)
type ConstructorParameters<T> // 생성자 매개변수 타입
type InstanceType<T> // 클래스 인스턴스 타입
type ThisParameterType<T> // this 매개변수 타입
type OmitThisParameter<T> // this 매개변수 제거type Uppercase<T> // 대문자 변환
type Lowercase<T> // 소문자 변환
type Capitalize<T> // 첫 글자 대문자
type Uncapitalize<T> // 첫 글자 소문자type Awaited<T> // Promise 내부 타입 추출 (재귀적)내장 유틸리티 타입을 조합하여 프로젝트에 맞는 커스텀 유틸리티를 설계할 수 있습니다.
// 특정 프로퍼티만 선택적으로
type PartialBy<T, K extends keyof T> = Omit<T, K> & Partial<Pick<T, K>>;
// 특정 프로퍼티만 필수로
type RequiredBy<T, K extends keyof T> = Omit<T, K> & Required<Pick<T, K>>;
// 특정 프로퍼티만 Readonly로
type ReadonlyBy<T, K extends keyof T> = Omit<T, K> & Readonly<Pick<T, K>>;
// 사용 예시
interface User {
id: string;
name: string;
email: string;
bio?: string;
avatar?: string;
}
type CreateUser = PartialBy<User, "id">;
// { name: string; email: string; bio?: string; avatar?: string; id?: string }
type UserWithRequiredProfile = RequiredBy<User, "bio" | "avatar">;
// { id: string; name: string; email: string; bio: string; avatar: string }// 깊은 Readonly
type DeepReadonly<T> =
T extends (...args: any[]) => any ? T :
T extends object ? { readonly [K in keyof T]: DeepReadonly<T[K]> } :
T;
// 깊은 Mutable (readonly 제거)
type DeepMutable<T> =
T extends (...args: any[]) => any ? T :
T extends object ? { -readonly [K in keyof T]: DeepMutable<T[K]> } :
T;
// 깊은 NonNullable
type DeepNonNullable<T> =
T extends (...args: any[]) => any ? T :
T extends object ? { [K in keyof T]-?: DeepNonNullable<NonNullable<T[K]>> } :
NonNullable<T>;// 특정 프로퍼티의 존재로 타입 좁히기
type HasProperty<T, K extends string> =
T extends Record<K, unknown> ? T : never;
function hasProperty<T, K extends string>(
obj: T,
key: K
): obj is T & Record<K, unknown> {
return typeof obj === "object" && obj !== null && key in obj;
}
// 사용
function processEvent(event: unknown) {
if (hasProperty(event, "type")) {
console.log(event.type); // OK — event: unknown & Record<"type", unknown>
}
}NoInfer는 라이브러리 설계에서 추론의 주도권을 제어하는 도구입니다. 핵심 원칙은 다음과 같습니다.
// 나쁜 예: 여러 곳에서 T를 추론
function bad<T>(items: T[], defaultItem: T): T[] { /* ... */ }
// 좋은 예: items에서만 T 추론, defaultItem은 검증만
function good<T>(items: T[], defaultItem: NoInfer<T>): T[] { /* ... */ }function createStore<State extends Record<string, unknown>>(config: {
// State는 여기서 추론
initialState: State;
// 나머지는 State를 기반으로 검증
computed?: {
[key: string]: (state: NoInfer<State>) => unknown;
};
actions?: {
[key: string]: (state: NoInfer<State>, payload: any) => NoInfer<State>;
};
}) {
// ...
}function transform<T, R>(
data: T[],
fn: (item: NoInfer<T>) => R
): R[] {
return data.map(fn);
}
// T는 data에서 추론, fn의 item은 검증만
const result = transform(
[{ name: "Alice", age: 30 }],
(item) => item.name // item: { name: string; age: number }
);모든 곳에 NoInfer를 사용할 필요는 없습니다. TypeScript의 기본 추론이 올바르게 동작하는 경우에는 NoInfer가 불필요합니다. 추론 결과가 의도와 다를 때, 또는 "이 위치는 추론에 참여하면 안 된다"는 의도를 명시하고 싶을 때만 사용하세요.
NoInfer는 TypeScript 5.4에서 도입된 작지만 강력한 유틸리티 타입입니다. 타입 추론의 방향을 제어하여, "어디서 추론하고 어디서 검증할지"를 명시적으로 지정할 수 있습니다. 내장 유틸리티 타입과 커스텀 유틸리티 타입을 적절히 활용하면, 타입 안전하면서도 사용하기 편리한 API를 설계할 수 있습니다.
다음 장에서는 지금까지 배운 조건부 타입, 매핑 타입, 템플릿 리터럴 타입, infer, NoInfer를 결합하여 타입 수준 프로그래밍의 세계를 탐험합니다. 타입만으로 산술 연산, 문자열 파서, 상태 머신을 구현하는 고급 기법을 다룹니다.
이 글이 도움이 되셨나요?
관련 주제 더 보기
TypeScript 타입 시스템을 프로그래밍 언어로 활용하는 고급 기법 — 산술 연산, 문자열 파서, 상태 머신 등을 타입만으로 구현하는 패턴을 다룹니다.
TypeScript infer 키워드의 고급 활용 패턴, infer extends 구문, 공변/반변 위치에서의 추론, 그리고 실전 타입 추출 패턴을 심층 분석합니다.
TypeScript 프로젝트의 tsconfig.json 최적화, 프로젝트 참조, Isolated Declarations, 모노레포에서의 타입 전략을 실전 중심으로 다룹니다.