TypeScript 언어 핵심 정리

TypeScript는 JavaScript에 정적 타입 시스템을 추가한 프로그래밍 언어다. Microsoft에서 개발했으며, JavaScript 생태계를 그대로 사용하면서 규모가 커졌을 때 발생하는 타입 관련 문제를 줄이는 것을 주요 목적으로 한다.

TypeScript의 가장 중요한 특징은 브라우저나 Node.js가 TypeScript를 직접 실행하는 것이 아니라는 점이다.

작성한 TypeScript 코드는 먼저 JavaScript로 변환된다.

TypeScript -> TypeScript Compiler -> JavaScript -> Browser / Node.js / Runtime

따라서 TypeScript는 JavaScript를 대체하는 별도의 실행 환경이라기보다 JavaScript 프로그램을 더 안전하게 작성하기 위한 언어와 개발 도구의 집합에 가깝다.

특히 프로젝트 규모가 커지면서 함수의 입력과 출력, 객체의 구조, API 데이터 형태 등을 코드 수준에서 명확하게 관리해야 할 때 TypeScript의 가치가 커진다.


JavaScript와 TypeScript

JavaScript는 기본적으로 동적 타입 언어다.

let value = 10;

value = "Hello";

하나의 변수에 서로 다른 타입의 값을 넣을 수 있다.

TypeScript에서는 타입을 명시할 수 있다.

let value: number = 10;

// value = "Hello";

이 코드는 컴파일 과정에서 타입 오류가 발생한다.

다만 TypeScript의 타입 검사는 실행 중에 이루어지는 것이 아니다.

TypeScript -> Type Checking -> JavaScript 생성 -> 실행

타입 정보는 일반적으로 JavaScript로 변환될 때 제거된다.

따라서 TypeScript의 타입 시스템은 주로 개발 단계에서 오류를 발견하는 역할을 한다.


기본 타입

TypeScript에는 JavaScript의 기본 값에 대응하는 타입들이 있다.

let name: string = "Player";
let age: number = 24;
let alive: boolean = true;

배열도 타입을 지정할 수 있다.

let scores: number[] = [10, 20, 30];

let names: Array<string> = [
    "A",
    "B",
    "C"
];

객체는 구조를 기준으로 타입을 정의할 수 있다.

let player: {
    name: string;
    health: number;
} = {
    name: "Knight",
    health: 100
};

하지만 실제 프로젝트에서는 이런 객체 타입을 반복해서 작성하기보다 interfacetype을 사용하는 경우가 많다.


Type Inference

TypeScript는 타입을 항상 직접 작성할 필요가 없다.

let health = 100;
let name = "Player";

TypeScript가 초기값을 보고 타입을 추론한다.

health -> number
name -> string

함수의 반환 타입도 추론할 수 있다.

function add(a: number, b: number) {
    return a + b;
}

반환값이 number라는 것을 컴파일러가 알 수 있다.

따라서 타입을 무조건 모든 곳에 붙이는 것보다 컴파일러가 명확하게 추론할 수 있는 곳은 맡기고, 외부 경계나 의도가 필요한 곳에 타입을 명시하는 방식이 실용적이다.


함수

함수의 매개변수와 반환 타입을 지정할 수 있다.

function add(
    a: number,
    b: number
): number {
    return a + b;
}

반환값이 없는 함수는 void를 사용할 수 있다.

function log(message: string): void {
    console.log(message);
}

함수 자체도 타입으로 표현할 수 있다.

let operation: (a: number, b: number) => number;

operation = (a, b) => a + b;

함수를 값처럼 전달할 수 있다는 JavaScript의 특성을 TypeScript에서도 그대로 사용할 수 있다.


Optional Parameter

매개변수 뒤에 ?를 붙이면 선택적 매개변수가 된다.

function greet(name?: string) {
    console.log(name);
}

호출할 때 생략할 수 있다.

greet();
greet("Player");

이때 name의 타입은 사실상 다음과 같이 취급된다.

string | undefined

따라서 값이 존재하지 않는 상황을 고려해야 한다.


Union Type

하나의 값이 여러 타입 중 하나가 될 수 있다면 Union Type을 사용한다.

let value: string | number;

value = "Hello";
value = 100;

함수에서도 사용할 수 있다.

function print(value: string | number) {
    console.log(value);
}

Union Type은 API 응답이나 여러 상태를 표현할 때 자주 사용한다.

type Result = string | null;

Type Narrowing

Union Type을 사용하면 실제 타입을 확인한 뒤 그에 맞는 코드를 작성해야 한다.

function print(value: string | number) {
    if (typeof value === "string") {
        console.log(value.toUpperCase());
    }
    else {
        console.log(value.toFixed(2));
    }
}

typeof 검사 이후 TypeScript가 타입을 좁혀준다.

이것을 Type Narrowing이라고 한다.

객체에서도 사용할 수 있다.

if (player instanceof Player) {
    player.attack();
}

이런 방식으로 런타임의 조건과 TypeScript의 타입 시스템을 연결할 수 있다.


Interface

객체의 구조를 정의할 때 interface를 사용할 수 있다.

interface Player {
    id: number;
    name: string;
    health: number;
}

이제 해당 구조를 사용하는 객체를 만들 수 있다.

const player: Player = {
    id: 1,
    name: "Knight",
    health: 100
};

함수의 매개변수에도 사용할 수 있다.

function attack(player: Player) {
    console.log(player.name);
}

인터페이스의 목적은 클래스 자체를 정의하는 것이 아니라 객체가 어떤 구조를 가져야 하는지 계약을 표현하는 것에 가깝다.


Optional Property

프로퍼티 뒤에 ?를 붙이면 해당 프로퍼티를 생략할 수 있다.

interface Player {
    name: string;
    level?: number;
}

다음 두 객체 모두 유효하다.

const a: Player = {
    name: "A"
};

const b: Player = {
    name: "B",
    level: 10
};

사용할 때는 level이 존재하지 않을 가능성을 고려해야 한다.


readonly

프로퍼티를 읽기 전용으로 만들 수도 있다.

interface Player {
    readonly id: number;
    name: string;
}

객체 생성 이후 id를 변경하려 하면 TypeScript에서 오류가 발생한다.

player.id = 10;

다만 readonly 역시 TypeScript의 타입 시스템에 속하는 기능이다. 런타임에서 JavaScript 객체의 변경 자체를 강제로 막는 기능과는 다르다.


Type Alias

type을 사용해서 타입에 이름을 붙일 수도 있다.

type Player = {
    id: number;
    name: string;
    health: number;
};

간단한 객체 타입뿐만 아니라 Union Type도 표현할 수 있다.

type State =
    | "idle"
    | "move"
    | "attack"
    | "dead";

이제 State는 네 가지 문자열 중 하나만 허용한다.

let state: State = "idle";

게임 상태나 UI 상태처럼 허용 가능한 값의 범위가 명확한 경우 상당히 유용하다.


Interface와 Type

둘은 상당히 비슷한 용도로 사용할 수 있다.

interface Player {
    name: string;
}

type Player = {
    name: string;
};

일반적인 객체 구조를 표현할 때는 둘 다 사용할 수 있다.

차이점은 세부적인 타입 시스템에 있다.

type은 Union이나 Primitive Alias 같은 타입 조합에 자연스럽다.

type ID = string | number;

interface는 객체 구조를 정의하고 확장하는 형태에서 자연스럽다.

interface Entity {
    id: number;
}

interface Player extends Entity {
    name: string;
}

프로젝트에서는 둘 중 하나만 고집하기보다 목적에 맞게 사용하는 편이 좋다.


Intersection Type

여러 타입을 합칠 수도 있다.

type Position = {
    x: number;
    y: number;
};

type Entity = {
    id: number;
};

type GameObject = Position & Entity;

이제 GameObject는 세 가지 프로퍼티를 모두 가져야 한다.

const object: GameObject = {
    id: 1,
    x: 100,
    y: 200
};

Union이 “A 또는 B”라면 Intersection은 “A와 B를 모두 만족”하는 구조다.

A | B -> A 또는 B
A & B -> A와 B 모두

Class

TypeScript는 JavaScript의 Class 문법에 타입 시스템을 결합한다.

class Player {
    name: string;
    health: number;

    constructor(name: string, health: number) {
        this.name = name;
        this.health = health;
    }

    attack(): void {
        console.log("Attack");
    }
}

객체를 생성한다.

const player = new Player(
    "Knight",
    100
);

JavaScript의 Class와 기본 구조는 비슷하지만 접근 제한자와 타입 지정 등의 기능이 추가된다.


접근 제한자

TypeScript에서는 다음 접근 제한자를 사용할 수 있다.

public / private / protected
class Player {
    public name: string;
    private health: number;
    protected level: number;

    constructor() {
        this.name = "Player";
        this.health = 100;
        this.level = 1;
    }
}

private는 클래스 외부에서 직접 접근할 수 없도록 타입 시스템에서 제한한다.

player.health = 0;

컴파일 시 오류가 발생한다.

다만 TypeScript의 private와 JavaScript의 ECMAScript #private 필드는 성격이 다르다. 런타임 수준의 진짜 private 필드가 필요한 경우에는 # 문법을 사용할 수도 있다.


상속

TypeScript도 클래스 상속을 지원한다.

class Character {
    attack(): void {
        console.log("Character Attack");
    }
}

class Player extends Character {
    attack(): void {
        console.log("Player Attack");
    }
}

인터페이스 구현은 implements를 사용한다.

interface Damageable {
    takeDamage(amount: number): void;
}

class Player implements Damageable {
    takeDamage(amount: number): void {
        console.log(amount);
    }
}

상속 관계는 extends, 인터페이스 구현은 implements로 구분한다.


Generic

TypeScript에서 상당히 중요한 기능이다.

같은 구조를 여러 타입에서 사용하면서 타입 안정성을 유지할 수 있다.

function identity<T>(value: T): T {
    return value;
}

사용하면:

const a = identity(10);
const b = identity("Hello");

각각 number, string으로 추론된다.

컬렉션에서도 사용한다.

const players: Array<Player> = [];
const scores: Array<number> = [];

Generic을 사용하면 any를 사용하는 것보다 훨씬 안전하게 재사용 가능한 코드를 만들 수 있다.


Generic Constraint

Generic에 사용할 수 있는 타입을 제한할 수도 있다.

interface Entity {
    id: number;
}

function printId<T extends Entity>(entity: T) {
    console.log(entity.id);
}

T는 반드시 Entity의 구조를 만족해야 한다.

이런 방식으로 Generic의 유연성과 타입 안정성을 동시에 가져갈 수 있다.


any

any는 TypeScript의 타입 검사를 사실상 해제한다.

let value: any = 10;

value = "Hello";
value = {};
value.foo.bar();

컴파일러가 타입 오류를 거의 검사하지 않는다.

JavaScript 코드를 빠르게 TypeScript로 옮길 때 임시로 사용할 수 있지만, 프로젝트 전체에 any가 퍼지면 TypeScript를 사용하는 의미가 크게 줄어든다.

가능하면 정확한 타입이나 unknown을 사용하는 편이 낫다.


unknown

unknown도 어떤 값이든 받을 수 있지만 any보다 안전하다.

let value: unknown;

value = 10;
value = "Hello";

하지만 바로 메서드를 호출할 수 없다.

value.toUpperCase();

먼저 타입을 확인해야 한다.

if (typeof value === "string") {
    value.toUpperCase();
}

외부에서 들어오는 값을 아직 검증하지 않은 상태로 다룰 때 unknown이 적합하다.


never

never는 정상적으로 값을 반환하지 않는 경우를 표현한다.

대표적으로 항상 예외를 발생시키는 함수다.

function fail(message: string): never {
    throw new Error(message);
}

또는 모든 경우를 처리한 이후 더 이상 도달할 수 없는 상태를 표현할 때 사용할 수 있다.

Exhaustive Check에서도 활용된다.

type State = "idle" | "attack";

function handle(state: State) {
    switch (state) {
        case "idle":
            break;

        case "attack":
            break;

        default:
            const exhaustive: never = state;
            return exhaustive;
    }
}

새로운 상태가 추가되었는데 switch에서 처리하지 않았다면 타입 오류를 발생시킬 수 있다.


Optional Chaining

JavaScript에도 존재하는 기능으로 TypeScript에서도 사용할 수 있다.

const name = player?.name;

playernull 또는 undefined라면 접근을 중단한다.

중첩된 객체에서도 사용할 수 있다.

const value =
    player?.inventory?.items?.[0];

Null이나 Undefined가 존재할 수 있는 데이터를 다룰 때 유용하다.


Nullish Coalescing

??를 사용하면 값이 null 또는 undefined일 때 기본값을 사용할 수 있다.

const level =
    player.level ?? 1;

||와 차이가 있다.

const value = 0 || 10;

이 경우 0은 falsy이기 때문에 10이 된다.

반면:

const value = 0 ?? 10;

결과는 0이다.

따라서 “값이 없을 때”와 “falsy일 때”를 구분해야 한다.


Enum

TypeScript에는 enum이 있다.

enum State {
    Idle,
    Move,
    Attack,
    Dead
}

사용:

let state: State = State.Attack;

다만 현대 TypeScript 코드에서는 단순한 상태값이라면 문자열 Literal Union을 사용하는 경우도 많다.

type State =
    | "idle"
    | "move"
    | "attack"
    | "dead";

특히 JSON이나 API와 직접 연결되는 값이라면 문자열 Union이 더 자연스러운 경우가 많다.


Literal Type

TypeScript는 문자열이나 숫자 자체를 타입으로 사용할 수 있다.

let state: "idle" | "attack";

state = "idle";
state = "attack";

// state = "dead";

이것은 단순한 string보다 훨씬 강한 제약이다.

string -> 모든 문자열
"idle" | "attack" -> 두 값만 허용

UI 상태, 게임 상태, HTTP Method 같은 제한된 값 표현에 유용하다.


Type Assertion

개발자가 특정 타입이라고 확신하는 경우 Type Assertion을 사용할 수 있다.

const element =
    document.getElementById("game")
    as HTMLCanvasElement;

이것은 값을 실제로 변환하는 것이 아니다.

Type Assertion -> "이 값을 이 타입으로 취급해도 된다"

런타임에서 객체 자체가 변환되는 것은 아니다.

따라서 잘못된 Assertion을 사용하면 컴파일러를 속이는 결과가 될 수 있다.


Type Guard

복잡한 객체의 타입을 런타임에서 검사해야 하는 경우 Type Guard를 만들 수 있다.

interface Player {
    name: string;
}

function isPlayer(
    value: unknown
): value is Player {
    return (
        typeof value === "object" &&
        value !== null &&
        "name" in value
    );
}

이후:

if (isPlayer(value)) {
    console.log(value.name);
}

value is Player라는 반환 타입을 통해 TypeScript에게 타입을 알려준다.

API 응답이나 외부 데이터처럼 컴파일러가 타입을 신뢰할 수 없는 경계에서 유용하다.


Structural Typing

TypeScript의 타입 시스템에서 중요한 개념이다.

TypeScript는 기본적으로 객체의 이름이나 상속 관계보다 구조가 맞는지를 기준으로 타입을 판단한다.

interface Player {
    name: string;
}

const character = {
    name: "Knight",
    health: 100
};

const player: Player = character;

characterPlayer라고 선언하지 않았지만 name: string을 가지고 있기 때문에 Player로 사용할 수 있다.

Player -> name: string
Character -> name: string / health: number

Character의 구조가 Player가 요구하는 구조를 포함하고 있기 때문에 호환된다.

이것이 TypeScript의 Structural Type System이다.


Module

TypeScript 프로젝트에서는 ES Module을 기본적인 코드 분리 방식으로 사용한다.

// player.ts

export class Player {
}

다른 파일에서:

import { Player } from "./player";

함수도 export할 수 있다.

export function add(
    a: number,
    b: number
): number {
    return a + b;
}

이렇게 모듈 단위로 코드를 분리하면 프로젝트 규모가 커져도 의존성을 관리하기 쉽다.


Promise

JavaScript의 비동기 처리는 TypeScript에서도 Promise를 사용한다.

function loadPlayer(): Promise<Player> {
    return fetchPlayer();
}

asyncawait를 사용할 수도 있다.

async function load() {
    const player = await loadPlayer();

    console.log(player.name);
}

반환 타입도 추론할 수 있지만 필요하면 명시할 수 있다.

async function load(): Promise<Player> {
    return await loadPlayer();
}

TypeScript는 여기서 비동기 실행 자체를 제공하는 것이 아니다. JavaScript의 Promise와 async/await에 타입 정보를 추가하는 역할을 한다.


API와 TypeScript

TypeScript가 실무에서 특히 유용한 부분 중 하나가 API 데이터 구조를 명확하게 표현할 수 있다는 점이다.

interface PlayerResponse {
    id: number;
    name: string;
    level: number;
}

API 호출:

async function getPlayer(): Promise<PlayerResponse> {
    const response = await fetch("/api/player");

    return response.json();
}

이제 코드를 작성할 때 응답 데이터의 구조를 기준으로 자동 완성과 타입 검사를 받을 수 있다.

다만 여기서 주의할 점이 있다.

const data: PlayerResponse =
    await response.json();

이렇게 타입을 지정했다고 해서 서버에서 실제로 PlayerResponse 형태의 데이터가 왔다는 것이 검증되는 것은 아니다.

TypeScript의 타입은 런타임 데이터를 자동으로 검증하지 않는다.

외부 데이터가 신뢰할 수 없는 경우에는 별도의 런타임 검증이 필요하다.


TypeScript의 컴파일 과정

TypeScript 프로젝트는 대략 다음과 같은 과정을 거친다.

.ts / .tsx -> TypeScript Compiler -> Type Checking -> JavaScript -> Bundler -> Browser / Node.js

대표적인 설정 파일이 tsconfig.json이다.

{
    "compilerOptions": {
        "target": "ES2022",
        "module": "ESNext",
        "strict": true
    }
}

여기에서 strict 같은 옵션을 통해 타입 검사의 엄격도를 조절할 수 있다.

실제 프로젝트에서는 TypeScript 컴파일러가 JavaScript를 만들고, Vite, Webpack, esbuild, SWC 같은 도구가 번들링이나 변환을 담당하는 구조도 흔하다.


TypeScript와 Node.js

TypeScript 자체가 서버 런타임은 아니다.

Node.js에서 TypeScript를 사용하면 일반적으로 다음 구조가 된다.

TypeScript -> Compile / Transform -> JavaScript -> Node.js

예를 들어 Express를 사용한다면:

Client -> HTTP -> Node.js -> Express -> TypeScript Business Logic -> Database

TypeScript는 여기서 서버의 비즈니스 로직과 데이터 구조에 타입을 부여한다.


TypeScript와 React

React에서도 TypeScript를 사용할 수 있다.

interface Props {
    name: string;
    level: number;
}

function PlayerInfo({
    name,
    level
}: Props) {
    return (
        <div>
            {name} - {level}
        </div>
    );
}

컴포넌트의 Props 구조가 명확해진다.

Parent Component -> Props -> TypeScript Type Check -> Child Component

대규모 프론트엔드 프로젝트에서 컴포넌트 간 데이터 구조를 명확하게 유지하는 데 상당히 유용하다.


TypeScript의 장점

TypeScript의 가장 큰 장점은 JavaScript의 유연성을 유지하면서 정적 타입 검사를 추가할 수 있다는 것이다.

특히 규모가 커지면 효과가 크다.

함수 -> Parameter Type / Return Type
객체 -> Property Type
API -> Request / Response Type
Component -> Props Type

이런 구조가 코드에 남기 때문에 IDE의 자동 완성, 리팩터링, 오류 탐지가 훨씬 좋아진다.

특히 여러 개발자가 하나의 코드베이스를 수정하거나 장기간 유지보수해야 하는 프로젝트에서 타입 정보 자체가 일종의 문서 역할을 한다.


TypeScript의 한계

TypeScript가 모든 타입 문제를 해결해주는 것은 아니다.

가장 큰 오해가 이것이다.

TypeScript != Runtime Type Safety

예를 들어:

interface Player {
    name: string;
}

const player =
    JSON.parse(input) as Player;

이 코드에서 input이 실제로 Player 형태인지 TypeScript가 검사하지 않는다.

as Player는 단지 컴파일러에게 그렇게 취급하라고 말하는 것이다.

따라서 다음과 같은 외부 데이터는 별도로 검증해야 한다.

HTTP Response / Database / User Input / File / WebSocket / External API

TypeScript의 타입 시스템과 런타임 검증은 별개의 문제다.


JavaScript와 TypeScript의 관계

둘의 관계를 하나의 흐름으로 정리하면 다음과 같다.

JavaScript -> Dynamic Type -> Runtime에서 타입 결정
TypeScript -> JavaScript + Static Type System -> Compile Time Type Check -> JavaScript -> Runtime

TypeScript는 JavaScript를 버리고 새로운 실행 환경을 만드는 방향이 아니다.

기존 JavaScript 생태계를 유지하면서 개발 단계에서 더 많은 정보를 확보하는 방향이다.

그래서 기존 JavaScript 라이브러리도 TypeScript에서 사용할 수 있고, Node.js, React, Next.js 같은 JavaScript 생태계의 도구들과 함께 사용할 수 있다.


TypeScript의 핵심 구조

TypeScript를 전체적으로 보면 다음과 같이 정리할 수 있다.

JavaScript -> Type System -> Type Inference -> Interface / Type -> Union / Intersection -> Generic -> Narrowing -> Structural Typing -> Class / Module -> Promise / Async -> TypeScript Compiler -> JavaScript -> Runtime

여기서 가장 중요한 관계는 TypeScript의 타입 정보가 실행을 위한 정보가 아니라 개발과 컴파일을 위한 정보라는 것이다.

TypeScript Type -> 개발 단계 -> 검사 / 자동 완성 / 리팩터링 -> JavaScript -> Runtime

정리

TypeScript는 단순히 JavaScript에 : number, : string을 붙이는 언어는 아니다.

핵심은 JavaScript가 가진 동적이고 유연한 구조 위에 정적 타입 시스템을 추가해서 프로그램의 구조를 코드 수준에서 명확하게 표현하는 것이다.

특히 다음 개념들이 서로 연결되어 있다.

Type Annotation -> Type Inference -> Union / Intersection -> Narrowing -> Generic -> Interface / Type -> Structural Typing

그리고 실제 애플리케이션에서는 이 타입 시스템이 함수, 객체, API, 컴포넌트, 데이터베이스 모델 등의 경계를 정의하는 데 사용된다.

다만 TypeScript는 런타임을 통제하지 않는다.

TypeScript -> "이 코드의 타입 구조가 올바른가?"
Runtime Validation -> "실제로 들어온 데이터가 올바른가?"

를 검사하는 것은 별도의 문제다.

이 차이를 알고 있어야 TypeScript를 제대로 사용할 수 있다.

결국 TypeScript는 JavaScript 생태계를 유지하면서 코드의 구조와 계약을 타입 시스템으로 명시하고, 컴파일 단계에서 많은 오류를 제거하는 언어라고 볼 수 있다.

프론트엔드에서는 React, Next.js 같은 생태계와 함께 사용되고, 백엔드에서는 Node.js와 결합할 수 있다. 규모가 커질수록 타입 정보가 코드의 구조를 설명하는 역할까지 하게 된다는 점이 TypeScript를 사용하는 가장 현실적인 이유라고 볼 수 있다.