타입을 하나 정의해놓고 나면, 그걸 조금씩 변형한 타입이 자꾸 필요해진다. 사용자 전체 타입은 있는데 "수정 폼에선 몇 개만 바꿀 수 있고", "화면엔 비밀번호 빼고 내려주고", "권한 목록마다 참거짓을 매긴 표가 필요하고" 하는 식이다. 이걸 매번 손으로 새로 적으면 원본이 바뀔 때마다 전부 따라 고쳐야 한다. 타입스크립트는 이런 뻔한 변형을 미리 만들어뒀는데, 이걸 유틸리티 타입(utility type, 이미 있는 타입을 재료로 새 타입을 찍어내는 도구)이라 부른다. 원본 하나만 고치면 파생된 것들이 알아서 따라온다. 실무에서 하루에도 몇 번씩 쓰는 것들만 골라, 전부 tsc(타입스크립트 컴파일러)로 돌려 확인한 결과와 함께 정리한다.
공통으로 쓸 원본 타입을 하나 두고 시작한다.
interface User {
id: number;
name: string;
email: string;
password: string;
}
Partial은 전부 선택으로 바꾼다. 모든 속성을 있어도 되고 없어도 되는 것(?)으로 만든다. 일부 필드만 넘겨 기존 값을 갱신하는 함수에 딱 맞는다.
function update(user: User, patch: Partial<User>): User {
return { ...user, ...patch };
}
update(someUser, { name: "hong" }); // OK, 나머지는 안 넘겨도 됨
patch가 Partial<User>라 name 하나만 넘겨도 통과한다. 이게 없으면 update를 부를 때마다 안 바꿀 필드까지 다 채워 넣어야 한다. 나는 설정 갱신 함수를 죄다 이 꼴로 짠다.
Required는 반대로 전부 필수로 만든다. Partial과 정확히 반대다. 물음표가 붙은 선택 속성들을 싹 다 필수로 바꾼다.
interface Opt { a?: string; b?: number; }
const r: Required<Opt> = { a: "x" };
// error TS2741: Property 'b' is missing in type '{ a: string; }' but required in type 'Required<Opt>'.
기본값을 다 채운 뒤의 설정 객체처럼, "이제부터 전부 있다고 보장된다"를 표현할 때 쓴다. 함수 안에서 옵션에 기본값을 다 먹인 다음 그 뒤 로직엔 Required 타입으로 넘기면 물음표 검사가 사라져 편하다.
Readonly는 못 바꾸게 잠근다. 모든 속성을 읽기 전용으로 만들어 대입을 막는다. 실수로 원본을 건드리는 사고를 컴파일 단계에서 잡는다.
const u: Readonly<User> = getUser();
u.name = "changed";
// error TS2540: Cannot assign to 'name' because it is a read-only property.
단 이건 겉면만 잠근다. 속에 든 배열이나 객체까지 얼리진 못하니, 깊은 데까지 막고 싶으면 따로 처리해야 한다. 그래도 함수가 받은 인자를 안 바꾸겠다는 약속을 타입으로 박아두는 데 유용하다.
Pick은 필요한 키만 뽑는다. 원본에서 원하는 속성만 골라 새 타입을 만든다. 두 번째 자리에 뽑을 키를 유니온(세로줄로 묶은 목록)으로 적는다.
type UserCard = Pick<User, "id" | "name">;
const card: UserCard = { id: 1, name: "hong", email: "x" };
// error TS2353: Object literal may only specify known properties, and 'email' does not exist in type 'UserCard'.
목록 카드처럼 몇 개 필드만 보여주는 화면 타입을 만들 때 좋다. 뽑지 않은 email을 넣으면 위처럼 거절당한다.
Omit은 뺄 키만 지운다. Pick의 반대다. 원본에서 특정 키만 빼고 나머지를 전부 가져온다. 뺄 게 넣을 것보다 적을 때 이쪽이 편하다.
type SafeUser = Omit<User, "password">;
// { id: number; name: string; email: string }
type NewUser = Omit<User, "id">;
// 서버가 id를 매기니, 만들 때 넘기는 타입엔 id가 빠진다
화면으로 내려줄 때 비밀번호를 빼거나, 새로 만들 때 아직 없는 id를 빼는 식으로 쓴다. User에 필드가 하나 늘면 SafeUser에도 자동으로 딸려 들어오니, 민감 필드만 계속 Omit으로 관리하면 실수로 새 필드가 노출되는 사고를 막는다.
Record는 키-값 사전을 만든다. "이런 키들이 있고, 값은 전부 이 타입"인 객체 타입을 찍어낸다. 첫 자리에 키 목록, 둘째 자리에 값 타입을 넣는다.
type Role = "admin" | "user" | "guest";
type Perm = Record<Role, boolean>;
const p: Perm = { admin: true, user: true };
// error TS2741: Property 'guest' is missing in type '{ admin: true; user: true; }' but required in type 'Perm'.
키가 리터럴 유니온이면 그 키를 하나라도 빼먹었을 때 위처럼 잡아준다. 권한 표나 상태별 라벨 묶음처럼 "모든 경우를 빠짐없이 채웠는지" 확인하고 싶을 때 강력하다. 키를 그냥 string으로 두면(Record<string, number>) 아무 문자열이나 키로 받는 느슨한 사전이 된다.
ReturnType과 Parameters는 함수에서 타입을 캐낸다. 이미 있는 함수의 반환 타입이나 매개변수 타입을 되뽑아온다. 함수 시그니처를 손으로 베껴 적다가 원본과 어긋나는 사고를 막는다.
function createUser(name: string): User { /* ... */ }
type CU = ReturnType<typeof createUser>; // User
type Args = Parameters<typeof createUser>; // [name: string]
typeof createUser는 함수 자체의 타입을 가리키고, 거기서 반환값과 인자 목록을 캐낸다. 원본 함수 시그니처가 바뀌면 이 타입들도 저절로 따라 바뀐다.
진짜 힘은 조합에서 나온다. 유틸리티 타입은 서로 겹쳐 쓸 수 있다. 예를 들어 수정 폼 타입은 "이름과 이메일만, 그마저도 선택"인데, 이건 Pick으로 두 개 뽑고 Partial로 감싸면 끝난다.
type UserForm = Partial<Pick<User, "name" | "email">>;
const f: UserForm = { name: "hong" }; // OK, email 없어도 됨
원본 User 하나만 고치면 UserForm, SafeUser, Perm이 전부 따라 움직인다. 이게 유틸리티 타입을 쓰는 진짜 이유다. 타입을 손으로 복붙해두면 원본과 사본이 조용히 어긋나는데, 유틸리티 타입으로 파생시켜두면 원본이 곧 진실의 출처가 되어 어긋날 틈이 없다. 처음엔 이름이 안 외워져 헷갈리지만, Partial과 Pick, Omit 이 셋만 손에 익어도 타입 적는 시간이 확 준다.