26.01.21 우타스 스터디 - 2장

lsjoon·2026년 1월 21일

TIL

목록 보기
55/57

cf. 우아한 타입스크립트 - 2장

목차

  1. namespace
  2. as const, typeof, keyof
  3. string vs String
  4. truthly vs true
  5. Record<string, never>
  6. type vs interface
  7. Discriminated Union

ps. LocalStorage 데이터 전처리

1. namespace


cf. 타입스크립트 모듈 & 네임스페이스 시스템 이해하기

타입스크립트의 Namespace(네임스페이스)

  • 코드를 논리적으로 그룹화
  • 전역 스코프(Global Scope)가 더러워지는 것(변수 이름 충돌 등)을 방지
  • 현재에는 특수 용도 외 미사용 = 표준 Module 시스템(import/export)을 사용
특징NameSpaceES Modules
목적특수 용도일반적 사용
구조전역 스코프에 객체 생성파일 단위로 스코프 격리
의존성 관리<reference> 태그 혹은 번들러import
Tree Shaking어려움 (불필요한 코드 제거 힘듦)매우 잘 됨 (최적화 유리)
사용처"레거시 코드, 타입 정의(.d.ts)"모든 현대적 앱 개발

a. 기본 사용

namespace Validation {
    const lettersRegexp = /^[A-Za-z]+$/;  // 외부에서 접근 불가능 (private 처럼 동작)  

    export interface StringValidator {    // 외부에서 접근 가능 (export 사용)
        isAcceptable(s: string): boolean;
    }

    export const variable = 123;

    export class LettersOnlyValidator implements StringValidator {
        isAcceptable(s: string) {
            return lettersRegexp.test(s);
        }
    }
}

// 사용 방법: '네임스페이스이름.멤버'
let myValidator = new Validation.LettersOnlyValidator();
console.log(Validation.variable); // 123

b. 중첩

namespace Shapes {
    export namespace Polygons {
        export class Triangle { /* ... */ }
        export class Square { /* ... */ }
    }
}

// 접근: 점(.)으로 연결
import Polygon = Shapes.Polygons; // 별칭(alias) 사용 가능
let sq = new Polygon.Square();

c. 파일 분할과 참조

// Validation.ts
namespace Validation { export interface StringValidator { ... } }

// LettersOnlyValidator.ts
/// <reference path="Validation.ts" />
namespace Validation { export class LettersOnlyValidator { ... } }

2. as const, keyof, typeof


a. as const (Const Assertions, 상수 단언)

변하지 않는 상수이며, 가장 구체적인 값(리터럴) 그 자체로 타입을 지정

as const 없음

  • 배열은 string[]
  • 객체 속성은 string 같은 일반적인 타입으로 추론

as const 있음

  • 배열은 readonly 튜플
  • 객체 속성은 특정 문자열 값 그 자체로 고정(리터럴 타입)
const colors = ["red", "blue"];               // 타입: string[] (변경 가능)

const colorsConst = ["red", "blue"] as const; // 타입: readonly ["red", "blue"] (변경 불가, 순서 및 값 고정)

b. typeof (타입 쿼리)

변수나 객체(값)를 읽어서 그 구조를 타입으로 변환

  • 데이터를 먼저 만들고, 그 데이터의 형태를 타입으로 변환
const user = {
  name: "철수",
  age: 25
};

// user 객체의 구조를 그대로 타입으로 가져옴
// type UserType = { name: string; age: number; }
type UserType = typeof user;

c. keyof (인덱스 타입 쿼리)

객체 타입에서 키(Key)들만 뽑아서 유니온 타입(Union Type, OR 조건)으로 만듦.

예시

// 1. 데이터 정의 [ 읽기 전용, 값은 string이 아닌 '/', '/about' 리터럴 그 자체 ]
const ROUTES = {
  HOME: '/',
  ABOUT: '/about',
  CONTACT: '/contact'
} as const; 

type RoutesType = typeof ROUTES; // 2. 객체의 전체 타입 추출 (typeof)
type RouteKey = keyof RoutesType; // 3. 키(Key)만 추출 ("HOME" | "ABOUT" | "CONTACT")

type RouteValue = RoutesType[RouteKey]; // 4. 값(Value)만 추출 ("/" | "/about" | "/contact") ★ 가장 많이 쓰는 패턴

// 사용 예시
function moveTo(path: RouteValue) {
  console.log(`이동 중: ${path}`);
}

moveTo(ROUTES.HOME); // ✅ 성공
moveTo('/about');    // ✅ 성공 (값이 정확히 일치하므로)
// moveTo('/login'); // ❌ 에러: RouteValue 타입에 '/login'은 없습니다.

3. string vs String


string

  • 원시 타입 (Primitive Type) = 진짜 문자열
  • 가볍고 빠름
  • 값이 메모리에 직접 저장
const name: string = "지민";
const greeting: string = `안녕하세요, ${name}`;

String

  • 원시 래퍼 객체 (Wrapper Object) = new String()을 통해 생성되는 객체
  • 자바스크립트 내부적으로 문자열을 다루기 위해 존재하는 클래스(생성자 함수)이자 객체
  • 원시 타입보다 무거움
  • 객체이므로 힙(Heap) 메모리에 저장
const nameObj: String = new String("지민");

console.log(typeof "지민");      // 'string' (원시 타입)
console.log(typeof nameObj);     // 'object' (객체)

String의 용법

원시 타입이 메서드를 가질 수 있도록 하기 위함 (= Auto-boxing)

Auto-boxing
자바스크립트 엔진이 순간적으로 원시 타입(string)을 래퍼 객체(String)로 감싸서(Wrapping) 메서드를 실행하고 다시 원시 타입으로 돌려놓음

  • 자바스크립트에서 "hello".toUpperCase() 처럼 원시 타입 문자열이 메서드를 가질 수 있는 이유

4. truthly vs true

  • true, false
    진짜 불리언(Boolean) 값

  • Truthy, Falsy
    불리언이 아니더라도, 참이나 거짓처럼 "취급"되는 값

falsy의 종류

  • false
  • 0 (숫자 0)
  • -0 (음수 0)
  • 0n (BigInt 0)
  • "" (빈 문자열)
  • null
  • undefined
  • NaN (Not a Number)

truthly의 종류

  • "0" (문자열 0)
  • "false" (문자열 false)
  • [] (빈 배열) → 객체 타입은 무조건 참
  • {} (빈 객체) → 객체 타입은 무조건 참
  • Infinity

5. Record<string, never>


= "키는 문자열일 수 있지만, 값은 절대 존재할 수 없다(never)"
= 아무 속성도 넣을 수 없는 객체

"속성을 가질 수 없는 빈 객체"를 표현하는 가장 엄격한 방법

사용처

  1. React 컴포넌트에서 Props를 받지 않음을 명시할 때
  2. API 요청 바디가 비어있어야 할 때
  3. 제네릭 타입에서 "아무 옵션도 받지 않음"을 표현할 때
const emptyObject: Record<string, never> = {}; // ✅ 성공: 빈 객체는 할당 가능

const notEmpty: Record<string, never> = {      // ❌ 실패: 속성이 하나라도 있으면 에러 발생!
  id: 1, 
}; // 에러: Type 'number' is not assignable to type 'never'.

const user: Record<string, never> = {          // ❌ 실패: 문자열도 안 됨
  name: "철수"
};

vs { }

// 1. 일반 {} 타입
const weakEmpty: {} = { name: "철수" }; // ⚠️ 성공 (엄밀히 말하면 '객체'라는 뜻에 가깝기 때문)
// (단, 객체 리터럴로 바로 넣을 때는 잉여 속성 체크가 작동하지만, 변수를 통해 넣으면 통과됨)

// 2. Record<string, never>
const strictEmpty: Record<string, never> = { name: "철수" };  // ❌ 무조건 에러 발생

6. type vs interface


cf. Do you prefer using type or interface to define props?

a. 성능

  • 런타임 : 동일
  • 컴파일 : interface가 유리할 수 있음 (IDE 반응 속도, 빌드 시간 등)

b. 객체 관계의 명확성

  • interface - extends
    명확한 계층 관계 (= 상속) - "A는 B를 상속받는다"
    컴파일러가 해당 관계를 미리 예측하여, 재계산하지 않고 캐싱 용이
    속성 충돌 발생 시, 선언 시점에 즉시 에러 호출

  • type - &
    논리적인 연산 수행 필요 - "A와 B를 합친 새로운 모양 생성"
    컴파일러가 A & B를 마주칠 때 마다, 두 타입의 모든 속성을 재귀적으로 검사해서 합치는 플랫화(Flattening) 작업 진행할 수 있음
    타입이 복잡할 경우 컴파일러 부하 커짐 ( A & B & C & & ,,,)

c. 이름 기반 캐싱 (Named Types)

  • interface
    생성될 때 고유한 이름 가짐
    컴파일러는 내부적으로 이 이름을 식별자로 사용하여 비교 수행

  • type (Alias)
    이름은 붙어있지만, 실제로는 그 내용물(구조)을 가리키는 별칭
    경우에 따라 컴파일러가 타입의 구조를 완전히 풀어서 비교해야 할 때가 있음

d. 선언 병합 (Declaration Merging)의 특성

  • interface
    같은 이름으로 여러 번 선언하면 자동으로 합쳐짐 (= 선언 병합)
    컴파일러 입장에서 인터페이스는 "열려 있는 객체"로 취급하여, 나중에 속성이 추가될 것을 대비한 최적화 진행

  • type
    선언되는 순간 닫혀 있는 완전한 타입으로 계산
    유연한 최적화가 덜 들어감


7. Discriminated Union


구별된 유니온(Discriminated Union) = "태그된 유니온(Tagged Union)"

  • "공통된 속성(Tag/Discriminator)을 하나 심어두고, 그 값에 따라 타입을 구분

간단 예제

// 1. 각각의 인터페이스 정의 (kind 속성이 핵심!)
interface Circle {
  kind: "circle"; // 리터럴 타입 (그냥 string이 아님)
  radius: number;
}

interface Square {
  kind: "square"; // 리터럴 타입
  sideLength: number;
}

// 2. 유니온으로 합치기
type Shape = Circle | Square;

// 3. 사용하기
function getArea(shape: Shape) {
  // 처음에는 shape가 Circle인지 Square인지 모름 (접근 불가)
  // console.log(shape.radius); // ❌ 에러! Square일 수도 있으니까

  // 4. 구분자(kind)를 통해 "타입 좁히기(Narrowing)" 수행
  switch (shape.kind) {
    case "circle":
      // ✅ 여기 들어오면 TypeScript는 shape가 무조건 'Circle'임을 앎
      return Math.PI * shape.radius ** 2; 

    case "square":
      // ✅ 여기 들어오면 shape는 무조건 'Square'임
      return shape.sideLength ** 2;
  }
}

never를 활용한 완전성 검사

구별된 유니온을 사용할 때, 모든 케이스를 다 처리했는지 컴파일러가 검사하게 만드는 방법

type Shape = Circle | Square | Triangle; // Triangle이 새로 추가됨!

function getArea(shape: Shape) {
  switch (shape.kind) {
    case "circle":
      return shape.radius ** 2 * Math.PI;
    case "square":
      return shape.sideLength ** 2;
    // ⚠️ 실수로 Triangle 케이스를 작성하지 않음
    
    default:
      // 여기서 에러가 발생합니다! 
      // Triangle 타입은 never에 할당할 수 없기 때문입니다.
      const _exhaustiveCheck: never = shape; 
      return _exhaustiveCheck;
  }
}

실전 예제

// 1. 상태별 타입 정의
interface LoadingState {
  status: "loading"; // 구분자
}

interface SuccessState {
  status: "success"; // 구분자
  data: { id: number; name: string }; // 성공했을 때만 데이터가 있음
}

interface ErrorState {
  status: "error"; // 구분자
  error: Error; // 실패했을 때만 에러 객체가 있음
}

// 2. 전체 상태 타입
type ApiState = LoadingState | SuccessState | ErrorState;

// 3. 컴포넌트나 함수에서의 처리
function renderUI(state: ApiState) {
  // state.data // ❌ 에러! (Loading이나 Error일 때는 data가 없으므로)

  if (state.status === "loading") {
    return "로딩 중...";
  }

  if (state.status === "error") {
    // 여기서는 state.error에 안전하게 접근 가능
    return `에러 발생: ${state.error.message}`;
  }

  if (state.status === "success") {
    // 여기서는 state.data에 안전하게 접근 가능
    return `사용자 이름: ${state.data.name}`;
  }
}

PS. LocalStorage 데이터 전처리


cf. Parsing Discriminated Unions with Zod

zodsafeParse 활용

a. 스키마 정의

import { z } from 'zod';

// 1. 스키마 정의
const UserConfigSchema = z.object({
  theme: z.enum(['light', 'dark', 'system']),
  notifications: z.object({
    email: z.boolean(),
    sms: z.boolean(),
  }),
  lastLogin: z.string().datetime().optional(), // ISO 날짜 문자열
});

// 2. 타입 추출 (TypeScript용)
type UserConfig = z.infer<typeof UserConfigSchema>;

// 3. 기본값 정의 (데이터가 깨졌을 때 사용할 안전장치)
const DEFAULT_CONFIG: UserConfig = {
  theme: 'light',
  notifications: { email: true, sms: false },
};

const STORAGE_KEY = 'app-user-config';

b. safeParse를 활용한 localStroage 읽기 함수

이 함수는 절대 에러를 던지지 않고 항상 유효한 UserConfig 객체를 반환함을 보장

function getSafeUserConfig(): UserConfig {
  // 1. LocalStorage에서 Raw 데이터 가져오기 (string | null)
  const rawData = localStorage.getItem(STORAGE_KEY);

  if (!rawData) {
    return DEFAULT_CONFIG; // 저장된 게 없으면 기본값 반환
  }

  try {
    // 2. JSON 파싱 (여기서 문법 에러가 날 수 있으므로 try-catch)
    const parsedJson = JSON.parse(rawData);

    // 3. Zod로 구조 검증 (safeParse 사용!)
    // parse()를 썼다면 여기서 형식이 안 맞을 때 에러(Throw)가 발생하여 앱이 멈춤
    const result = UserConfigSchema.safeParse(parsedJson);

    if (result.success) {       // ✅ 검증 성공: Zod가 보장하는 안전한 데이터 반환
      return result.data;
    } else {                    // ❌ 검증 실패: 형식이 맞지 않음 (예: 사용자가 값을 조작함, 구버전 데이터)
      console.warn('Storage data is invalid. Falling back to default.', result.error);
      
      // (선택 사항) 잘못된 데이터는 지우거나 덮어쓰기
      // localStorage.removeItem(STORAGE_KEY); 
      
      return DEFAULT_CONFIG;   // 앱이 죽는 대신 기본값으로 우아하게 처리
    }

  } catch (e) {                // JSON.parse 자체가 실패한 경우 (예: "undefined" 문자열 등)
    console.error('JSON parsing failed', e);
    return DEFAULT_CONFIG;
  }
}

c. React Custom Hook으로 구현

import { useState, useEffect } from 'react';
// 위에서 만든 Schema, DEFAULT_CONFIG 활용

export function useUserConfig() {
  // 초기값 설정 시 위에서 만든 안전한 함수 사용
  const [config, setConfig] = useState<UserConfig>(() => getSafeUserConfig());

  // config가 변경될 때마다 LocalStorage에 저장
  useEffect(() => {
    localStorage.setItem(STORAGE_KEY, JSON.stringify(config));
  }, [config]);

  // 설정 업데이트 함수
  const updateConfig = (newConfig: Partial<UserConfig>) => {
    setConfig((prev) => ({ ...prev, ...newConfig }));
  };

  return { config, updateConfig };
}


// result 변수의 타입
type SafeParseReturnType<T> = 
  | { success: true; data: T } 
  | { success: false; error: ZodError };
profile
중요한 것은 꺾여도 그냥 하는 마음

1개의 댓글

comment-user-thumbnail
2026년 1월 22일

정리 굿굿굿 감사합니다
앞으로도 잘 부탁합니다 성준님!ㅎㅎ

답글 달기