고급 타입 패턴과 Best Practices
함께 쓰면 안 되는 props를 유니온과 선택적 never로 막고, 유틸리티 타입과 제네릭 컴포넌트로 props의 모양을 다시 쓰며, 바깥 데이터를 검사하는 타입 가드와 판별 유니온의 분기 누락 검사, satisfies를 쓴 설정 객체를 React 코드에 적용합니다.
props와 상태에 타입을 붙이는 것만으로는 막지 못하는 실수가 있습니다. 링크로 쓸지 버튼으로 쓸지에 따라 받아야 할 props가 다른 컴포넌트, 어떤 데이터든 받아 목록으로 그리는 컴포넌트, 불러오는 중과 실패를 동시에 가질 수 있는 상태가 그런 예입니다. 이 글은 이런 경우에 쓰는 타입 패턴을 React 컴포넌트에 적용하고, 각 패턴이 검사 단계에서 막아 주는 것과 실행 중에 따로 확인해야 하는 것을 나눕니다.
예제는 앞 글처럼 Vite의 react-ts 템플릿에서 컴포넌트마다 .tsx 파일로 만듭니다. 마지막 절의 그림은 막으려는 실수에 따라 어떤 패턴을 고를지 정리합니다.
함께 쓰면 안 되는 props 막기
버튼 모양의 컴포넌트가 href를 받으면 <a>로, onClick을 받으면 <button>으로 그린다고 해 봅니다. 두 가지 쓰임새를 각각 인터페이스로 만들고 유니온으로 묶되, 반대쪽 속성을 선택적 never로 적으면 둘을 함께 넘기는 코드를 막을 수 있습니다.
import type { MouseEventHandler, ReactNode } from "react";
interface BaseButtonProps {
children: ReactNode;
className?: string;
disabled?: boolean;
}
interface AnchorButtonProps extends BaseButtonProps {
href: string;
onClick?: never;
}
interface HtmlButtonProps extends BaseButtonProps {
onClick: MouseEventHandler<HTMLButtonElement>;
href?: never;
}
type ButtonProps = AnchorButtonProps | HtmlButtonProps;
export default function ConditionalButton(props: ButtonProps) {
const { children, className, disabled } = props;
if (props.href !== undefined) {
return disabled ? (
<a className={className} aria-disabled="true">
{children}
</a>
) : (
<a href={props.href} className={className}>
{children}
</a>
);
}
return (
<button type="button" onClick={props.onClick} className={className} disabled={disabled}>
{children}
</button>
);
}AnchorButtonProps는 href를 반드시 받고 onClick은 받을 수 없으며, HtmlButtonProps는 그 반대입니다. 그래서 <ConditionalButton href="/home" onClick={handleClick}>은 유니온의 어느 쪽에도 맞지 않아 진단되고, 둘 다 없는 <ConditionalButton>Nothing</ConditionalButton>도 진단됩니다. href는 한쪽에서는 string이고 다른 쪽에서는 undefined이므로, props.href !== undefined 검사가 props를 AnchorButtonProps로 좁히고, 그 아래에서는 props.onClick이 단언 없이 핸들러 타입이 됩니다.
검사를 if (props.href)처럼 참인 값으로 하지 않은 것은, href=""가 타입으로는 문자열이지만 falsy여서 버튼 분기로 빠지기 때문입니다. 또 비활성 링크는 href를 빼고 aria-disabled를 붙여 그렸는데, CSS의 pointer-events: none만으로는 마우스 클릭만 막히고 키보드의 Tab과 Enter로는 여전히 링크를 따라갈 수 있기 때문입니다.
선택적 never가 막는 범위는 설정에 따라 조금 다릅니다. 기본 설정에서 onClick?: never는 onClick이 undefined일 수 있다는 뜻이어서, onClick={undefined}처럼 값이 undefined인 속성은 허용됩니다. exactOptionalPropertyTypes를 켜면 선택적 속성에 undefined를 직접 넣는 것까지 막으므로, 그때는 속성 자체를 쓸 수 없게 됩니다.
유틸리티 타입으로 props의 모양 다시 쓰기
Partial, Pick, Omit 같은 유틸리티 타입은 이미 있는 타입에서 새 타입을 만들어, 같은 필드를 여러 번 적지 않게 해 줍니다. Partial<T>는 모든 속성을 선택적으로, Pick<T, K>는 고른 속성만, Omit<T, K>는 고른 속성을 뺀 나머지만 가진 타입을 만듭니다.
export interface Product {
id: number;
name: string;
price: number;
category: string;
}
type ProductPatch = Partial<Pick<Product, "name" | "price" | "category">>;
export function describeUpdate(productId: number, changes: ProductPatch): string {
return `product ${productId}: ${Object.keys(changes).join(",")}`;
}
console.log(describeUpdate(1, { price: 1250 }));
console.log(describeUpdate(2, { name: "Wireless Keyboard", category: "Accessories" }));product 1: price
product 2: name,categoryPartial<Product>를 그대로 쓰면 id까지 선택적 속성이 되어, 바꾸면 안 되는 id를 수정 내용에 넣을 수 있습니다. 그래서 먼저 Pick으로 바꿀 수 있는 필드만 고른 뒤 Partial로 감쌌고, describeUpdate(1, { id: 2 })처럼 새 객체 리터럴에 id를 넣으면 알 수 없는 속성이라는 진단이 나옵니다. 다만 이 검사는 객체 리터럴을 바로 넘길 때의 초과 속성 검사여서, 이미 id를 가진 변수를 넘기거나 서버에서 받은 객체를 넘기면 실행 중의 객체에서 id가 빠지지는 않습니다.
Pick은 컴포넌트가 정말로 쓰는 필드만 props로 요구할 때도 유용합니다.
interface UserProfile {
id: number;
name: string;
email: string;
address: string;
phone: string;
}
type UserContactInfo = Pick<UserProfile, "name" | "email">;
function ContactCard({ user }: { user: UserContactInfo }) {
return (
<div>
<h3>{user.name}</h3>
<p>Email: {user.email}</p>
</div>
);
}
const currentUser: UserProfile = {
id: 1,
name: "John Doe",
email: "[email protected]",
address: "123 Main St",
phone: "555-1234",
};
export default function ContactCards() {
const picked: UserContactInfo = { name: currentUser.name, email: currentUser.email };
return (
<>
<ContactCard user={currentUser} />
<ContactCard user={picked} />
</>
);
}currentUser는 name과 email을 가지므로 UserContactInfo가 필요한 자리에 그대로 넘길 수 있습니다. 두 카드는 같은 이름과 이메일을 그리지만, 첫 번째 카드가 받은 객체에는 다섯 키가 모두 남아 있고 두 번째 카드가 받은 picked에는 두 키만 있습니다. 타입을 좁히는 것과 실제 데이터에서 필드를 빼는 것은 다른 일이므로, 민감한 필드를 넘기지 않으려면 picked처럼 새 객체를 만들어야 합니다.
Omit은 저장할 때 서버가 정하는 필드를 빼고 편집할 필드만 남길 때 씁니다.
import { useState } from "react";
import type { FormEvent } from "react";
interface Post {
id: number;
title: string;
content: string;
authorId: number;
createdAt: Date;
}
type EditablePost = Omit<Post, "id" | "createdAt" | "authorId">;
interface PostEditorProps {
initialPost?: EditablePost;
onSave: (post: EditablePost) => void;
}
export default function PostEditor({ initialPost, onSave }: PostEditorProps) {
const [title, setTitle] = useState(initialPost?.title ?? "");
const [content, setContent] = useState(initialPost?.content ?? "");
const handleSubmit = (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
onSave({ title, content });
};
return (
<form onSubmit={handleSubmit}>
<input value={title} onChange={(e) => setTitle(e.target.value)} placeholder="Title" />
<textarea value={content} onChange={(e) => setContent(e.target.value)} placeholder="Content" />
<button type="submit">Save Post</button>
</form>
);
}EditablePost는 title과 content만 남으므로, onSave에 넘기는 객체도 그 두 필드로 충분합니다. useState의 초기값은 처음 렌더링에서만 쓰이므로, 편집기가 화면에 있는 동안 부모가 initialPost를 다른 글로 바꾸어도 입력창의 값은 바뀌지 않습니다. 다른 글을 편집하게 하려면 부모가 key={post.id}처럼 글마다 다른 key를 주어 편집기를 새로 마운트하는 방법을 씁니다.
제네릭 컴포넌트
어떤 데이터든 받아 목록으로 그리는 컴포넌트는 제네릭 함수로 만듭니다. 함수 컴포넌트도 함수이므로 타입 매개변수를 가질 수 있고, JSX에서 쓸 때는 넘긴 props에서 타입 인수가 추론됩니다.
import type { Key, ReactNode } from "react";
interface Item {
id: string | number;
}
interface GenericListProps<T extends Item> {
items: readonly T[];
renderItem: (item: T) => ReactNode;
getKey?: (item: T) => Key;
}
export default function GenericList<T extends Item>({ items, renderItem, getKey }: GenericListProps<T>) {
return (
<ul>
{items.map((item) => (
<li key={getKey ? getKey(item) : item.id}>{renderItem(item)}</li>
))}
</ul>
);
}import GenericList from "./GenericList";
const users = [
{ id: 1, name: "Alice", age: 30 },
{ id: 2, name: "Bob", age: 24 },
];
const products = [
{ id: "p1", productName: "Laptop", price: 1200 },
{ id: "p2", productName: "Mouse", price: 25 },
];
export default function ListExamples() {
return (
<>
<GenericList items={users} renderItem={(user) => `${user.name} (${user.age} years old)`} />
<GenericList items={products} renderItem={(product) => `${product.productName} - $${product.price}`} />
</>
);
}첫 목록은 Alice (30 years old)와 Bob (24 years old)를, 둘째 목록은 Laptop - $1200과 Mouse - $25를 그립니다. 첫 번째 GenericList에서는 T가 사용자의 타입으로, 두 번째에서는 상품의 타입으로 추론되므로, 사용자 목록의 renderItem에서 user.productName을 읽으면 진단됩니다. items와 renderItem이 같은 T를 쓰기 때문에, 넘긴 데이터와 그리는 함수가 서로 맞는지 검사되는 것입니다.
제약 Item에는 컴포넌트가 실제로 쓰는 id만 두었습니다. 다른 속성도 허용하려고 [key: string]: any 인덱스 시그니처를 함께 적는 코드도 흔한데, 그러면 컴포넌트 안에서 item.anything처럼 없는 속성을 읽어도 any가 되어 검사되지 않습니다. 구조적 타입 검사에서는 id만 있는 제약으로도 다른 속성을 가진 객체가 통과하므로 인덱스 시그니처는 필요하지 않습니다. 또 id의 타입이 맞다고 목록 안에서 값이 겹치지 않는다는 뜻은 아니므로, 키의 고유함은 데이터를 만드는 쪽에서 지켜야 합니다.
타입 가드와 바깥 데이터
판별 유니온은 공통 속성 하나의 리터럴 값으로 어느 타입인지 구별하는 유니온입니다. 다음 Content는 type이 "text"이면 value를, "image"이면 src와 alt를 가지며, type을 검사하는 타입 가드 함수가 값을 좁혀 줍니다.
interface TextContent {
type: "text";
value: string;
}
interface ImageContent {
type: "image";
src: string;
alt: string;
}
export type Content = TextContent | ImageContent;
function isImageContent(content: Content): content is ImageContent {
return content.type === "image";
}
export default function ContentDisplay({ content }: { content: Content }) {
if (isImageContent(content)) {
return <img src={content.src} alt={content.alt} />;
}
return <p>{content.value}</p>;
}if 안에서 content는 ImageContent로, 그 아래에서는 TextContent로 좁혀집니다. 같은 일은 content.type === "image"를 직접 검사해도 되며, 판별 속성을 검사하는 조건은 함수로 꺼내지 않아도 타입을 좁힙니다. 이 가드는 이미 Content로 타입이 정해진 값 가운데 어느 쪽인지를 가를 뿐이어서, 서버에서 받은 unknown 값을 검사하는 데 쓸 수는 없습니다.
바깥에서 들어온 값은 구조를 직접 확인한 뒤에 Content로 받아들입니다.
import type { Content } from "./components/ContentDisplay";
export function isContent(value: unknown): value is Content {
if (typeof value !== "object" || value === null || Array.isArray(value)) {
return false;
}
const record = value as Record<string, unknown>;
if (record.type === "text") {
return typeof record.value === "string";
}
if (record.type === "image") {
return typeof record.src === "string" && typeof record.alt === "string";
}
return false;
}
console.log(isContent(null), isContent({ type: "text", value: "hello" }), isContent({ type: "image", src: 42, alt: "사진" }));false true false세 번째 값은 type이 "image"이지만 src가 문자열이 아니므로 거절됩니다. 함수 안의 as Record<string, unknown>은 객체의 속성을 읽을 수 있게 하는 단언일 뿐이고, 결과의 참과 거짓은 그 뒤의 검사가 정합니다. 이 함수는 모양만 확인하며, 주소가 안전한지나 이미지가 실제로 있는지는 따로 확인해야 합니다.
판별 유니온과 분기 누락 검사
앞 글의 UserProfile은 사용자, 불러오는 중, 오류를 세 상태로 따로 두어서, 불러오는 중이면서 이전 사용자도 있는 조합이 생겼습니다. 화면의 상태를 판별 유니온 하나로 만들면, 상태마다 필요한 데이터만 가지게 되어 모순된 조합을 만들 수 없습니다.
DISCRIMINATED UNION / EXHAUSTIVE SWITCH
status별 데이터와 렌더 분기를 같은 계약으로 맞춘다
본문 UserLoadState의 idle·loading·success·error가 각각 switch 분기로 이어집니다. success에는 빈 users 배열도 들어갈 수 있습니다. 새 variant를 추가하면 default의 assertNever에서 누락을 정적으로 확인합니다.
각 행은 상태 → 렌더 결과; 시간 순서가 아니다
각 쌍: 상태 → 반환값서로 시간 전이가 아님
STATE CONTRACT
각 상태에 필요한 데이터
success에서 users를, error에서 message를 읽도록 status로 좁힙니다. 서로 모순되는 boolean 조합을 줄이며 전이 자체를 자동 구현하거나 외부 응답을 검증하지는 않습니다.
LESSON OUTPUT
네 status와 반환 문자열
idle은 “사용자 조회 전”, loading은 “불러오는 중...”, success는 users.length를 이용한 “사용자 N명”, error는 message를 포함한 “오류: ...”를 반환합니다. 각 행은 시간 전이가 아닌 상태와 결과의 대응입니다.
STATIC FEEDBACK
새 상태를 추가하면 분기도 갱신
strict 기준으로 모든 case를 처리하면 default의 state가 never로 좁혀집니다. 새 variant를 처리하지 않으면 assertNever(state) 인자가 never에 맞지 않아 컴파일 진단을 얻습니다.
MODEL EXTENSION
empty 상태를 나누는 선택
원문 success는 빈 배열도 포함합니다. 별도 empty variant를 추가하는 확장은 0명 화면을 따로 표현하지만, 그 경우 union·전이·switch를 함께 갱신해야 합니다.
RUNTIME LIMIT
assertNever가 검증기는 아니다
함수의 throw는 예상 못한 값이 실제 들어올 때 실행됩니다. 타입 단언으로 우회한 외부 unknown을 정적으로 검사한 union과 같다고 가정하지 않습니다.
본문 renderState는 문자열을 반환합니다. JSX 분기로 확장할 수 있지만 assertNever는 정상 렌더 뒤에 실행되는 처리 단계가 아닙니다.
type UserLoadState =
| { status: "idle" }
| { status: "loading" }
| { status: "success"; users: { id: number; name: string }[] }
| { status: "error"; message: string };
function assertNever(value: never): never {
throw new Error(`Unhandled state: ${JSON.stringify(value)}`);
}
export function renderState(state: UserLoadState): string {
switch (state.status) {
case "idle":
return "사용자 조회 전";
case "loading":
return "불러오는 중...";
case "success":
return `사용자 ${state.users.length}명`;
case "error":
return `오류: ${state.message}`;
default:
return assertNever(state);
}
}
console.log(renderState({ status: "success", users: [] }));
console.log(renderState({ status: "error", message: "timeout" }));사용자 0명
오류: timeout그림처럼 네 상태는 각각 하나의 반환 문자열에 대응하며, 이것은 시간에 따른 전이가 아니라 상태와 결과의 짝입니다. case "success" 안에서만 state.users를, case "error" 안에서만 state.message를 읽을 수 있고, 빈 배열은 success 안에서 사용자 0명으로 처리됩니다. 네 경우를 모두 처리하면 default에 남는 state의 타입은 never가 됩니다.
나중에 { status: "empty" } 같은 상태를 유니온에 더하고 switch를 고치지 않으면, default의 state가 never가 아니게 되어 assertNever(state)에서 진단됩니다. 그래서 상태를 늘릴 때 분기를 빠뜨린 곳을 컴파일러가 모두 알려 줍니다. assertNever의 throw는 단언으로 검사를 우회한 값이 실제로 들어왔을 때만 실행되며, 바깥 데이터를 검증하는 일은 앞 절의 isContent 같은 함수가 맡습니다.
satisfies와 as const로 설정 객체 만들기
경로 목록이나 기능 플래그 같은 설정 객체는 키와 값을 그대로 타입으로 쓰고 싶으면서도, 정해진 모양을 지켰는지 검사하고 싶은 값입니다. as const는 값을 리터럴 타입과 readonly로 고정하고, satisfies는 추론된 타입을 바꾸지 않은 채 그 값이 주어진 타입에 맞는지만 검사합니다.
type RouteConfig = {
path: `/${string}`;
auth: "public" | "private";
};
export const routes = {
home: { path: "/", auth: "public" },
dashboard: { path: "/dashboard", auth: "private" },
settings: { path: "/settings", auth: "private" },
} as const satisfies Record<string, RouteConfig>;
export type RouteKey = keyof typeof routes;
export function getRoutePath(key: RouteKey): string {
return routes[key].path;
}
console.log(getRoutePath("dashboard"));/dashboardRouteKey는 "home" | "dashboard" | "settings"이므로 getRoutePath("reports")는 진단되고, 경로를 "dashboard"처럼 슬래시 없이 적으면 /로 시작하는 문자열만 받는 path의 템플릿 리터럴 타입에 맞지 않아 진단됩니다. 객체에 : Record<string, RouteConfig>로 타입을 붙였다면 키가 string으로 넓어져 RouteKey를 얻을 수 없었을 것이고, satisfies는 검사만 하므로 세 키와 리터럴 값이 그대로 남습니다.
다만 as const는 타입에서만 readonly이고 실행 중에 객체를 얼리지는 않으며, satisfies도 실행 중의 검증 코드를 만들지 않습니다. auth: "private"는 그 경로에 로그인이 필요하다는 표시일 뿐이어서, 실제로 접근을 막는 인증 검사는 라우터와 서버에서 따로 구현해야 합니다. satisfies의 자세한 동작은 TypeScript 4.9 릴리스 노트의 satisfies에서, as const는 TypeScript 3.4 릴리스 노트의 const 단언에서 확인할 수 있습니다.
막으려는 실수에 맞는 패턴 고르기
이 글의 패턴은 서로 다른 실수를 막습니다. 패턴을 많이 쓰는 것보다, 지금 막고 싶은 실수가 무엇인지를 먼저 정하고 그에 맞는 패턴을 고릅니다.
UI TYPE PATTERNS / FAILURE AND CONTRACT
UI에서 막을 실패에 맞는 타입 패턴을 고른다
금지 Props 조합, shape 재사용, 입력과 callback의 관계, 외부 데이터 검증, 상태 분기, 설정 객체는 서로 다른 문제입니다. 본문 예제로 계약의 효과와 runtime 한계를 함께 확인합니다.
FORBIDDEN COMBINATION
union과 선택적 never
ButtonProps는 href와 onClick의 동시 전달을 금지합니다. T extends U ? X : Y 형태의 조건부 타입 예제가 아니며 href=""의 truthy 분기와 링크 키보드 비활성화는 별도의 실제 동작 문제입니다.
STATIC SHAPE
Pick·Omit·Partial
필드를 고르거나 제외하거나 선택적으로 만드는 정적 타입 변환입니다. 실제 객체의 필드를 삭제하지 않습니다. 본문 PartialProduct는 id도 선택적이며 저장 정책상 금지 필드가 자동 배제되지는 않습니다.
RELATIONSHIP
같은 T를 입력과 callback에
GenericList의 items: T[]와 renderItem(item: T)를 연결합니다. T extends Item 제약의 id와 실제 User/Product 속성은 함께 추론되며 any 인덱스 signature가 모든 필드를 검증해 주지는 않습니다.
EXTERNAL INPUT
unknown은 값 구조를 검사
isImageContent는 이미 Content인 값의 type tag만 좁힙니다. 외부 unknown에는 객체 여부, src·alt 등 필드 타입을 확인하는 guard나 schema가 별도로 필요합니다.
RENDER BRANCH
판별 union과 never
각 status에 맞는 payload와 switch 분기를 함께 정의합니다. assertNever로 새 variant의 누락을 드러내되 실제 state 전이와 runtime 입력 검사는 따로 구현합니다.
CONFIGURATION
as const와 satisfies
routes의 as const는 리터럴/readonly 타입을 보존하고 satisfies는 RouteConfig 계약을 검사합니다. 런타임 동결이나 외부 설정 검증이 아니며 satisfies만으로 모든 값이 좁은 리터럴로 유지된다고 가정하지 않습니다.
작은 계약으로 구체적인 실패를 막습니다. 정적 타입 검사, 사용자 동작 테스트, 외부 입력 검증은 각각의 역할을 가집니다.
함께 쓰면 안 되는 props는 유니온과 선택적 never로, 이미 있는 타입의 일부만 쓰는 props는 유틸리티 타입으로 막습니다. 넘긴 데이터와 그리는 함수가 맞아야 하는 컴포넌트는 같은 타입 매개변수로 둘을 잇고, 바깥에서 들어온 값은 unknown으로 받아 구조를 검사합니다. 상태에 따라 데이터와 화면이 달라지는 곳은 판별 유니온과 never 검사로, 키와 값을 그대로 쓰는 설정은 as const와 satisfies로 다룹니다.
정리
- 유니온과 선택적
never는 함께 쓰면 안 되는 props를 막고,undefined와의 비교로 어느 쪽인지 좁힙니다. Partial,Pick,Omit은 타입만 바꾸므로, 실제 데이터에서 필드를 빼려면 새 객체를 만들어야 합니다.- 제네릭 컴포넌트는 같은 타입 매개변수로 데이터와 콜백을 이으며, 제약에는 컴포넌트가 쓰는 속성만 둡니다.
- 판별 속성을 검사하는 타입 가드는 이미 정해진 유니온을 가를 뿐이고, 바깥의
unknown값은 구조를 직접 검사합니다. - 판별 유니온으로 화면 상태를 만들고
default에서never를 검사하면, 상태를 늘릴 때 빠뜨린 분기를 찾을 수 있습니다. as const와satisfies는 설정 객체의 리터럴을 유지하면서 모양을 검사하지만, 실행 중의 검증이나 권한 검사를 대신하지 않습니다.
연습 문제
1. ConditionalButton의 진단
다음 세 가지 쓰임 가운데 진단되는 것은 무엇인가요? href=""를 넘기면 어느 태그로 그려지나요?
<ConditionalButton href="/home">Home</ConditionalButton>
<ConditionalButton href="/home" onClick={() => {}}>Both</ConditionalButton>
<ConditionalButton onClick={() => {}} disabled>Off</ConditionalButton>해설 보기
두 번째만 진단됩니다. href가 있으면 AnchorButtonProps여야 하는데 그쪽은 onClick을 받을 수 없고, onClick이 있으면 HtmlButtonProps여야 하는데 그쪽은 href를 받을 수 없습니다. href=""는 undefined가 아니므로 <a>로 그려지는데, 빈 href는 현재 문서를 가리키는 링크이므로 허용하지 않으려면 따로 검사해야 합니다.
2. 유니온에 상태 더하기
UserLoadState에 { status: "empty" }를 더하고 renderState는 그대로 두었습니다. 어디에서 진단이 나오나요? renderState({ status: "success", users: [] })의 결과는 바뀌나요?
해설 보기
default에 남는 state의 타입이 never가 아니라 { status: "empty" }가 되므로, assertNever(state)의 인수가 never에 맞지 않는다는 진단이 나옵니다. success에 빈 배열을 넘긴 호출은 여전히 사용자 0명을 돌려주며, 빈 목록을 empty로 나타내려면 상태를 만드는 쪽에서 빈 배열일 때 empty를 고르도록 함께 바꿔야 합니다.
3. 설정 객체의 타입
routes에서 satisfies Record<string, RouteConfig> 대신 변수에 : Record<string, RouteConfig> 타입을 붙이면 RouteKey는 무엇이 되나요? routes.home.path = "/main"은 두 경우에 각각 진단되나요?
해설 보기
변수에 타입을 붙이면 키가 string으로 넓어져 RouteKey는 string이 되고, 없는 경로 이름도 받게 됩니다. satisfies를 쓴 원래 코드에서는 as const가 속성을 readonly로 만들어 대입이 진단됩니다. 변수에 타입을 붙인 경우에는 타입 표기가 as const의 readonly를 덮어쓰므로 대입이 허용되며, 어느 경우든 실행 중의 객체가 얼려진 것은 아닙니다.
다음 글 Zustand를 활용한 상태 관리에서는 여러 컴포넌트가 함께 쓰는 상태를 Zustand 스토어로 옮기고, 상태와 액션의 타입, 선택자, 비동기 갱신과 저장을 다룹니다.