2장 기본형

프디·2025년 3월 30일

2.1 애너테이션 효과적으로 사용하기

  • 반환값이 형식 애너테이션이면 컴파일러는 반환값이 형식에 맞는지 확인
  • 구조적 형식 시스템
  • 모양이나 구조가 일치하면 호환
  • User는 Person보다 프로퍼티가 많지만 Person의 모든 프로퍼티를 User가 포함하므로 ,printPersoindp User객체를 전달할 수 있음
  • 애너테이션을 너무 많이 사용하면 필요 이상으로 형식검사 -> 형식검사가 필요한곳(함수 매개변수)에 쓰면 좋다

2.2 any와 unknown활용하기

type Person = {
  name: string;
  age: number;
};
function printPerson(person: Person) {
  for(let key in person) {
   console.log(`${key} : ${person[key]}`);
   //   Element implicitly has an 'any' type because expression of type 'string' can't be used to index type 'Person'.
    
    // ❌ 에러: 'string' 타입은 'Person'에 인덱스로 사용할 수 없습니다.
  }
}

/**
 * for...in에서 key의 타입은 항상 string입니다.
 * 하지만 Person의 키 타입은 keyof Person("name" | "age")입니다.
 * person[key]를 사용하면 TypeScript는 key가 "name" 또는 "age"가 아닐 수도 있다고 판단하여 에러를 발생시킵니다.
 */

문제 원인

for...in 구문에서 key는 항상 string 타입이다.

반면, Person 타입의 키는 "name" | "age" (keyof Person)이다.

따라서 person[key]에서 TypeScript는 key가 "name"이나 "age"가 아닐 가능성을 고려하여 오류를 발생시킨다.


자바스크립트 프로토 타이핑

  • TypeScript의 구조적 타입 시스템(Structural Typing) 에서는 Person 타입보다 더 많은 속성을 가진 객체가 들어올 수도 있습니다.
const personWithExtraProps = {
  name: "Alice",
  age: 30,
  address: "Seoul", // ❌ Person 타입에는 없는 속성
};

printPerson(personWithExtraProps);
  • printPerson(personWithExtraProps)를 호출하면, person 객체에는 "address"라는 속성이 포함될 수도 있음.
    즉, key가 "name"이나 "age"가 아닐 수도 있기 때문에 TypeScript가 이를 허용하지 않는 것.

해결 방법

(1) as keyof Person으로 캐스팅
TypeScript에게 key가 반드시 Person 타입의 키("name" | "age")임을 명시적으로 알려주면 됨.

function printPerson(person: Person) {
  for (let key in person) {
    console.log(`${key} : ${person[key as keyof Person]}`); 
  }
}
  • key가 반드시 Person 타입의 키임을 명시적으로 알려줌.

(2) Object.keys()와 as keyof Person 사용
Object.keys()를 사용하면 실제로 존재하는 키만 가져올 수 있음

function printPerson(person: Person) {
  Object.keys(person).forEach((key) => {
    console.log(`${key} : ${person[key as keyof Person]}`);
  });
}
  • Object.keys()는 실제 존재하는 키만 반환하지만, 반환 타입이 string[]이기 때문에 캐스팅이 필요.

(3) Object.entries() 사용
Object.entries()를 사용하면 키와 값을 동시에 안전하게 가져올 수 있습니다.

function printPerson(person: Person) {
  Object.entries(person).forEach(([key, value]) => {
    console.log(`${key} : ${value}`);
  });
}

/**
 * Object.entries(person)의 반환 타입은 **[string, any][]**입니다.
 * 즉, 모든 키는 string 타입, 모든 값은 any 타입이 됩니다.
 * 따라서 key가 "name" | "age"가 아닌 일반적인 문자열(string)로 처리되므로, TypeScript가 타입 오류를 발생시키지 않습니다.
 * 또한 value도 any 타입이므로, console.log(value)에서도 타입 검사 없이 문제없이 실행됩니다
**/
  • Object.entries()의 반환 타입이 [string, any][]이기 때문에 타입 오류 없이 동작합니다.

  • 단점: Person 타입 외의 속성(ex. address)도 출력될 수 있습니다.


추가 속성 문제와 타입 안전

const personWithExtraProps = {
  name: "Alice",
  age: 30,
  address: "Seoul", // ❌ Person 타입에 없는 속성
};

printPerson(personWithExtraProps);
  • 위 경우 Person 타입에 없는 address도 출력된다.

Person 타입의 키만 출력하고 싶을 때

(1) 방법 1: 하드코딩된 키 배열 사용

// 방법 1: 하드코딩
function printPersonStrict(person: Person) {
  (["name", "age"] as const).forEach((key) => {
    console.log(`${key} : ${person[key]}`);
  });
}
  • 안전하지만 재사용성 낮음

(2) 제네릭 + 전달된 키 배열 활용

// 방법 2: 제네릭과 전달된 키 배열 활용
function printPersonSafe<T extends Record<string, any>>(person: T, keys: (keyof T)[]) {
  keys.forEach((key) => {
    console.log(`${key} : ${person[key]}`);
  });
}
  • 장점:
    • 재사용 가능: Person이 아니라 어떤 객체 타입이든 적용 가능.
    • 원하는 키만 골라서 순회 가능.
  • 단점:
    • 키를 직접 매개변수로 넘겨야 함 (["name", "age"])
    • printPersonSafe(personWithExtraProps, ["name", "age"]); // "address"는 출력 안 됨
    • printPersonSafe(personWithExtraProps, ["name", "age", "address"])처럼 하면 address도 출력됨 → 강제 제한은 안 됨

(3) 방법 3: Object.keys 사용 + 타입 단언 조합

//방법 3: Object.keys 사용 + 단언
function printPersonStrict(person: Person) {
  (Object.keys(person) as (keyof Person)[]).forEach((key) => {
    console.log(`${key} : ${person[key]}`);
  });
}
  • 장점:
    • 자동으로 키를 가져오므로 하드코딩이 필요 없음.
    • 간편하게 작성 가능.
  • 단점:
    • Object.keys는 실제 런타임 객체의 키를 반환하므로, 추가 속성(address)이 있는 경우에도 출력됨.
    • 타입 단언(as (keyof Person)[])이지만 완전한 타입 안전이 아님.

(4) keyof를 기반으로 타입 안전한 키 배열 헬퍼

function typedKeys<T>() {
  return <K extends keyof T>(...keys: K[]) => keys;
}

// 2. 사용 예시
const personKeys = typedKeys<Person>()("name", "age"); // personKeys는 ('name' | 'age')[]
function printPerson(person: Person) {
  personKeys.forEach((key) => {
    console.log(`${key} : ${person[key]}`);
  });
}
  • keyof를 기반으로 하여 타입 안전한 키 배열 생성 가능.
  • 하드코딩처럼 보이지만, 타입 추론을 통해 안전하게 사용 가능.

보충: any vs unknown

  • any: 어떤 타입이든 허용 (무조건 허용, 검사 안 함)
    - 타입 검사를 건너뛰기 때문에 매우 유연하지만 타입 안전성이 없음
    - 함수 인자, 객체 속성 등에 암묵적으로 사용될 경우, 실수로 인한 버그 발생 가능
    - noImplicitAny 옵션: 암묵적인 any 사용을 방지
    TypeScript tsconfig.json에 "noImplicitAny": true를 설정하면: 명시적으로 any라고 선언하지 않은 경우, 컴파일러가 오류를 발생시킵니다.
function log(message) {  // ❌ 암시적 any, noImplicitAny가 true이면 에러
  console.log(message);
}
  • unknown: 타입은 모르지만 검사 후 사용할 수 있음
    - any처럼 모든 타입을 받을 수 있지만, 직접 사용하는 데는 검사가 필요
    • TypeScript는 typeof, instanceof, in 등의 제어 흐름 분석(control flow analysis)을 통해 unknown을 구체적인 타입으로 좁힐 수 있음

2.3 올바른 객체 선택하기

(1) Object

  • Object 타입의 문제점 중 하나는 모든 객체가 Object이지만, 특정 메서드(예: toString())의 반환 타입을 강제함.(Object 타입을 사용하면 내장 메서드의 타입과 충돌이 발생할 수 있음) => 피해야함
let obj: Object = {
  toString() {
    return false; // 'boolean'은 'string'에 할당할 수 없음.
  }
};

(2) object

  • 소문자 object는 복합형식만 지원하고 기본형식은 지원하지 않음
    => 기본형 제외하고 객체만 받고 싶을 때 사용
let a: object;

a = { name: "Alice" }; // ✅ OK
a = [1, 2, 3];         // ✅ OK
a = () => {};          // ✅ OK

a = 42;                // ❌ Error
a = "Hello";           // ❌ Error
  • 즉, number, string, boolean, null, undefined, symbol, bigint 같은 원시 타입은 허용되지 않음.
  • 대신 {}, [], function() {}, new Date() 같은 참조형 객체는 허용

(3) {} 타입

  • {}는 TypeScript에서 비어 있는 객체 리터럴 타입으로 보일 수 있지만, 실제로는 null과 undefined를 제외한 모든 값을 의미한다. 즉, 기본형(문자열, 숫자 등)도 포함된다.
    -> 타입 제한 없이 다 받고 싶을 때
let b: {};

b = 42;           // ✅ OK
b = "hello";      // ✅ OK
b = { x: 1 };     // ✅ OK
b = null;         // ❌ Error
b = undefined;    // ❌ Error
  • 결론적으로 {}는 가장 느슨한 타입 중 하나.
    → 거의 모든 값을 받아들이기 때문에 널리 쓰이지만, 타입 안전성은 떨어질 수 있다.

2.4 튜플 형식 사용하기

배열 vs 객체

  • JavaScript에서는 데이터를 객체({ name: "Stefan", age: 40 })나 배열(["Stefan", 40])로 저장할 수 있음.
    객체는 키-값 구조로 이해하기 쉽고, 배열은 구조적 제약이 적어 이름을 자유롭게 할당할 수 있음.

  • TypeScript는 배열(["Stefan", 40])을 자동으로 (string | number)[]로 추론함.
    즉, 배열의 요소가 문자열이든 숫자든 상관없이 추가적인 요소도 허용되는 문제가 생김.
    구조적 제약이 없어서 const [name, age] = person;을 해도 name과 age의 타입이 string | number가 되어 불편함.

  • 튜플은 요소의 개수와 타입을 고정할 수 있음.

const person: [string, number] = ["Stefan", 40];

person[0]은 string, person[1]은 number로 고정됨.
길이가 2개로 고정되어 추가적인 요소를 넣을 수 없음 (person[2] = false; → ❌ 오류 발생).
더 명확한 코드 작성 가능.

  • 튜플 타입에 레이블 추가 가능
type Person = [name: string, age: number];

가독성이 좋아지고, 협업할 때 코드 이해가 쉬워짐. 함수의 매개변수에서도 활용 가능

  • 함수 인수의 형식 애너테이션에도 튜플 형식이 사용됨
//튜플 없이 사용 
function hello(name: string, msg: string): void {}
//튜플 사용
function hello(...args: [name: string, msg: string]): void {}
  • Rest 요소와 함께 유연하게 사용 가능
    여러 개의 문자열을 받을 때 유용하게 활용 가능.
function h(a: string, b: string, c: string): void {}

function h(a: string, ...r: [string]): void {}

2.5 인터페이스와 Type Alias의 차이 이해하기

  • 인터페이스만 가능한 기능: 선언 합치기(Declaration Merging)
    인터페이스는 같은 이름으로 여러 번 선언할 수 있으며, 자동으로 병합됨.
    하지만, Type alias는 같은 이름으로 선언할 수 없음.
// 인터페이스 병합 가능
interface Person {
  name: string;
}

interface Person {
  age: number;
}

// { name: string; age: number; }으로 병합됨

FormData 인터페이스의 문제

  • FormData라는 이름이 브라우저의 내장 FormData API와 충돌하여 혼동을 일으킬 수 있음.
    • TypeScript는 사용자 정의 FormData 인터페이스와 브라우저 내장 FormData API를 이름만 보고 동일한 타입으로 판단
    • 컴파일할 때는 오류가 없지만, 실행하면 entries()가 존재하지 않아 런타임 오류가 발생.
interface FormData {
  name: string;
  age: number;
  address: string[];
}
  • 이렇게 인터페이스로 선언하면, 브라우저의 전역 인터페이스인 FormData와 자동으로 병합(Declaration Merging) 되어버림.
  • 인터페이스는 동일이름 중복선언되어 자동병합됨
function send(data: FormData) {
  console.log(data.entries()); // ✅ 타입상 OK
}
  • data.entries()가 컴파일 상으론 OK . 그런데 entries()는 우리가 만든 객체에는 없음
  • 런타임에서 entries()를 호출하다가 오류 발생.
type FormData = {
  name: string;
  age: number;
  address: string[];
};
  • 만약 이 코드가 글로벌 스코프나 다른 곳에서 이미 존재하는 FormData와 충돌할 경우,
    타입스크립트는 중복 선언 오류를 발생시킴
// ❌ Duplicate identifier 'FormData'.(2300)

→ 이처럼 type alias는 동일 이름으로의 병합이 불가능하기 때문에,
의도치 않게 DOM의 FormData와 충돌할 경우 컴파일 타임에 문제를 알려줘서
실수를 미리 방지할 수 있다.


  • 외부에서 확장될 수 있는 공개 API나 라이브러리용 타입은 interface를 사용

  • 내부에서만 사용하는 타입, 특히 전역 이름과 충돌 가능성이 있는 타입은 type alias로 정의
    → 타입 병합을 방지하고 의도치 않은 확장을 막을 수 있음

2.6 함수 오버로드 정의하기

  • 모든 가능한 시나리오를 각각의 함수 시그니처로 정의하고, 마지막 함수 시그니처는 실제 구현으로 대신한다.
function task(name: string, dependencies: string[]): void;
function task(name: string, callback: CallbackFn): void;
function task(name: string, dependencies: string[], callback: CallbackFn): void;

// 실제 구현부 
function task(name: string, param2: string[] | CallbackFn, param3?: CallbackFn): void {
  // ...
}
  • task 함수에서 허용된 오버로드 목록

    task(name: string, callback: CallbackFn): void;
    task(name: string, dependencies: string[], callback: CallbackFn): void;
    • (두번째 매개변수와 새번째 매개변수에 둘다 CallbackFn 가 오는 경우는 허용하지 않은 상태)
  • 타입으로 표현하면

type TaskFn = {
  (name: string, dependencies: string[]): void;
  (name: string, callback: CallbackFn): void;
  (name: string, dependencies: string[], callback: CallbackFn): void;
};
//실제 구현정의는 필요없이 형식 시스템 오버로드만 있으면 된다.
const task: TaskFn =( name : string, dependencies: string[] | CallbackFn, callback?: CallbackFn) => {
  //
}

2.7 this 매개변수의 형식(Type) 정의하기

  • this의 형식을 가정하는 콜백함수를 구현할때 함수를 구현하는 시점에서 this를 어떻게 정의할까?

함수 시그니처에 첫번째 매개변수로 this 넣기

const button = document.querySelector("button");

button?.addEventListener("click", handleToggle);

function handleToggle(this: HTMLButtonElement) { // this가 HTMLButtonElement임을 명시
  this.classList.toggle("clicked"); 
}

//또는 상위 타입으로 유용성 개선
function handleToggle(this: HTMLElement) { // 모든 HTML 요소에 적용 가능
  this.classList.toggle("clicked");
}

OmitThisParameter

  • 함수 타입에서 매개변수 중 this를 제거한 새로운 함수 타입을 생성
function handleToggle(this: HTMLElement) {
  this.classList.toggle("clicked");
}

type ToggleFn = typeof handleToggle;
// (this: HTMLElement) => void
  • this: HTMLElement를 필요로 하는 함수의 타입을 ToggleFn으로 정의
function handleToggle(this: HTMLButtonElement) {
  this.classList.toggle("clicked");
}

type ToggleFn = typeof handleToggle;

type WithoutThis = OmitThisParameter<ToggleFn>;
// () => void 

const toggleFnWithoutThis: WithoutThis = handleToggle.bind(button);
// bind(button)을 사용하면 this가 사라지므로 타입이 () => void가 됨.
  • OmitThisParameter는 this가 필요 없는 일반적인 함수 타입을 만들 때 사용.
  • OmitThisParameter는 타입 변경만 하는 것이므로, 실행할 때 this 바인딩을 올바르게 처리해야 함.

ThisParameterType

  • 함수 타입에서 this 매개변수의 타입만 추출.
type ToggleFn = typeof handleToggle;
// ToggleFn의 타입: (this: HTMLButtonElement) => void
// type ToggleFn = (this: HTMLButtonElement) => void;


type ToggleFnThis = ThisParameterType<ToggleFn>;
// ToggleFnThis = HTMLButtonElement
  • ThisParameterType는 함수 타입에서 this 매개변수의 타입만 추출하는 역할을 합니다.

2.8 심볼 사용하기

  • 심볼 값은 동일한 서술자(description)을 가지더라도 각각 유일한 값으로 생성
const sym1 = Symbol("description");
const sym2 = Symbol("description");

console.log(sym1 === sym2); // ❌ false (항상 새로운 심볼 생성)
  • 런타임스위치나 모드비교코드
    • Symbol을 사용하면 같은 값이 중복되지 않음
    • 문자열을 사용하면 값이 조작될수있는데, 심볼은 같은 값을 만들 수 없고, 오직 정의된 값만 사용 가능.
      => 안정적인 비교 가능. 임의변경 방지
  • 직렬화 할 수 없는 프로퍼티에도 심볼사용
    • 심볼을 객체의 프로퍼티 키로 사용하면 JSON 직렬화 시 포함되지 않음.
    • 숨겨야 하는 데이터를 객체에 저장할 수 있음.
const secretKey = Symbol("secret");
const user = {
  name: "Alice",
  age: 30,
  [secretKey]: "This is a secret value"
};

console.log(JSON.stringify(user));
// 출력: {"name":"Alice","age":30} (Symbol 속성 제외)

console.log(user[secretKey]);
// 출력: "This is a secret value" (여전히 접근 가능)

Symbol.for

  • 전역 레지스트리에 등록된 심볼을 공유

  • 동일한 키로 만든 심볼은 동일한 참조를 가짐

    const symA = Symbol.for("sharedKey");
    const symB = Symbol.for("sharedKey");
    console.log(symA === symB); //  true (전역 심볼은 공유됨)
    	```

Symbol.keyFor

  • 전역 심볼에 등록된 심볼에서 키를 가져오는 함수

    const usedSymbolKeys: string[] = [];
    function extendObject(obj: any, symbol: symbol, value: any) {
    // 전역 심볼의 키를 가져옴
    const key = Symbol.keyFor(symbol);
    
    //  처음 본 키라면 저장
    if (key && !usedSymbolKeys.includes(key)) {
      usedSymbolKeys.push(key);
    }
    
    // 객체에 해당 심볼을 키로 추가
    obj[symbol] = value;
    }
    function printAllValues(obj: any) {
    //usedSymbolKeys에 저장된 키들을 Symbol.for(key)로 다시 변환하여 값을 출력함.
    //즉, Symbol.for()를 사용하면 같은 키를 가진 심볼을 언제든지 다시 찾아서 사용할 수 있음.
    usedSymbolKeys.forEach(key => {
      console.log(obj[Symbol.for(key)]);
    });
    }

unique symbol을 이용한 명목적 타입 구현

  • Ts 특징(구조적 타이핑)에 따른 문제점

    //TypeScript는 원래 구조적 타이핑(Structural Typing, Duck Typing) 사용
    type ID = string;
    type UserID = string;
    
    let userA: ID = "user123";
    let userB: UserID = "user456";
    
    userA = userB; // ✅ 가능 (둘 다 string 타입이므로)
    • 구조(프로퍼티)가 같으면 타입 이름이 달라도 호환 가능
    • UserId와 Id를 number로만 정의하면 구분이 불가능
  • unique symbol을 사용하면 TypeScript가 명목적 타입처럼 동작

    //unique symbol을 사용하면 TypeScript가 명목적 타입처럼 동작
    const USER_ID: unique symbol = Symbol("USER_ID");
    const ORDER_ID: unique symbol = Symbol("ORDER_ID");
    
    type UserId = typeof USER_ID;
    type OrderId = typeof ORDER_ID;
    
    let userId: UserId = USER_ID;
    let orderId: OrderId = ORDER_ID;
    
    userId = orderId; // ❌ 오류 발생! (UserId와 OrderId는 다른 타입)
  • 런타임시 명목상 형식 검사를 하는데 필요한 형식이다?

    const DEV_MODE: unique symbol = Symbol("dev");
    const PROD_MODE: unique symbol = Symbol("prod");
    
    type Mode = typeof DEV_MODE | typeof PROD_MODE;
    
    function startApp(mode: Mode) {
      if (mode === DEV_MODE) {
        console.log("Starting in Development Mode...");
      } else if (mode === PROD_MODE) {
        console.log("Starting in Production Mode...");
      }
    }
    
    startApp(DEV_MODE); // 정상 작동
    startApp(PROD_MODE); //  정상 작동
    startApp(Symbol("dev")); //  오류 발생 (잘못된 `Symbol`)
    
  • 구조적 타입 검사란?

    • 구조가 같으면 다른타입이라도 할당가능
  • 명목적 타입 검사란?

    • 구조가 같더라도 이름이 다르면 다른 타입으로 간주하는 방식
  • 명목상 형식 검사가 필요한 이유

    • TypeScript는 컴파일 타임에만 타입을 체크.
    • 하지만 런타임에서도 특정 타입을 보장해야 하는 경우가 있음.
  • unique symbol을 사용하면 "특정한 심볼 값만 허용하는" 명목적 타입 검사를 런타임에서도 수행 가능

//unique symbol을 사용한 명목적 타입 검사의 예시
declare const userIdBrand: unique symbol;
declare const productIdBrand: unique symbol;

type UserId = number & { [userIdBrand]: void }; //intersection type , number이면서 userIdBrand라는 키를 가진 객체 형태의 타입
type ProductId = number & { [productIdBrand]: void };

function getUser(id: UserId) {}
function getProduct(id: ProductId) {}

const uid = 123 as UserId;
const pid = 123 as ProductId;

getUser(uid); // ✅ OK
getUser(pid); // ❌ 에러! → 명확히 다른 타입으로 구분됨

2.9 값과 형식 네임스페이스 이해하기

  • TypeScript의 타입은 컴파일 시점에만 존재하고, 자바스크립트로 변환되면 사라진다.
    반면, 함수나 변수 같은 값은 런타임에도 존재하며 실제로 실행된다.
// TypeScript 타입 공간에만 존재
type Collection = string[];

// JavaScript 값 공간에서 실행됨
function printCollection(coll: Collection) { 
  console.log(...coll);
}
  • 타입(Collection)은 코드가 실행될 때는 존재하지 않고, 오직 타입 검사에만 사용된다.
    반면, 함수(printCollection)는 실행될 때 실제 값으로 동작한다.
  • typeof를 사용하면 값에서 타입을 가져올 수 있음
const person = { name: "Stefan" };

type PersonType = typeof person;

const anotherPerson: PersonType = { name: "Alice" }; 
  • 클래스는 타입과 값 네임스페이스를 동시에 가짐
class Person {
  name: string;
  constructor(n: string) {
    this.name = n;
  }
}

// 클래스는 값으로 사용 가능
const personInstance = new Person("Stefan");

// 클래스는 타입으로도 사용 가능
type PersonType = Person; 

const anotherPerson: PersonType = new Person("Alice"); 
  • 하지만 같은 구조를 가진 객체라고 해도 클래스의 인스턴스가 아니라면 instanceof 검사는 실패한다.
function checkPerson(person: Person) {
  return person instanceof Person;
}

checkPerson(new Person("Stefan")); // true
checkPerson({ name: "Stefan" }); // false (Person의 인스턴스가 아님)
  • "TS2749: YourType refers to a value, but is being used as a type" 오류의 원인
type PersonProps = { name: string };

function Person({ name }: PersonProps) { 
  // React 컴포넌트
}

type PrintComponentProps = { 
  // collection: Person[]; //❌ 'Person' 은 값을 참조하지만 형식으로 사용되고 있습니다.
  collection: PersonProps[];
};
  • PersonProps는 타입 네임스페이스에서 정의되었기 때문에 정상 작동한다.
  • Person은 값 네임스페이스에서 정의된 함수이므로 타입으로 직접 사용할 수 없다.
profile
프론트엔드개발자인디

0개의 댓글