🔥 객체 타입 기초: 필수, 선택, readonly

#typescript#object-type#readonly#optional
883자
8분

인라인 객체 타입은 그 줄에서 바로 읽는다

아침에 빈 required-only.ts 파일을 열고 const user = { id: "user-1", name: "Kim" } 부터 적었다. 곧바로 email 을 넣으려다 멈췄다. 이 객체에 어떤 키가 꼭 있어야 하는지 먼저 적어 두지 않으면 나는 다음 줄에서 email 을 넣을지 말지부터 다시 고민한다.

나는 값을 채우기 전에 타입 주석을 먼저 붙였다. { id: string; name: string; email: string } 를 변수 옆에 바로 적으면 컴파일러가 세 키를 모두 요구한다. 짧은 객체 타입은 type 이름을 따로 만들지 않아도 같은 규칙으로 검사한다.

ts
// file: required-only.ts
export {};
 
const user: { id: string; name: string; email: string } = {
  id: "user-1",
  name: "Kim",
  email: "kim@example.com",
};
 
const sameUser = user;
//    ^? const sameUser: { id: string; name: string; email: string; }
 
// const missingEmail: { id: string; name: string; email: string } = {
//   id: "user-2",
//   name: "Lee",
// };
// Property 'email' is missing in type '{ id: string; name: string; }' but required in type '{ id: string; name: string; email: string; }'.
 
console.log(user.id, user.name, user.email);
ts
// file: required-only.ts
export {};
 
const user: { id: string; name: string; email: string } = {
  id: "user-1",
  name: "Kim",
  email: "kim@example.com",
};
 
const sameUser = user;
//    ^? const sameUser: { id: string; name: string; email: string; }
 
// const missingEmail: { id: string; name: string; email: string } = {
//   id: "user-2",
//   name: "Lee",
// };
// Property 'email' is missing in type '{ id: string; name: string; }' but required in type '{ id: string; name: string; email: string; }'.
 
console.log(user.id, user.name, user.email);

필수 속성 객체는 누락을 가장 먼저 잡는다. 세 키를 모두 선언했으니 email 하나만 빠져도 그 객체는 같은 타입이 아니다. 객체 값이 맞는지보다, 약속한 키를 다 채웠는지가 먼저 걸린다.

아래 // ^? 표기는 타입 추론 편에서 이미 썼다. 여기서는 sameUser 가 세 키를 모두 가진 객체라는 결과만 읽으면 충분하다. 인라인 객체 타입은 짧은 예제에서 특히 읽기 빠르다.

선택 속성은 생략 가능성과 undefined 를 함께 남긴다

선택 속성은 키를 빼고 객체를 만들 수 있게 한다. 대신 값을 읽을 때는 그 키가 없을 수도 있다는 사실을 타입에 남긴다. 입력을 느슨하게 받는 만큼, 읽는 쪽은 undefined 가능성까지 직접 처리한다.

그래서 email?: stringemail 이 늘 문자열이라는 뜻이 아니다. strict 기본에서는 원시 타입 편에서 본 것처럼 undefined 를 따로 다룬다. 그 결과 user.email 을 읽으면 타입이 string | undefined 로 남는다.

ts
// file: optional-property.ts
export {};
 
const guest: { id: string; name: string; email?: string } = {
  id: "user-1",
  name: "Kim",
};
 
const member: { id: string; name: string; email?: string } = {
  id: "user-2",
  name: "Lee",
  email: "lee@example.com",
};
 
const guestEmail = guest.email;
//    ^? const guestEmail: string | undefined
 
console.log("guest:", guestEmail ?? "없음");
console.log("member:", member.email ?? "없음");
ts
// file: optional-property.ts
export {};
 
const guest: { id: string; name: string; email?: string } = {
  id: "user-1",
  name: "Kim",
};
 
const member: { id: string; name: string; email?: string } = {
  id: "user-2",
  name: "Lee",
  email: "lee@example.com",
};
 
const guestEmail = guest.email;
//    ^? const guestEmail: string | undefined
 
console.log("guest:", guestEmail ?? "없음");
console.log("member:", member.email ?? "없음");

선택 속성은 입력을 느슨하게 받되, 읽는 쪽에는 단서를 남긴다. guestmember 가 둘 다 통과하는 이유는 email 이 필수가 아니기 때문이다. 하지만 타입 검사는 그 키가 늘 있다고 가정하지 않는다.

그래서 guest.email 을 꺼내면 바로 string 이 되지 않는다. ?? 나 조건문이 뒤에 붙는 이유가 여기에 있다. 선택 속성은 편의를 주지만, 그 편의를 사용한 흔적도 타입에 남긴다.

readonly 는 속성 재할당만 막는다

readonly 는 객체 전체를 얼리지 않는다. 내가 막고 싶은 대상은 그 속성에 대한 다시 할당 한 번뿐이다. 그래서 readonly 는 좁고 정확한 제약으로 읽는 편이 낫다.

이 차이는 as / satisfies / as const 편에서 as const 가 객체 전체에 readonly 를 붙이던 결과와 다르다. 여기서는 어느 속성을 잠글지 내가 직접 고른다. 같은 readonly 라는 단어라도 붙는 범위가 다르다.

ts
// file: readonly-property.ts
export {};
 
let user: {
  readonly id: string;
  name: string;
  profile: { city: string };
} = {
  id: "user-1",
  name: "Kim",
  profile: { city: "Seoul" },
};
 
const currentId = user.id;
//    ^? const currentId: string
 
user.name = "Park";
user.profile.city = "Busan";
 
// @ts-expect-error Cannot assign to 'id' because it is a read-only property.
user.id = "user-2";
 
user = {
  id: "user-3",
  name: "Lee",
  profile: { city: "Incheon" },
};
 
console.log(user.id, user.name, user.profile.city);
ts
// file: readonly-property.ts
export {};
 
let user: {
  readonly id: string;
  name: string;
  profile: { city: string };
} = {
  id: "user-1",
  name: "Kim",
  profile: { city: "Seoul" },
};
 
const currentId = user.id;
//    ^? const currentId: string
 
user.name = "Park";
user.profile.city = "Busan";
 
// @ts-expect-error Cannot assign to 'id' because it is a read-only property.
user.id = "user-2";
 
user = {
  id: "user-3",
  name: "Lee",
  profile: { city: "Incheon" },
};
 
console.log(user.id, user.name, user.profile.city);

readonly 속성은 값 교체 한 줄을 막는다. 컴파일러는 user.id = ... 만 막고 user.name 변경, profile.city 변경, user = { ... } 같은 동작은 그대로 둔다. 이 차이를 헷갈리면 readonly 를 실제보다 더 강한 제약으로 오해한다.

이 규칙은 얕다. 중첩 객체까지 잠그지 않았으므로 안쪽 값은 바뀐다. 변수 선언이 let 이면 객체 전체 교체도 가능하고, 그 가능 여부는 readonly 가 아니라 변수 선언 방식이 정한다.

세 표시를 같이 적으면 구분이 빨라진다

세 표시는 따로 외우기보다 한 객체에 같이 적을 때 구분이 빨라진다. id 는 고정하고, name 은 바꾸고, email 은 없어도 되게 두면 각 규칙이 서로 섞이지 않는다. 같은 객체 안에 놓였을 때 차이가 더 또렷하다.

나는 이런 비교를 한 화면에 모아 두면 어떤 오류가 어느 키에서 나는지 더 빨리 읽는다. 필수 여부와 변경 가능 여부는 같은 문제가 아니다. 읽는 시점의 undefined 가능성도 또 다른 문제다.

ts
// file: mixed-modifiers.ts
export {};
 
const withoutEmail: { readonly id: string; name: string; email?: string } = {
  id: "user-1",
  name: "Kim",
};
 
const withEmail: { readonly id: string; name: string; email?: string } = {
  id: "user-2",
  name: "Lee",
  email: "lee@example.com",
};
 
let current: { readonly id: string; name: string; email?: string } = withEmail;
 
current.name = "Lee Min";
 
const currentEmail = current.email;
//    ^? const currentEmail: string | undefined
 
// @ts-expect-error Cannot assign to 'id' because it is a read-only property.
current.id = "user-3";
 
console.log(withoutEmail.id, withoutEmail.email ?? "없음");
console.log(withEmail.id, withEmail.email ?? "없음");
console.log(current.name, currentEmail ?? "없음");
ts
// file: mixed-modifiers.ts
export {};
 
const withoutEmail: { readonly id: string; name: string; email?: string } = {
  id: "user-1",
  name: "Kim",
};
 
const withEmail: { readonly id: string; name: string; email?: string } = {
  id: "user-2",
  name: "Lee",
  email: "lee@example.com",
};
 
let current: { readonly id: string; name: string; email?: string } = withEmail;
 
current.name = "Lee Min";
 
const currentEmail = current.email;
//    ^? const currentEmail: string | undefined
 
// @ts-expect-error Cannot assign to 'id' because it is a read-only property.
current.id = "user-3";
 
console.log(withoutEmail.id, withoutEmail.email ?? "없음");
console.log(withEmail.id, withEmail.email ?? "없음");
console.log(current.name, currentEmail ?? "없음");

세 표시를 같이 적으면 질문을 셋으로 나눠 읽는다. 키를 꼭 넣어야 하는가, 값을 다시 넣을 수 있는가, 읽을 때 undefined 를 처리해야 하는가를 각각 따로 판단한다. 객체 타입을 읽을 때 이 순서가 가장 실용적이다.

이번 글에서 객체 타입의 기본 규칙은 세 줄이면 충분했다. 키를 다 채우면 필수고, ? 는 생략 가능성과 undefined 가능성을 남긴다. readonly 는 그 속성 재할당만 막는다. 이 세 줄만 정확히 읽어도 인라인 객체 타입이 길어졌을 때 컴파일러가 어느 부분을 검사하는지 바로 읽는다.

YouTube 영상

채널 보기
인공지능은 세상을 어떻게 숫자로 읽는가? - 이미지, 소리 그리고 텍스트가 행렬이 되는 원리 | 선형대수학
숫자 하나가 AI 모델의 운명을 바꾼다? | 선형대수학
트라이(Trie)를 이용한 자동 완성 알고리즘 | Trie 자료구조 이야기
투영과 예측, 그리고 선형 결합 | 선형대수학
직교성과 벡터 투영 | 선형대수학
행렬의 가장 중요한 연산 - 행렬 곱셈 | 선형대수학
우리가 매일 쓰는 맞춤법 검사기와 라우터 속에 숨겨진 알고리즘은? | Trie 자료구조 이야기
AI 추천 시스템의 원리, 벡터 사이의 각도와 코사인 유사도 | 선형대수학