raw
Development

TypeScript 톺아보기

2025.08.26·18분

왜 톺아보는가?

따로 TypeScript를 공부하지는 않고, 개발을 하면서 모르는 부분을 검색하며 학습을 했습니다. 개발을 할 때는 필요하면 type을 바로 추가하거나, 오류가 나는 부분은 적당하게 맞춰서 type을 작성하다보니, 크게 어려움을 느끼지 못 했습니다.

최근에 오픈소스를 기여하게 되면서, 어능정도 정형화된 컨벤션과 type을 관리하는 방법들을 잘 소화하지 못 해 삽질 하게 되면서 한번 톺아봐야겠다 라고 결론을 내렸습니다.

TypeScript

왜 사용하는 걸까?

프로젝트를 만들어서 개발을 하게되면, JavsScript 또는 TypeScript로 언어를 선택하게 됩니다. 보통 TypeScript는 타입을 지정해줘야하는 번거로움 때문에 JavaScript를 사용하는 사람도 많습니다.

'TypeScript' 는 JavaScript 기반의 정적 타입 문법을 추가한 프로그래밍 언어입니다.

'JavaScript' 는 대표적인 동적 타입 언어입니다. 배우기 쉽고 유연한 장점이 있지만, 동시에 런타입 에러 발생 가능성을 가지고 있습니다. 여러 의존성이 겹친 규모가 큰 프로젝트에서는 에러가 어디서 발생하는지 알기가 쉽지 않습니다.

하지만, TypeScript는 정적 타입 언어로 변수의 타입을 명시적으로 지정해서 사용합니다.

컴파일 단계에서 타입 체크를 하여 코드 작성 중 타입 오류를 방지하고, 타입 체킹 기능인 '타입 어노테이션' 을 활용하여 에러를 빠르게 처리할 수 있습니다.

어떻게 설정할까?

TypeScript를 사용하기 위해서는 tsconfig.json이 필요합니다. 컴파일 옵션을 설정 할 수 있으며, 여러가지 오류의 범위를 지정할 수 있습니다. 다음 동작원리를 보면서 왜 이 과정이 필요한지 알아봅시다.

출처: 한 입 크기로 잘라먹는 타입스크립트 | 더 자세한 내용

동작원리

JavaScript의 컴파일 과정을을 보면 코드가 AST로 변환하고 AST를 다시 바이트 코드로 변환되어서 이렇게 변환된 바이트코드를 컴퓨터가 실행하게 됩니다.

이게 대다수의 프로그래밍 언어의 동작 방식입니다.

그러면 TypeScript는 어떻게 동작할까요?

마찬가지로 TypeScript도 AST로 변환합니다. 그리고 AST에서 Type Checking을 하게 됩니다.

성공 시 JavaScript의 컴파일 과정으로 변환이 됩니다. 실패 시 컴파일이 종료되구요.

따라서 TypeScript 컴파일 시 미리 타입 체킹 이 되니 보다 안전하게 컴파일이 가능합니다.

TypeScript 흔히 잘 모르는 것들

타입 계층도

위 계층도를 알고 계시면, 타입을 상속하거나 다른 타입에 할당 가능한 지 알 수 있습니다.

슈퍼타입(상위 타입) 은 더 넓은 범위의 타입이고, 서브 타입(하위 타입) 은 더 구체적인 타입입니다.

업 캐스팅 : 서브 타입의 값을 슈퍼타입의 값으로 취급하는 것 ( 대부분 가능 ) 다운 캐스팅 : 슈퍼 타입의 값을 서브 타입의 값으로 취급하는 것 ( 대부분 불가능 )

타입 단언과 타입 좁히기 그리고 유니온 타입

여러 복잡한 타입 속에서 타입 추론을 하다보면 내부 코드가 타입 추론 을 하지 못하고 빨간색 에러를 나타나게 되는데 이 때 AI에게 도움을 받으면 옵셔널 체이닝(?.)as any로 그냥 에러를 급하게 막게 됩니다.

협업 시 타입 관련 오류를 찾지 못 하거나, 이상한 값을 불러와서 오히려 생산성을 저하 시킬 수 있습니다.

그래서 타입 단언, 타입 좁히기 그리고 유니온 타입 을 적절히 사용하여 타입을 관리하면 클린 코드 측면에서 효율적으로 코드를 작성할 수 있습니다.

ts
1// 타입 단언 (as 키워드)
2const element = document.getElementById('myId') as HTMLInputElement;
3
4// 타입 좁히기
5function processValue(value: string | number) {
6 if (typeof value === 'string') {
7 // 이 블록에서는 value가 string 타입으로 좁혀짐
8 return value.toUpperCase();
9 }
10 // 이 블록에서는 value가 number 타입
11 return value.toFixed(2);
12}
13
14// 유니온 타입과 함수 오버로딩을 통한 타입 좁히기
15function getData(id: string): string;
16function getData(id: number): number;
17function getData(id: string | number): string | number {
18 if (typeof id === 'string') {
19 return `Data for ${id}`;
20 }
21 return id * 2;
22}
23
24// 사용자 정의 타입 가드
25function isString(value: unknown): value is string {
26 return typeof value === 'string';
27}

Q. 제네릭 타입 변수를 사용하면 보다 깔끔하게 코드 작성이 가능하지 않나요? A. 제네릭은 미리 정해진 타입 구조에서 타입만 바뀌는 경우 적절하고, 타입 좁히기는 런타입에 실제 값을 보고 타입을 판단해야 하는 경우가 적절하여, 둘 다 상황에 맞게 사용해야 합니다.

인터페이스와 타입

ts
1// 인터페이스 - 확장 가능
2interface User {
3 name: string;
4 age: number;
5}
6
7interface User {
8 email: string; // ✅ 선언 병합 가능
9}
10
11interface Admin extends User {
12 role: string;
13}
14
15// 타입 별칭 - 더 유연함
16type UserType = {
17 name: string;
18 age: number;
19}
20
21// type UserType = { email: string } // ❌ 중복 선언 불가
22
23type AdminType = UserType & {
24 role: string;
25}
26
27// 유니온 타입, 원시값 등은 type만 가능
28type Status = 'loading' | 'success' | 'error';
29type ID = string | number;

둘 다 타입을 정의하지만, 미묘한 차이가 있습니다.

확장 가능성이 있는 객체 구조 : interface 유니온, 교차 타입, 복합 타입: type

프로젝트 마다 타입을 어떻게 관리 할 지 다르다고 합니다. 저는 interface로 관리하고, 유니온 타입이 필요하다면 type으로 선언하는 편 입니다 !

제너릭? 제너릭이 핵심 문법

제네릭이란?

제네릭은 함수나 인터페이스, 타입 별칭, 클래스 등을 다양한 타입과 함께 동자갛도록 만들어 주는 타입 스크립트의 핵심 기능 중 하나 입니다.

제네릭이 필요한 상황

ts
1// 제네릭 없이 만든다면...
2function getFirstString(arr: string[]): string {
3 return arr[0];
4}
5
6function getFirstNumber(arr: number[]): number {
7 return arr[0];
8}
9
10function getFirstBoolean(arr: boolean[]): boolean {
11 return arr[0];
12}
13
14// 이런 식으로 무한히 만들어야 함...

해당 타입의 맞는 메서드를 사용할 때, 위에서 배운 것 처럼 타입 좁히기 를 사용하면 타입 추론이 되어서 사용가능합니다, 그런데 이 문제를 더 간단하게 해결 가능합니다.

ts
1function getFirst<T>(arr: T[]): T {
2 return arr[0];
3}
4
5// 이제 모든 타입에서 사용 가능
6const firstString = getFirst(['a', 'b', 'c']); // string
7const firstNumber = getFirst([1, 2, 3]); // number
8const firstUser = getFirst([{name: 'John'}, {name: 'Jane'}]); // {name: string}

이렇듯 인수에 따라서 가변적인 타입을 정할 수 습니다.

다양한 예제를 보면

ts
1// 기본 제너릭
2function identity<T>(arg: T): T {
3 return arg;
4}
5
6const stringResult = identity<string>("hello"); // string
7const numberResult = identity(42); // 타입 추론으로 number
8
9// 제약 조건이 있는 제너릭
10interface Lengthwise {
11 length: number;
12}
13
14function logLength<T extends Lengthwise>(arg: T): T {
15 console.log(arg.length);
16 return arg;
17}
18
19// 여러 제너릭 매개변수
20function merge<T, U>(obj1: T, obj2: U): T & U {
21 return { ...obj1, ...obj2 };
22}
23
24// 조건부 타입과 함께
25type NonNullable<T> = T extends null | undefined ? never : T;
  1. 제약 조건 (Constraints) 때로는 제네릭 타입이 특정 조건을 만족해야 할 때가 있습니다. extends 키워드를 사용해서 제약을 걸 수 있습니다.

  2. 여러 제네릭 매개변수 여러 개의 제네릭 타입을 사용할 수도 있습니다.

  3. 조건부 타입과 함께 제네릭과 조건부 타입을 함께 사용하면 더 복잡한 타입 로직을 만들 수 있습니다.

인덱스드 엑세스 타입, keyof,맵드, 템플릿 리터럴 타입

TypeScript의 고급 타입 기능들입니다. 처음에는 복잡해 보이지만, 이해하고 나면 정말 강력한 도구들입니다.

keyof 연산자 - 객체 타입의 모든 키를 유니온 타입으로 만들어주는 연산자

ts
1type Person = {
2 name: string;
3 age: number;
4 address: string;
5}
6
7type PersonKeys = keyof Person; // "name" | "age" | "address"

객체의 키만 받는 받는 함수를 만들때 안전하게 사용 가능합니다.

인덱스드 엑세스 타입 - 이미 정의된 타입에서 특정 속성의 타입만 뽑아낼 때 사용합니다.

ts
1type Person = {
2 name: string;
3 age: number;
4 address: {
5 city: string;
6 country: string;
7 }
8}
9
10type PersonName = Person["name"]; // string
11type PersonAddress = Person["address"]; // { city: string; country: string; }
12type PersonCity = Person["address"]["city"]; // string
13
14// 배열의 요소 타입도 가져올 수 있음
15type Users = User[];
16type SingleUser = Users[number]; // User

맵드 타입 - 기존 타입을 '변환'해서 새로운 타입을 만드는 방법입니다. 객체의 모든 속성에 동일한 변환을 적용할 때 사용

ts
1type Person = {
2 name: string;
3 age: number;
4 email: string;
5}
6
7// 모든 속성을 선택적으로 만들기
8type OptionalPerson = {
9 [K in keyof Person]?: Person[K];
10}
11// 결과: { name?: string; age?: number; email?: string; }
12
13// 모든 속성을 읽기 전용으로 만들기
14type ReadonlyPerson = {
15 readonly [K in keyof Person]: Person[K];
16}
17// 결과: { readonly name: string; readonly age: number; readonly email: string; }

템플릿 리터럴 타입 - 타입 레벨에서 문자열을 조합할 수 있는 기능입니다.

ts
1type Greeting = "hello" | "hi";
2type Name = "world" | "typescript";
3
4type GreetingMessage = `${Greeting} ${Name}`;
5// "hello world" | "hello typescript" | "hi world" | "hi typescript"

유틸리티 타입들

TypeScript에서 기본으로 제공하는 유틸리티 타입들은 정말 자주 사용하게 됩니다. 다양한 예시를 보여드리겠습니다 !

Partial<T> - 모든 속성을 선택적(optionoal)으로 만들어주는 타입입니다.

ts
1interface User {
2id: number;
3name: string;
4email: string;
5age: number;
6}
7
8type PartialUser = Partial<User>;
9// { id?: number; name?: string; email?: string; age?: number; }
10
11// 실무 예시: 사용자 정보 업데이트
12function updateUser(id: number, updates: Partial<User>) {
13// id는 필수, 나머지는 선택적으로 업데이트
14}
15
16updateUser(1, { name: "새 이름" }); // ✅
17updateUser(1, { email: "new@email.com", age: 25 }); // ✅

Required<T> - Partial과 반대로, 모든 속성을 필수로 만들어줍니다.

ts
1interface Config {
2 apiUrl?: string;
3 timeout?: number;
4 retryCount?: number;
5}
6
7type RequiredConfig = Required<Config>;
8// { apiUrl: string; timeout: number; retryCount: number; }
9
10// 기본값으로 채워진 완전한 설정을 만들 때
11function createConfig(partial: Config): RequiredConfig {
12 return {
13 apiUrl: partial.apiUrl ?? 'http://localhost:3000',
14 timeout: partial.timeout ?? 5000,
15 retryCount: partial.retryCount ?? 3
16 };
17}

Readonly<T> -모든 속성을 읽기 전용으로 만들어줍니다.

ts
1typescriptinterface User {
2 id: number;
3 name: string;
4}
5
6type ReadonlyUser = Readonly<User>;
7// { readonly id: number; readonly name: string; }
8
9const user: ReadonlyUser = { id: 1, name: "John" };
10// user.name = "Jane"; // ❌ 컴파일 에러! 읽기 전용이라 수정 불가

Pick<T, K> - 특정 속성들만 선택해서 새로운 타입을 만듭니다.

ts
1typescriptinterface User {
2id: number;
3name: string;
4email: string;
5password: string;
6createdAt: Date;
7updatedAt: Date;
8}
9
10type UserPublicInfo = Pick<User, "id" | "name" | "email">;
11// { id: number; name: string; email: string; }
12
13// API 응답에서 민감한 정보 제외하고 반환할 때
14function getUserPublicInfo(user: User): UserPublicInfo {
15return {
16 id: user.id,
17 name: user.name,
18 email: user.email
19};
20}

Omit<T, K> - Pick과 반대로, 특정 속성들을 제외한 나머지로 새로운 타입을 만듭니다.

ts
1typescripttype UserWithoutSensitiveInfo = Omit<User, "password" | "createdAt" | "updatedAt">;
2// { id: number; name: string; email: string; }
3
4// 사용자 생성할 때 id, createdAt, updatedAt은 자동 생성
5type CreateUserRequest = Omit<User, "id" | "createdAt" | "updatedAt">;
6// { name: string; email: string; password: string; }
7Record\<K, T\>
8-값 쌍의 타입을 만들어줍니다. 객체의 모든 키가 같은 타입의 값을 가질 때 사용해요.
9typescripttype UserRole = "admin" | "user" | "guest";
10type UserPermissions = Record<UserRole, string[]>;
11// { admin: string[]; user: string[]; guest: string[]; }
12
13const permissions: UserPermissions = {
14admin: ["read", "write", "delete"],
15user: ["read", "write"],
16guest: ["read"]
17};
18
19// 또는 동적인 키를 가진 객체
20type ApiCache = Record<string, any>;
21const cache: ApiCache = {
22"/api/users": [...],
23"/api/posts": [...],
24// 어떤 문자열 키든 가능
25};

Exclude<T, U>와 Extract<T, U> - 유니온 타입에서 특정 타입을 제외하거나 추출할 때 사용합니다.

ts
1typescripttype AllStatus = "loading" | "success" | "error" | "idle";
2
3// Exclude: 특정 타입 제외
4type ActiveStatus = Exclude<AllStatus, "idle">;
5// "loading" | "success" | "error"
6
7// Extract: 특정 타입만 추출
8type ErrorStates = Extract<AllStatus, "error" | "loading">;
9// "error" | "loading"
10ReturnType\<T\>
11함수의 반환 타입을 추출해줍니다. 함수의 리턴 값 타입을 다른 곳에서 재사용하고 싶을 때 유용해요.
12typescriptfunction getUser() {
13return {
14 id: 1,
15 name: "John",
16 email: "john@example.com"
17};
18}
19
20type GetUserReturn = ReturnType<typeof getUser>;
21// { id: number; name: string; email: string; }
22
23// API 함수들의 반환 타입을 타입으로 사용할 때
24type UserData = ReturnType<typeof getUser>;
25const users: UserData[] = [];

마무리

TypeScript를 사용하게 되면서 제네릭으로 얽혀있고 상속받는 타입들의 출처를 찾아다니면서 why? 를 계속 남발했었다. 하지만, 이제는 의도 를 알게 되었고, 의도를 파악하니 이유 를 알게되었다.

너무 재밌다. 마치 책을 읽으며 저자의 마음을 알게되었을 때, 이런 표현 방법을 사용하는구나 와 같은 맥락이었다.

다음은 Next.js를 톺아 볼 예정이다.

긴 글 읽어주셔서 감사합니다.

[출처] 인프런 : 한 입 크기로 잘라먹는 타입스크립트(TypeScript) 문서 : [한 입 크기로 잘라먹는 타입스크립트(winterlood- 문서] (https://ts.winterlood.com/) 더 자세한 글을 확인하실 수 있습니다.