TypeScript 정리

최씨·7일 전

Frontend

목록 보기
13/13
post-thumbnail

⏰ 10분만 투자하면 아래 내용을 알 수 있어요!

☑️ TS 기본 핵심 개념 정리
☑️ 유틸리티 타입과 ReactNode 활용법
☑️ ! 연산자 남발을 줄이는 방법
☑️ Zod 입문


🍀 시작

예전과 달리 이제는 TypeScript 필수가 된 분위기입니다.

그만큼 면접이나 라이브 코딩에서도 TS 관련 질문을 종종 받게 됩니다.
과거에는 “type과 interface의 차이는 뭔가요?”같은 개념 질문이 주를 이뤘다면,
요즘은 예시 타입 코드를 제시하고 “이 타입 코드를 개선해보세요”처럼 실제 사용 맥락과 설계 판단을 묻는 질문이 더 많아진 느낌입니다.

처음 TS로 어느 정도 레벨의 플젝을 하면, 정말 많은 타입 에러를 경험하게 됩니다. 가장 많이 겪는 에러가 TS 에러일 것입니다. 간단히 number, string만 있는게 아니라 ReactNode, HTMLDivElement 등 다양하게 있습니다.
실무를 조금이라도 경험해 보면 Pick, Omit 같은 유틸리티 타입을 훨씬 더 유연하게, 그리고 생각보다 깊게 사용하고 있다는 것도 알게 됩니다.

그러다 보면 !?를 남발하고 있는 나 자신을 발견하게 됩니다. (경험담…)
그래서 저도 처음부터 다시 정리 + 관련 추가정보 공부를 위해 글을 쓰게 되었습니다.

이미지 설명

🍀 기본

✏️ 원시 타입과 리터럴 타입

가장 basic한 내용인데, 표로 구조도 잘 나와있는게 있어서 첨부해 봤습니다.

  • 원시 타입 (Primitive Type)
    • 하나의 값만 저장하는 타입
    • ex) number / string / boolean / null / undefined
  • 리터럴 타입
    • 특정 값으로 만든 타입
    • ex) 10, “hello”
    • 리터럴 타입은 단독으로 쓰이기보다는 유니온 타입과 결합될때 활용도가 높습니다.
      type Direction = "left" | "right" | "center";

✏️ 타입 별칭과 인덱스 시그니처

타입 별칭

type 또는 interface를 사용해 타입에 이름을 붙이고 재사용하는 방법입니다.

interface Person {
  name: string;
  age: number;
}

type Person = {
  name: string;
  age: number;
}
  • interface : 객체 타입 선언에 특화, 선언 병합 가능
  • type : 유니온, 튜플, 리터럴 등 다양한 타입 표현 가능

인덱스 시그니처

객체의 key를 미리 알 수 없을 때, key와 value의 타입 규칙만 정의하는 방식입니다.
아래 코드처럼 객체의 key가 고정되어 있다면, 각 key를 타입에 명시적으로 정의할 수 있습니다.

type CountryCodes = {
	Korea: string,
	UnitedState: string,
	UnitedKingdom: string,
}

let countryCodes: CountryCodes = {
	Korea: "ko",
	UnitedState: "us",
	UnitedKingdom: "uk",
}

하지만 key가 계속 추가되는 경우에는 타입도 함께 수정해야 하므로 관리가 어렵습니다.
이럴 때 인덱스 시그니처를 사용하면 key의 개수를 미리 알 필요 없이 타입을 정의할 수 있습니다.

// 인덱스 시그니처
type CountryCodes = {
	[key : string] : string;
}

let countryCodes: CountryCodes = {
	Korea: "ko",
	UnitedState: "us",
	UnitedKingdom: "uk",
}

이 타입은 문자열 key와 string value를 갖는 객체 구조만을 보장하며,
동적으로 확장되는 key-value 데이터를 표현할 때 사용됩니다.


✏️ 타입 추론/단언/좁히기

타입 추론

TS가 코드의 값과 문맥을 기준으로 타입을 자동으로 결정하는 동작입니다.
밑의 코드의 x처럼 타입을 명의하지 않은 변수도 초기값을 기준으로 number로 추론됩니다.

또한 함수의 반환 타입은 return 값을 기준으로 자동 추론됩니다.

isBigNumber 함수의 리턴값도 boolean이라고 자동 추론된 것을 볼 수 있습니다.


타입 단언

개발자가 컴파일러에게 특정 타입임을 직접 알려주는 방식입니다.

const element = document.getElementById("myElement") as HTMLDivElement;

element.style.color= "red"; 

document.getElementById() 메서드의 반환 타입은 HTMLElement | null 로 정의되어 있지만,
해당 요소가 HTMLDivElement임을 확신하는 경우 as HTMLDivElement 를 사용해 타입을 단언 할 수 있습니다.


타입 좁히기

여러 타입이 가능한 값에서, 조건문 같은 제어 흐름을 통해 타입 범위를 더 구체적으로 줄이는 과정입니다.
이때 typeof, Array.isArray 같은 “타입을 판별하는 조건/함수”를 타입 가드라고 부르며, 가드의 결과로 분기 내부에서 타입이 좁혀집니다.

function printLength(value: string | string[]) {
  if (Array.isArray(value)) {
    // value: string[]
    console.log(value.length);
  } else {
    // value: string
    console.log(value.length);
  }
}

Array.isArray(value)로 배열 여부를 판별해 분기 내부에서 value 타입이 string[] 또는 string으로 좁혀집니다.

type Age = string | number;

function getAge(age: Age) {
  if (typeof age === "string") {
    // age: string
    console.log(age.length);
  } else {
    // age: number
    console.log(age.toFixed(0));
  }
}

typeof age === "string"으로 문자열 여부를 판별해 분기 내부에서 age 타입이 string 또는 number로 좁혀집니다.


✏️ 제네릭

타입을 미리 고정하지 않고, 사용하는 시점에 타입을 전달받아 재사용하는 방식입니다.

예를 들어 배열이나 함수가 number 전용, string 전용으로만 동작하면 재사용성이 떨어집니다. 이때 제네릭을 쓰면 같은 로직을 여러 타입에 적용할 수 있습니다.

function toArray<T>(value: T): T[] {
  return [value];
}

const a = toArray<number>(1);       // number[]
const b = toArray<string>("hello"); // string[]

더하는 함수에서는, 매번 숫자가 아니라 두 단어를 더할때도 쓸 수 있습니다.

function add<T>(x: T, y: T): T {
   return x + y;
}

add<number>(1, 2);
add<string>('hello', 'world');

🍀 유틸리티 타입

유틸리티 타입은 크게 맵드 타입 기반 , 조건부 타입 기반 이 두 가지로 나뉘게 됩니다.

맵드 타입 기반

  • Partial : 모든 프로퍼티를 선택적 프로퍼티로 변경
  • Required : 모든 프로퍼티를 필수 프로퍼티로 변경
  • Readonly : 모든 프로퍼티를 읽기 전용 프로퍼티로 변경
  • Pick : 특정 프로퍼티만 선택
  • Omit : 특정 프로퍼티를 제외
  • Record : key-value 형태의 객체 타입 생성

조건부 타입 기반

  • Exclude : T에서 U를 제거
  • Extract : T에서 U를 추출
  • ReturnType : 함수의 반환값 타입을 추출

유틸리티 타입은 쉽게 직접 구현해 볼 수 있습니다.
예를 들어 Pick은 다음과 같습니다.

interface Post {
	title: string;
	tags: string[];
	content: string;
	thumbnailURL?: string;
}

type Pick<T, K extends keyof T> = {
	// K extends 'title' | 'tags | 'content' | 'thumbnailURL'
	// 'title' | 'content' extends 'title' | 'tags | 'content' | 'thumbnailURL'
	[key in K]: T[key];
}

const legacyPost: Pick<Post, "title" | "content"> = {
	title: "옛날 글",
	content: "옛날 컨텐츠",
}

해당 링크에 제가 다른 유틸리티 타입들도 구현해 두었으니, 참고하시면 동작 원리를 이해하는 데 도움이 될 것 같습니다.


예를 들어, 정보에 대한 타입이 있다고 가정해 보겠습니다.

type Info = {
  id: number;
  title: string;
  description: string;
  content: string;
  createdAt: string;
  updatedAt: string;
};

개발을 하다 보면 Info 타입을 그대로 사용하기에는 필드가 많아 요약 정보용 타입이 필요한 경우가 생깁니다.
이때 새로운 타입을 정의하지 않고 Pick을 사용할 수 있습니다.

type SummaryInfo = Pick<Info, "id" | "title" | "createdAt">;

선택할 속성이 많다면, 반대로 제외할 속성을 기준으로 Omit을 사용할 수 있습니다.

type SummaryInfo = Omit<Info, "description" | "content" | "updatedAt">;

또한 기존 타입의 일부 속성과 추가 속성을 함께 사용하는 경우에는,
Pick과 교차 타입(&)을 조합해 표현할 수 있습니다.

type AnotherInfo = Pick<Info, "id" | "title"> & {
  commentCount: number;
};

이러면, 기존 타입을 재사용하면서 구조를 확장하기 유용합니다.


🍀 유용한 ReactNode

ReactNode 란 컴포넌트가 렌더링 할 수 있는 모든 것을 포함하는 가장 넓은 범위의 타입입니다.
문자열, 숫자, null, undefined, ReactElement까지 포함하기 때문에 주로 children의 props 타입으로 사용됩니다.

    type ReactNode =
      | ReactElement
      | string
      | number
      | ReactFragment
      | ReactPortal
      | boolean
      | null
      | undefined;

재사용이 잦은 버튼 컴포넌트를 예로 들어 보겠습니다.
아래 세 가지 방식 중 어떤 구조가 가장 재사용하기 좋다고 느껴지시나요?

  // 1
  interface Props1 {
      text: string;
  }

  <Button text="버튼 안의 텍스트" />

  // 2
  interface Props2 {
      children: string;
  }

  <Button>
      버튼 안의 텍스트
  </Button>

  // 3
  interface Props3 {
      children: ReactNode;
  }

  <Button>
      버튼 안의 텍스트
  </Button>

정답은 없지만, 각각을 비교해 보면 다음과 같습니다.

  • 1번
    텍스트를 props로 전달받아 렌더링하는 방식입니다. 구조적으로 HTML button 태그와는 다르고, 버튼 내부를 문자열로만 다루게 됩니다.
  • 2번
    실제 button 태그처럼 children으로 내용을 받는 점은 자연스럽습니다. 다만 children 타입이 string으로 제한되어 있어, 문자열 외의 요소를 함께 사용하기 어렵습니다.
  • 3번
    childrenReactNode로 받아 문자열뿐 아니라 다른 컴포넌트나 태그도 함께 사용할 수 있습니다. 구조적으로도 HTML button 태그와 유사해, 아이콘과 텍스트가 함께 들어가는 경우에도 대응할 수 있습니다.

재사용성과 확장성을 고려하면, 개인적으로는 3번 방식이 가장 적절하다고 생각합니다.


🍀 느낌표 연산자 (!) 를 조심하자.

느낌표 연산자(Non-null assertion operator) !
특정 값이 null이나 undefined가 아님을 개발자가 컴파일러에게 단언하는 문법입니다.
이 연산자는 타입 검사 단계에서만 동작하며, 실제 런타임에서는 아무런 검증도 수행하지 않습니다.

아래 코드에서는 email은 optional 속성이므로 실제로는 없을 수 있지만, ! 을 사용함으로써 TS에게 “무조건 email 있어” 라고 말해주는 셈입니다.

interface User {
  email?: string;
}

function printEmail(user: User) {
  console.log(user.email!.toLowerCase());
}

이 코드는 컴파일 에러는 발생하지 않지만,
실행 시 email이 undefined라면 toLowerCase() 호출로 인해 런타임 에러가 발생합니다.


왜 조심해야 할까?

! 연산자는 개발자의 주장일 뿐, 이를 뒷받침하는 실제 검증 로직은 없습니다.
즉, 타입 에러를 해결하는 근거가 아니라 “내가 보기엔 이 값은 있을 거야”라는 가정에 가깝습니다.
그래서 타입 에러 난다고 무작정 ! 를 붙이기보다는, 아래와 같은 대안을 먼저 고려하는 것이 좋습니다.


대안들

  1. 옵셔널 체이닝 (?.)

    • 값이 undefined이거나 null이면 즉시 평가를 중단하고 undefined를 반환
    • 존재 여부가 불확실한 값을 가장 안전하게 접근하는 방법
    console.log(user.email?.toLowerCase());
  2. 널 병합 연산자 (??)

    • 값이 null 또는 undefined일 때 기본값을 명시적으로 지정
    • “없으면 이 값으로 처리한다”는 의미
    console.log((user.email ?? "").toLowerCase());
  3. 조건부 연산자 ( early return / if )

    • 값이 없으면 아예 로직을 실행하지 않도록 차단
    if (!user.email) return;
    
    console.log(user.email.toLowerCase());
  4. 타입 가드

    • typeof, instanceof 등을 활용해 런타임에서 실제 타입을 체크
    type Age = 'string' | 'number';
    
    // ❌ 에러 발생 -> age가 string이 아니라 number일 수도 있음
    function getAge1(age: Age) {
    	age.length;
    }
    
    // ✅ 정상 동작
    function getAge2(age: Age) {
    	if (typeof age === 'string') {
    		age.length;
    	}
    }

팀 차원의 제어

팀 규칙으로 eslint를 통해 ! 연산자를 사용하면 error가 나도록 설정 할 수도 있습니다.

export default tseslint.config({
  rules: {
    '@typescript-eslint/no-unnecessary-type-assertion': 'error',
  },
})

이러면 몰래쓰지도 못합니다 ㅠㅜ


🍀 Zod로 우아하게

이미지 설명

Zod는 TypeScript-first 스키마 유효성 검사 라이브러리로,
정적 타입 추론과 런타임 검증을 함께 제공합니다.

백엔드 API에서 넘어오는 데이터나 폼 데이터를 다룰 때,
우리는 보통 if 문과 typeof 체크로 데이터 형태를 검증합니다.
이 과정에서 조건문이 늘어나고, 누락된 속성이나 기본값 처리까지 직접 관리하게 됩니다.

Zod를 사용하면 이러한 검증 로직을 선언적인 스키마로 표현할 수 있습니다.
보일러플레이트를 줄이면서도, TypeScript의 타입 안전성과 런타임 데이터 검증을 동시에 확보할 수 있습니다.


기존 방식의 불편함

아래는 “순수 TS + 수동 검증” 방식에서의 불편함을 보여주는 예시입니다.

type User = {
	name: string;
	age: number;
};

async function fetchUser(): Promise<User> {
	const res = await fetch('https://api.example.com/user');
	const data = await res.json();
	
	// 수동 검증
	if (!data.age || typeof data.age !== 'number') {
		throw new Error("에러임~")
	};
	
	return {
		// 기본값 처리도 직접 해야함
		name: data.name || "Choi",
		age: data.age,
	};
}

데이터 구조가 커질수록 검증, 기본값, 에러 처리가 많아져, 스파게티 코드가 되면서 유지보수가 힘들어집니다.


Zod를 사용한 방식

Zod는 이런 불편함을 해결하기 위해 선언적인 방식으로 데이터의 형태인 Schema를 정의하고 검증합니다.

import { z } from 'zod';

// 스키마 정의가 곧 문서이자 타입입니다.
const UserSchema = z.object({
	name: z.string().default("Choi"),
	age: z.number(),
});

async function fetchUser() {
	const res = await fetch('https://api.example.com/user');
	const data = await res.json();
	
	// 단 한 줄로 검증과 타입 추론 완료
	return UserSchema.parse(data);
}

UserSchema.parse(data) 한 줄로 런타임 검증이 끝나고, 스키마와 맞지 않으면 예외가 발생합니다.


z.infer

이전 코드에서 UserSchema.parse() 반환 타입은 함수 내부에서 자동으로 추론됩니다.

다만 fetchUser() 의 반환값을 여러 곳에서 재사용하게 되면, User 같은 타입이 필요할 수 있습니다. 이때 type User = { … } 를 따로 만들면, 스키마(UserSchema)와 타입(User)을 둘 다 관리해야 해서 수정 시점에 둘이 어긋날 수 있습니다.

이때 z.infer를 활용하면 User를 따로 다시 작성하지 않고, 스키마에서 타입을 뽑아 써서 편리합니다.
(z.infer는 Zod 스키마로부터 TypeScript 타입을 추론해주는 유틸리티입니다.)

// 별도의 interface나 type 정의가 필요 없습니다.
type User = z.infer<typeof UserSchema>;

// User 타입은 자동으로 다음과 같이 추론됩니다.
// { name: string; age: number; }

필드가 추가되거나 타입이 바뀌어도 UserSchema만 수정하면 User도 함께 갱신됩니다.


Zod 공식 문서와 다른 분의 form 관련 zod 글도 너무 좋으니, 시간 날 때 참고하면 좋을 것 같습니다 ㅎㅎ


🍀 마무리

공부하다보니, TS에 대해 모르는게 아직도 산더미라는 걸 느꼈습니다.
역시 공부에는 끝이 없군요… 차근차근 나아가야겠습니다.

처음에는 타입 에러가 와장창 뜰 때마다 “아… TS 없애고 싶다” 같은 생각을 정말 많이 했던 것 같습니다. “TS만 전담하는 개발자가 따로 있으면 좋겠다”라는 생각도 들었고요.
그런데 또 쓰다 보면, TypeScript의 도움을 받아서 점점 편해지는 순간들이 생기더라고요. 데이터가 명확해진다거나, 전체적인 로직이나 데이터 플로우가 눈에 더 잘 들어온다거나… 아무튼 TS의 필요성과 유용성을 체감하게 되는 순간이 오는 것 같습니다. (물론 아직도 어렵긴 합니다)

만약 이 글을 너무나도 쉽게 읽혔다면, TS 고수 일수도?ㅎㅎ (미리 박수 👏👏👏)
(참고로 전 쌀국수에 고수 안넣어 먹습니다 ㅈㅅ)


🌐 참고 자료

profile
정답은 없지만, 가까워지려고 노력하고 있습니다 :)

4개의 댓글

comment-user-thumbnail
4일 전

경일님 역시 정리 잘하시는군요 ㅎㅎ 잘 읽고 가요!!

1개의 답글
comment-user-thumbnail
4일 전

'GOAT'

1개의 답글