10단계
10단계 — TypeScript 깊이 (strict·narrowing·generic)
0회 조회
10단계 — TypeScript 깊이 (strict·narrowing·generic)
5단계부터 9단계까지, 사실 여러분은 줄곧 TypeScript 를 써 왔습니다. React 컴포넌트의 props, useState 의 타입, 폼 입력의 모양 — 전부 TS 가 뒤에서 잡아 주고 있었어요. 다만 암묵적으로 썼을 뿐입니다. 마지막 단계에서는 그 도구를 정면으로 봅니다. strict 가 무엇을 잡아 주는지, 타입을 어떻게 좁히는지, 외부 데이터를 어떻게 안전하게 받는지, 그리고 제네릭으로 어떻게 재사용 가능한 타입을 짜는지.
strict 모드가 잡아 주는 것
tsconfig.json 의 "strict": true 는 한 줄이지만, 그 안에 검사 묶음이 들어 있습니다. 가장 체감되는 둘:
1) 암묵적 any 금지. 타입을 적지 않아 컴파일러가 추론에 실패하면, 조용히 any 로 넘어가는 대신 오류로 알려 줍니다.
// strict 면 오류: name 의 타입이 암묵적 any
function greet(name) {
return `안녕, ${name}`;
}
매개변수에 타입을 한 글자라도 적어 주면 컴파일러가 추론을 멈추고 그대로 받아들입니다.
// 타입을 적으면 해결
function greet(name: string) {
return `안녕, ${name}`;
}
2) null / undefined 분리. strict 가 꺼져 있으면 모든 타입에 null 이 몰래 들어 있습니다. 켜면 의도한 곳만 null 일 수 있어요.
function firstChar(s: string | null) {
return s.charAt(0); // 오류: s 가 null 일 수 있음
}
이 오류는 귀찮은 게 아니라 친절한 것 입니다. 화면이 흰 페이지로 죽는 런타임 오류를, 코드를 짜는 지금 이 자리로 당겨 줍니다. 해결은 다음 절의 좁히기 입니다.
타입 좁히기 (narrowing)
string | null 처럼 타입이 여러 가능성을 가질 때, 조건문으로 지금 이 가지에서는 어느 쪽인지 를 컴파일러에게 알려 주는 것을 좁히기 (narrowing) 라고 합니다. 도구는 평범한 JavaScript 문법 그대로예요.
typeof 로 원시 타입 좁히기:
function format(value: string | number) {
if (typeof value === "string") {
return value.toUpperCase(); // 이 블록 안에서 value 는 string
}
return value.toFixed(2); // 여기서는 number 로 좁혀짐
}
in 으로 객체 모양 좁히기:
type Cat = { meow(): void };
type Dog = { bark(): void };
function speak(animal: Cat | Dog) {
if ("meow" in animal) {
animal.meow(); // Cat
} else {
animal.bark(); // Dog
}
}
타입 가드 함수 — 직접 만드는 좁히기. 판별이 복잡하면 value is Type 반환 타입을 가진 함수로 빼냅니다.
type User = { id: string; name: string };
function isUser(value: unknown): value is User {
return (
typeof value === "object" &&
value !== null &&
"id" in value &&
"name" in value
);
}
if (isUser(data)) {
console.log(data.name); // 이 블록 안에서 data 는 User
}
isUser 가 true 를 돌려주면, 컴파일러는 그 뒤로 data 를 User 로 취급합니다. 좁히기는 "타입을 바꾸는" 게 아니라 "이 가지에서는 이거라고 증명하는" 일입니다.
외부 데이터는 unknown 으로 받기
API 응답, localStorage 값, JSON.parse 결과 — 코드 밖에서 들어오는 값은 그 타입을 컴파일러가 알 수 없습니다. 여기서 any 를 쓰면 모든 검사가 꺼지고, 오타 하나가 런타임까지 살아남아요.
const data: any = await res.json();
console.log(data.usrename); // 오타지만 any 라 통과 → 런타임에 undefined
대신 unknown 으로 받습니다. unknown 은 "정체를 모름" 이라, 좁히기 전에는 아무것도 할 수 없게 막아요.
const data: unknown = await res.json();
console.log(data.name); // 오류: unknown 은 그대로 못 씀 — 막아 줌
if (isUser(data)) {
console.log(data.name); // 좁힌 뒤에야 사용 가능
}
any 는 "검사 꺼" 이고 unknown 은 "검사한 뒤 써" 입니다. 외부 데이터의 기본값은 언제나 unknown 으로 두세요. (실제 프로젝트에서는 손으로 짠 타입 가드 대신 zod 같은 검증 라이브러리를 쓰는 경우가 많은데, 원리는 같습니다 — 받은 뒤 검증한다.)
제네릭 기초
같은 로직을 여러 타입 에 쓰고 싶을 때, 타입을 매개변수로 받는 것이 제네릭 입니다. 배열의 첫 요소를 돌려주는 함수를 생각해 봐요.
function first(arr: any[]): any { // any — 입력 타입 정보가 사라짐
return arr[0];
}
const n = first([1, 2, 3]); // n 의 타입이 any (아쉬움)
<T> 라는 타입 변수 를 두면, 입력 타입이 그대로 출력까지 따라옵니다.
function first<T>(arr: T[]): T | undefined {
return arr[0];
}
const n = first([1, 2, 3]); // n: number
const s = first(["a", "b"]); // s: string
<T> 는 "호출하는 쪽이 정하는 타입" 입니다. 호출할 때 배열을 넘기면 TS 가 T 를 알아서 채워요.
keyof — 객체의 키를 타입으로. 어떤 객체의 키 이름들 을 유니온 타입으로 만듭니다.
type User = { id: string; name: string; age: number };
type UserKey = keyof User; // "id" | "name" | "age"
제약 (extends) — T 의 범위를 좁히기. 제네릭은 너무 자유로우면 안에서 할 수 있는 게 없습니다. extends 로 "T 는 적어도 이런 모양" 이라고 약속을 받습니다.
// T 는 K 라는 키를 가진 객체여야 한다 — 그래야 obj[key] 가 안전
function getField<T, K extends keyof T>(obj: T, key: K): T[K] {
return obj[key];
}
const user = { id: "u1", name: "지수", age: 20 };
const name = getField(user, "name"); // name: string
getField(user, "email"); // 오류: "email" 은 키가 아님
K extends keyof T 덕분에, 없는 키를 넘기면 컴파일 시점에 막힙니다. 제네릭 + 제약 + keyof 조합이 타입 안전한 유틸리티의 기본 골격이에요.
유틸리티 타입
매번 새 타입을 처음부터 적는 대신, 이미 있는 타입에서 파생 하면 한 곳만 고쳐도 모두 따라옵니다. TS 가 기본 제공하는 것들:
interface User {
id: string;
name: string;
email: string;
createdAt: Date;
}
type UserCard = Pick<User, "id" | "name">; // 일부 필드만 골라 새 타입
type SafeUser = Omit<User, "email">; // 일부 필드를 빼고 새 타입
type UserDraft = Partial<User>; // 모든 필드를 선택적으로
type UsersById = Record<string, User>; // { [키: string]: User } 맵
언제 쓰는지 한 줄씩:
Pick— 큰 타입에서 화면에 필요한 필드만 추릴 때 (목록 카드 등).Omit— 민감한 필드(email등)를 빼고 클라이언트에 내려줄 때.Partial— 수정 폼처럼 일부만 채워 보내는 입력값.Record— id 로 객체를 찾는 맵을 만들 때.
핵심은 원본 User 가 바뀌면 파생 타입이 자동으로 따라온다 는 점입니다. UserCard 를 손으로 따로 적었다면, User 가 바뀔 때 같이 고치는 걸 잊기 쉬워요.
interface vs type — 어느 것을?
둘 다 객체 모양을 적을 수 있어서 처음엔 헷갈립니다. 실용적인 기준:
// 객체 모양이고 확장할 거면 interface
interface Animal { name: string }
interface Dog extends Animal { bark(): void }
// 유니온·별칭이 필요하면 type (interface 로는 불가능)
type Status = "idle" | "loading" | "done";
type Id = string | number;
기억하기 쉬운 규칙 — 객체 모양 하나면 interface, 유니온이나 별칭이 필요해지면 type. 둘 다 동작하는 자리라면 팀 컨벤션을 따르면 되고, 유니온이 필요해지는 순간 선택은 type 으로 자연히 정해집니다.
직접 해 보기
8단계에서 만든 폼을 떠올려 보세요. 폼이 서버로 보낼 데이터의 모양을 interface 로 적고, 서버 응답을 받는 자리를 unknown 으로 둔 뒤 타입 가드 함수로 좁혀 보세요. 그리고 응답 타입에서 Pick 으로 화면에 보여 줄 필드만 골라 카드 타입을 파생시켜 봅니다. 컴파일러가 빨간 줄로 무엇을 잡아 주는지 관찰하면, strict 가 왜 친절한지 손에 잡혀요.
더 깊이
강좌 마무리
HTML / CSS / JS 세 종류 파일에서 출발했습니다 (1~4단계). 그 위에 React 19 로 컴포넌트를 짜고 (5단계), Next.js App Router 로 라우트와 데이터 흐름을 잡고 (6단계), Tailwind 토큰으로 디자인을 일관되게 묶고 (7단계), 폼·검증·로딩 UX 로 사용자의 실수를 친절히 받았어요 (8단계). 이미지를 안전하게 업로드·검증·변환·저장하고 (9단계), 마지막으로 그 모든 코드를 떠받치던 TypeScript 를 정면으로 봤습니다 (10단계) — strict 의 안전망, 타입 좁히기, unknown 으로 외부 데이터 받기, 제네릭과 유틸리티 타입까지.
이제 여러분은 정적 페이지 한 장이 아니라, 사용자가 입력하고 파일을 올리는 살아 있는 화면을, 타입이 받쳐 주는 안정된 코드로 처음부터 끝까지 짤 수 있습니다. 다음 길은 둘 — nextjs-fullstack 강좌 에서 Next 에 DB 를 이어 진짜 데이터를 다루거나, backend-with-spring 강좌 에서 백엔드를 직접 만들어 보세요. 프론트엔드의 기초는 여기까지입니다 — 한 칸씩 끝까지 올라오느라 수고 많으셨어요.
🎉 HTML/CSS/JS 부터 React, Next, Tailwind 까지 완주를 축하해요
이어서 어떤 걸 배워 볼까요?