TypeScript는 JavaScript에 정적 타입 검사와 타입 표현을 더한다. 이 문서는 타입 추론·유니온·제네릭·객체 모델을 실제 예제로 설명하고, 컴파일 오류 수정과 외부 주문 데이터 검증을 통해 타입 검사와 런타임 검증의 역할을 구분한다.
프로그래밍 언어 | |
|---|---|
언어 | Python · Java · JavaScript · TypeScript · Rust |
개요: JavaScript에 타입 관계를 더한다
TypeScript는 JavaScript 코드에 정적 타입 정보를 붙이고 실행 전에 여러 종류의 불일치를 찾아내는 언어와 도구 체계다. 함수가 숫자를 받는다고 선언했는데 문자열을 전달하거나, 존재하지 않는 속성에 접근하는 실수를 개발 단계에서 발견하는 데 도움이 된다. 기존 JavaScript의 실행 의미를 이해하는 일은 여전히 중요하다.
예를 들어 고객 조회 함수의 반환값을 User | undefined로 표현하면 호출자가 고객이 없는 경우를 다루도록 검사할 수 있다. 이때 타입은 단순한 문서 주석보다 강한 피드백을 준다. 그러나 잘못된 고객 번호로 다른 고객을 조회하는 업무 오류나 서버 장애까지 자동으로 해결하지는 않는다. 무엇이 표현된 계약이고 무엇이 실행 중 확인해야 할 조건인지 나누어야 한다.
타입 검사의 결과는 컴파일러 버전과 설정에도 영향을 받는다. 이 문서의 예제는 TypeScript 5.9.3의 strict 설정에서 검증하며, 배열 인덱스와 선택적 속성을 더 엄격하게 다루는 옵션을 함께 사용한다. 최신 버전의 모든 기능을 소개하려는 문서가 아니라 타입 모델을 설계하고 오류를 읽는 기본 원리를 설명한다. 버전을 고정하면 예제 재현과 진단 비교가 쉬워진다.
검사·변환·실행은 서로 다른 단계
TypeScript 코드를 JavaScript로 변환하는 단계와 타입을 검사하는 단계는 도구 구성에 따라 분리될 수 있다. 어떤 개발 서버가 .ts 파일을 빠르게 실행해 준다고 전체 타입 검사가 성공했다는 의미는 아니다. 편집기 표시, 별도 tsc 검사, 번들러 빌드, 실제 실행 결과를 구분해야 한다.
타입 주석과 interface 같은 정보는 일반적인 JavaScript 출력에 남지 않는다. 함수 매개변수에 number라고 적었다고 런타임에 자동 숫자 검증 코드가 생기는 것은 아니다. JavaScript에서 직접 호출하거나 외부 데이터가 잘못 들어오면 선언과 다른 값이 도달할 수 있다. 입력 경계의 검증은 별도 코드의 책임이다.
모든 TypeScript 문법이 단순히 지워지기만 하는 것은 아니다. enum이나 매개변수 속성 등은 출력에 영향을 줄 수 있고, 변환 대상에 따라 문법이 바뀌기도 한다. 따라서 “타입이 지워진다”는 설명을 “어떤 .ts 파일도 문자 몇 개를 제거하면 똑같이 실행된다”로 확대하면 안 된다. 사용 중인 실행 도구가 지원하는 문법 범위를 확인한다.
noEmit은 JavaScript를 만들지 않고 검사하는 흐름에 유용하고, noEmitOnError는 오류가 있으면 출력을 만들지 않도록 하는 옵션이다. 기본값과 프로젝트 설정을 확인하지 않으면 오류 메시지가 있어도 출력 파일이 생긴 것을 성공으로 오해할 수 있다. 배포 과정은 타입 검사 실패를 실패 상태로 처리하도록 연결한다.
첫 예제: 함수의 입력과 반환 계약
아래 subtotal은 단가와 수량을 number로 받고 number를 반환한다. 1200과 3을 전달하면 3600이다. 함수 본문은 JavaScript의 곱셈이고 타입 주석은 호출 관계를 검사하는 정보다. 호출자가 문자열을 넘기는 실수를 실행 전에 찾을 수 있다.
하지만 number는 양수 정수나 유효한 금액이라는 뜻이 아니다. NaN, Infinity, 음수와 소수도 number 범주에 들어간다. 실제 주문 금액 계산이라면 단위, 범위, 수량의 정수 여부와 반올림 정책을 별도로 검사해야 한다. 타입이 잡는 오류와 업무 검증이 잡는 오류를 구분하는 첫 사례다.
예제는 각각 독립된 파일로 실행한다. 일반 예제의 공통 검사 명령은 tsc example.ts --strict --noUncheckedIndexedAccess --exactOptionalPropertyTypes --target ES2022 --module commonjs --noEmitOnError --outDir out 이다. 성공하면 node out/example.js로 실행한다. 실패 예제는 의도한 타입 오류를 확인하기 위한 것이므로 실행 파일이 생성될 것을 기대하지 않는다.
예제 코드
function subtotal(price: number, quantity: number): number {
return price * quantity;
}
console.log(subtotal(1200, 3));실행 결과
3600오류를 읽고 수정하기: 문자열 수량
다음 코드는 의도적으로 검사에 실패한다. subtotal의 두 번째 인자는 number인데 "3"은 string이므로 TS2345 진단이 나온다. 오류 문장의 표현은 컴파일러 버전에 따라 조금 달라질 수 있으나 어느 인자의 실제 타입이 기대 타입과 맞지 않는지 읽는 것이 핵심이다.
해결은 무조건 as number를 붙이는 것이 아니다. 사용자 입력이 문자열이라면 변환 후 유효성을 검사한다. 원래부터 숫자를 전달해야 하는 내부 호출이라면 호출자의 자료형이나 전달 값을 수정한다. 오류 위치가 함수 호출이라고 항상 함수의 매개변수 타입을 느슨하게 만드는 것이 맞는 것은 아니다.
실패하는 코드를 정상 예제와 같은 파일에 섞으면 noEmitOnError 때문에 전체 출력이 생성되지 않을 수 있다. 학습용 실패 사례는 별도 파일로 분리하고, 오류를 발견하는 것이 예상 결과라는 점을 명확히 한다. 실제 프로젝트에서는 오류를 숨기는 주석을 추가하기 전에 계약과 실제 데이터 흐름 중 어느 쪽이 잘못됐는지 조사한다.
검사에 실패해야 하는 예제
function subtotal(price: number, quantity: number): number {
return price * quantity;
}
console.log(subtotal(1200, "3"));예상 타입 진단: TS2345
명시적 변환과 유효성 검사
아래 parseQuantity는 문자열을 Number로 변환한 다음 양의 안전한 정수인지 확인한다. 빈 문자열은 Number로 변환하면 0이 될 수 있으므로 먼저 비어 있는지 확인한다. "3"은 3이 되고 "many"는 오류로 처리된다. 출력은 3과 invalid quantity다.
이 함수는 Number가 이해하는 표현을 허용한다. "1e2" 같은 지수 표현이나 앞뒤 공백을 반드시 금지해야 한다면 문자열 형식 규칙을 추가해야 한다. 데이터 타입 변환만으로 업무 입력 형식까지 결정되는 것은 아니다. 허용할 문법을 먼저 정하고 그 계약을 테스트한다.
반환 타입 number는 검사된 값이라는 사실을 팀에 완전히 전달하지 못한다. 값의 출처가 중요한 곳에서는 검증 함수만 생성할 수 있는 도메인 타입을 별도로 모델링할 수 있다. 그러나 타입 이름을 붙이는 것만으로 유효성 검사가 생기는 것은 아니며, 생성 경로와 검증 코드가 실제로 함께 있어야 한다.
예제 코드
function parseQuantity(raw: string): number {
const value = Number(raw);
if (raw.trim() === '' || !Number.isSafeInteger(value) || value <= 0) {
throw new Error('invalid quantity');
}
return value;
}
console.log(parseQuantity('3'));
try { parseQuantity('many'); }
catch (error) {
console.log(error instanceof Error ? error.message : 'unknown failure');
}실행 결과
3
invalid quantity타입 추론: 모든 변수에 주석을 쓰지 않아도 된다
컴파일러는 초기값과 사용 문맥에서 타입을 추론한다. const count = 3처럼 값이 분명한 곳에 같은 정보를 반복해서 적을 필요는 없는 경우가 많다. 반면 공개 함수의 입력이나 여러 구현이 공유하는 경계에는 명시적 타입이 설계 의도를 드러낸다. 추론과 명시는 둘 중 하나만 고르는 규칙이 아니다.
let으로 선언한 문자열 값은 이후 다른 문자열을 대입할 수 있도록 넓은 string으로 추론될 수 있다. const의 직접 값은 더 좁은 리터럴 타입을 유지할 수 있지만 const 객체의 모든 속성이 자동으로 불변 리터럴이 되는 것은 아니다. 객체 내용은 바뀔 수 있기 때문이다.
추론된 타입이 예상보다 넓다면 초기화 방식과 사용 문맥을 확인한다. 무조건 긴 타입 단언으로 덮기보다 값이 바뀔 필요가 있는지, 허용 값 집합을 선언해야 하는지 판단한다. 반환 타입을 명시하면 구현을 수정하다 뜻하지 않게 반환 형태를 바꿨을 때 함수 경계에서 오류를 확인하는 데 도움이 된다.
타입을 읽을 수 없을 정도로 복잡한 추론 결과가 노출되면 적절한 이름을 붙여 공개 계약을 단순하게 만들 수 있다. 타입 추론은 편의를 위한 도구이지 설계를 숨기는 목적이 아니다. 팀의 API를 사용하는 사람이 입력과 실패 가능성을 이해할 수 있는지가 기준이다.
객체 타입과 선택적 속성
객체 타입은 필요한 속성과 각 값의 타입을 표현한다. 아래 User는 id와 name이 필수이고 nickname은 선택적이다. nickname을 읽으면 값이 없을 가능성을 다뤄야 하므로 ??로 기본 표시를 선택한다. Ada는 별명이 없어 name을 사용하고 Lin은 L로 표시된다.
선택적 속성은 필드가 없는 경우를 표현한다. nickname: string | undefined처럼 속성 자체는 필수인 모델과 완전히 같은 뜻이 아니다. exactOptionalPropertyTypes를 켜면 선택적 속성에 명시적으로 undefined를 넣는 것과 속성을 생략하는 것을 더 엄격하게 구분한다. 서버 업데이트에서 “변경하지 않음”과 “값 제거”가 다른 뜻이라면 이 차이가 중요하다.
타입에 없는 추가 필드가 런타임에서 자동으로 삭제되지는 않는다. 타입이 User로 보인다고 객체에 id, name, nickname만 존재한다는 보장은 없다. 외부로 내보낼 데이터를 제한하려면 필요한 필드만 새 객체에 복사하는 직렬화 과정을 작성한다. 타입 선언을 개인정보 제거 도구로 취급하면 안 된다.
예제 코드
type User = { id: number; name: string; nickname?: string };
function label(user: User): string {
return user.nickname ?? user.name;
}
console.log(label({id: 1, name: 'Ada'}));
console.log(label({id: 2, name: 'Lin', nickname: 'L'}));실행 결과
Ada
L구조적 타입: 이름보다 필요한 형태
TypeScript의 많은 타입 호환성 판단은 구조를 기준으로 한다. 아래 함수는 name 문자열만 필요하므로 id와 name을 가진 변수도 전달할 수 있다. User라는 특정 클래스의 인스턴스여야 하는 것은 아니다. 작고 필요한 인터페이스에 의존하면 구현을 교체하거나 테스트 대역을 만들기 쉽다.
그렇다고 모든 객체 리터럴의 추가 속성이 언제나 무시되는 것은 아니다. 새 객체 리터럴을 직접 전달할 때는 초과 속성 검사가 오타를 잡아낼 수 있다. 변수에 담은 구조적 호환성과 리터럴 검사에서 다른 결과를 보고 타입 체계가 무작위로 작동한다고 오해하지 않도록 문맥을 확인한다.
같은 구조지만 의미가 다른 값도 호환될 수 있다. 고객 ID와 상품 ID가 모두 string이면 서로 바꿔 넘기는 실수를 기본 타입만으로 잡기 어렵다. 이런 경우 객체로 구분하거나 브랜드 타입 같은 모델을 검토할 수 있다. 브랜드 타입 역시 잘못된 단언으로 우회할 수 있으므로 외부 입력을 검증해 생성하는 경계를 함께 관리한다.
예제 코드
type Named = { name: string };
function greet(value: Named): string { return `Hello, ${value.name}`; }
const customer = {id: 1, name: 'Ada'};
console.log(greet(customer));실행 결과
Hello, Ada유니온과 좁히기: 가능한 경우를 분기한다
string | number는 둘 중 하나의 값이 올 수 있다는 뜻이다. 두 타입에서 모두 안전한 연산은 바로 할 수 있지만 문자열 전용 메서드를 무조건 호출할 수는 없다. typeof 분기를 사용하면 각 경로에서 가능한 타입이 좁아진다.
아래 normalize는 숫자이면 소수점 한 자리 표시를 만들고 문자열이면 앞뒤 공백을 제거한다. 출력은 3.0과 Ada다. 타입을 강제로 바꾸는 대신 실제 값의 종류를 검사하고 그 검사 결과에 맞는 연산을 수행한다. 코드의 제어 흐름과 타입 정보가 연결되는 방식이다.
truthy 검사만으로 좁힐 때는 0이나 빈 문자열이 제외되는 의미도 고려한다. null 여부만 확인하려는 코드가 if(value)를 쓰면 유효한 0을 빠뜨릴 수 있다. typeof null이 object인 JavaScript 규칙 역시 그대로 적용되므로 객체 검사에서는 null을 별도로 제외해야 한다.
예제 코드
function normalize(value: string | number): string {
if (typeof value === 'number') return value.toFixed(1);
return value.trim();
}
console.log(normalize(3));
console.log(normalize(' Ada '));실행 결과
3.0
Ada판별 유니온: 불가능한 상태를 줄이기
로딩 상태를 loading, data, error 세 속성으로 따로 관리하면 로딩 중인데 데이터와 오류도 동시에 있는 상태가 만들어질 수 있다. 필요한 상태의 조합을 판별 유니온으로 모델링하면 각 상태에서 어떤 데이터가 있는지 연결할 수 있다.
아래 State는 loading, ready, failed 중 하나다. kind가 ready이면 items가 있고 failed이면 message가 있다. switch는 kind를 기준으로 타입을 좁힌다. 상태를 읽는 쪽이 존재하지 않는 필드를 무조건 사용하지 않도록 도와준다. 출력은 Loading, 2 items, network다.
이 모델 자체가 네트워크 요청을 수행하거나 화면 전이를 자동 관리하는 것은 아니다. 오래된 응답이 새 상태를 덮는 문제는 요청 식별과 수명 관리로 해결해야 한다. 타입은 유효한 상태의 형태를 정하고, 실제 전이 규칙과 동시성 제어는 코드와 테스트로 구현한다.
예제 코드
type State =
| {kind: 'loading'}
| {kind: 'ready'; items: string[]}
| {kind: 'failed'; message: string};
function describe(state: State): string {
switch (state.kind) {
case 'loading': return 'Loading';
case 'ready': return `${state.items.length} items`;
case 'failed': return state.message;
}
}
console.log(describe({kind: 'loading'}));
console.log(describe({kind: 'ready', items: ['A', 'B']}));
console.log(describe({kind: 'failed', message: 'network'}));실행 결과
Loading
2 items
networknever와 모든 경우 처리하기
never는 가능한 값이 없다는 타입을 표현한다. 판별 유니온의 모든 경우를 처리한 뒤 남은 값이 never인지 검사하면 새로운 상태가 추가됐는데 처리 분기를 빠뜨린 상황을 발견할 수 있다.
아래는 failed 상태를 일부러 처리하지 않았다. default 경로의 state는 Failed 형태로 남아 있는데 assertNever는 never만 받으므로 TS2345 오류가 발생한다. 수정하려면 failed 분기를 추가해야 한다. as never로 강제하면 누락을 잡으려는 검사를 무력화한다.
이 패턴은 상태가 늘어날 때 관련 처리 지점을 찾는 데 유용하다. 하지만 외부에서 임의로 들어온 객체를 as State로 단언했다면 런타임에 정의하지 않은 kind가 들어올 수 있다. 완전성 검사는 정확한 타입 경계가 전제이며, 입력 검증을 대체하지 않는다.
검사에 실패해야 하는 예제
type State = {kind: 'ready'; value: number} | {kind: 'failed'; message: string};
function assertNever(value: never): never { throw new Error('unexpected state'); }
function describe(state: State): string {
switch (state.kind) {
case 'ready': return String(state.value);
default: return assertNever(state);
}
}예상 타입 진단: TS2345
any와 unknown: 모르는 값을 다루는 방식
any는 많은 타입 검사를 우회하게 한다. 점진적 전환이나 타입이 없는 라이브러리 경계에서 필요할 수 있지만 넓게 퍼지면 호출자의 잘못된 가정이 발견되지 않는다. unknown은 값의 타입을 모른다는 사실을 유지하고 사용 전에 좁히기를 요구한다.
아래 lengthOf는 unknown을 받고 문자열이면 길이를, 배열이면 원소 수를, 나머지는 0을 반환한다. 출력은 3, 2, 0이다. 잘못된 타입에서 속성이나 메서드를 즉시 호출하지 않도록 분기한다. 이것은 모든 알 수 없는 데이터에서 0을 반환해야 한다는 일반 규칙이 아니라 이 함수가 정한 계약이다.
JSON.parse 결과를 unknown 변수에 넣으면 이후 코드가 검증 없이 원하는 속성을 읽는 것을 줄일 수 있다. 다만 unknown으로 선언했다고 파서가 데이터 형식을 검증하는 것은 아니다. 어떤 속성이 필요한지 확인하는 코드가 뒤따라야 한다. any를 unknown으로 바꾸는 작업은 오류를 없애기보다 숨겨진 가정을 드러내는 과정일 수 있다.
예제 코드
function lengthOf(value: unknown): number {
if (typeof value === 'string') return value.length;
if (Array.isArray(value)) return value.length;
return 0;
}
console.log(lengthOf('Ada'));
console.log(lengthOf([1, 2]));
console.log(lengthOf(null));실행 결과
3
2
0타입 단언: 값을 바꾸는 변환 함수가 아니다
as는 컴파일러에게 값을 특정 타입으로 보도록 알리는 단언이다. 실제 JavaScript 값을 변환하는 연산이 아니다. 아래 raw는 런타임 문자열 "3"인데 number로 단언해도 문자열 그대로다. 따라서 + 1의 결과는 숫자 4가 아니라 문자열 31이다. 코드가 타입 검사를 통과해도 선언과 실제 값이 어긋날 수 있다는 사례다.
단언은 컴파일러가 알지 못하지만 개발자가 확실히 아는 사실을 표현할 때 제한적으로 쓸 수 있다. 그러나 외부 응답의 형태나 DOM 요소의 존재처럼 실패 가능한 조건을 단언으로 덮으면 실행 오류가 남는다. 단언의 근거가 코드의 검증인지 단순한 기대인지 구분한다.
non-null 단언 !도 null이나 undefined 검사를 실행하지 않는다. 필요한 요소가 반드시 존재한다고 적어도 실제 페이지 구조가 바뀌면 실패할 수 있다. 값이 없을 때의 분기를 작성할 수 있다면 그쪽이 실패 경로를 더 명확하게 드러낸다. 연속된 as unknown as SomeType은 타입 관계를 우회하는 신호이므로 왜 필요한지 검토한다.
예제 코드
const raw: unknown = '3';
const value = raw as number;
console.log(typeof value);
console.log(value + 1);실행 결과
string
31런타임 검증과 타입 가드
타입 가드는 실제 조건을 검사해 이후 코드에서 값을 더 구체적인 타입으로 다루게 한다. 아래 isUser는 null과 객체 여부, id가 정수인지, name이 문자열인지 확인한다. true일 때 컴파일러는 value를 User로 사용할 수 있다. 출력은 Ada와 invalid다.
value is User라는 반환 선언은 개발자가 작성한 검증의 정확성을 전제로 한다. 함수가 무조건 true를 반환해도 컴파일러가 모든 업무 의미의 거짓을 증명해 주지는 않는다. 필드를 추가했는데 검사 함수를 갱신하지 않으면 선언과 실제 보장이 어긋날 수 있다. 타입과 검증 규칙이 함께 바뀌도록 테스트하거나 스키마 기반 도구를 검토한다.
이 예제는 두 필드의 형태를 확인하지만 고객 ID가 데이터베이스에 존재하는지, 호출자가 볼 권한이 있는지까지 확인하지 않는다. 구조 검증, 범위 검증, 참조 무결성, 권한 검사는 서로 다른 조건이다. 타입 이름 하나에 모든 보장이 담겼다고 생각하지 않고 함수가 확인한 범위를 문서화한다.
예제 코드
type User = {id: number; name: string};
function isUser(value: unknown): value is User {
if (value === null || typeof value !== 'object') return false;
if (!('id' in value) || !('name' in value)) return false;
return typeof value.id === 'number' && Number.isInteger(value.id)
&& typeof value.name === 'string';
}
for (const value of [{id: 1, name: 'Ada'}, {id: '1', name: 'Lin'}]) {
console.log(isUser(value) ? value.name : 'invalid');
}실행 결과
Ada
invalid제네릭: 입력과 출력의 관계 보존
제네릭은 단순히 어떤 값이든 받기 위한 문법이 아니라 여러 위치의 타입 관계를 표현하는 도구다. 아래 first<T>는 T의 배열을 받고 첫 원소가 있으면 같은 T를 반환한다. 빈 배열이면 undefined다. 숫자 배열의 결과는 숫자 가능성을, 문자열 배열의 결과는 문자열 가능성을 유지한다.
any[]를 받고 any를 반환하면 입력과 출력의 관계가 사라져 잘못된 메서드 호출을 놓칠 수 있다. 반대로 number[]만 받으면 문자열 배열에 같은 논리를 재사용하기 어렵다. 제네릭은 구현은 공유하면서 호출별 타입 관계를 유지하도록 한다. 출력은 10, Ada, undefined다.
T를 number나 string으로 제한하지 않았으므로 함수 내부에서 T만의 특정 메서드를 호출할 수는 없다. 필요한 능력이 있다면 제약을 표현한다. 제네릭이 하나의 위치에만 등장하고 다른 타입과 연결되지 않는다면 불필요하게 복잡한 설계일 수 있다. 추상화의 목적이 실제 관계인지 장식인지 살펴야 한다.
예제 코드
function first<T>(items: readonly T[]): T | undefined {
return items[0];
}
console.log(first([10, 20]));
console.log(first(['Ada', 'Lin']));
console.log(first<number>([]));실행 결과
10
Ada
undefinedkeyof와 인덱스 접근 타입
keyof는 객체 타입의 키를 타입 수준에서 표현한다. T[K]는 그 키에 해당하는 값의 타입을 표현한다. 아래 getProperty는 아무 문자열이나 받는 것이 아니라 주어진 객체의 키만 받도록 K extends keyof T를 사용한다. name을 고르면 string, age를 고르면 number라는 관계가 유지된다.
이 함수의 핵심은 키와 값의 연결이다. 단순히 string을 받아 unknown을 반환하는 사전 조회보다 호출자가 결과를 활용하기 쉽다. 그러나 런타임에 사용자에게 받은 문자열이 자동으로 유효한 키가 되는 것은 아니다. 외부 문자열은 허용 키 목록이나 실제 객체 검사로 좁힌 뒤 전달한다.
Object.keys의 결과를 무조건 Array<keyof T>라고 단언하는 관행도 주의해야 한다. 구조적 타입으로 보이는 것보다 런타임 객체에 속성이 더 많을 수 있다. 타입 수준의 키 집합과 실행 중 열거되는 키 집합이 어떤 계약 아래 같은지 검토해야 한다.
예제 코드
function getProperty<T, K extends keyof T>(object: T, key: K): T[K] {
return object[key];
}
const user = {name: 'Ada', age: 30};
console.log(getProperty(user, 'name').toUpperCase());
console.log(getProperty(user, 'age') + 1);실행 결과
ADA
31배열 인덱스와 없는 값
배열은 길이가 실행 중 변한다. 타입이 number[]라고 해도 임의의 인덱스가 반드시 존재하지는 않는다. noUncheckedIndexedAccess 옵션은 이런 접근에서 undefined 가능성을 더 드러내도록 돕는다. 아래 빈 배열의 첫 원소를 number 변수에 바로 넣으면 TS2322가 발생한다.
수정은 반환 타입을 number | undefined로 인정하고 값이 없을 때 처리하거나, 앞에서 필요한 값이 실제 존재하는지 확인하는 것이다. 모든 위치에 !를 붙이면 검사를 켠 목적을 잃는다. 정확한 길이와 위치 의미가 있는 자료라면 일반 배열 대신 튜플을 고려할 수 있다.
배열 길이 검사와 복잡한 제어 흐름을 컴파일러가 언제나 인간과 같은 방식으로 추론하는 것은 아니다. 코드 구조를 단순하게 하거나 실제 값을 지역 변수에 담아 undefined 여부를 검사하면 타입과 실행 흐름을 함께 읽기 쉽다. 옵션을 켰다는 사실만으로 런타임에서 배열 범위 검사가 자동 삽입되는 것은 아니다.
검사에 실패해야 하는 예제
const values: number[] = [];
const first: number = values[0];
console.log(first);예상 타입 진단: TS2322
튜플과 readonly: 위치와 변경 권한
튜플은 위치마다 타입과 의미가 정해진 배열을 표현한다. 좌표의 x와 y처럼 길이가 정해진 작은 자료에 사용할 수 있다. 그러나 값의 의미가 많아지면 position[3]보다 이름 있는 객체 속성이 읽기 쉬울 수 있다. 타입을 짧게 쓰는 것보다 호출자의 이해가 중요하다.
readonly는 해당 타입을 통해 변경하는 작업을 제한한다. readonly 배열을 받는 함수는 원소를 읽는 데 집중하고 입력 배열을 제자리 정렬하거나 push하지 않겠다는 계약을 표현할 수 있다. 그러나 런타임 객체를 자동으로 동결하지는 않으며 다른 변경 가능한 참조가 같은 객체를 바꿀 수 있다.
아래 view는 readonly 속성을 가진 타입으로 보지만 original과 같은 객체를 가리킨다. original.count를 2로 바꾸면 view.count도 2다. 따라서 readonly를 불변 데이터의 완전한 보장으로 해석하면 안 된다. 복사, 소유권, 중첩 객체 공유와 실행 중 동결은 각각 다른 결정이다.
예제 코드
const original = {count: 1};
const view: Readonly<{count: number}> = original;
original.count = 2;
console.log(view.count);
const point: readonly [number, number] = [3, 4];
console.log(point[0] + point[1]);실행 결과
2
7type과 interface의 선택
type과 interface는 객체의 형태를 표현하는 데 겹치는 부분이 많다. interface는 객체 계약과 확장에 자주 쓰이고 type은 유니온이나 다른 타입 표현에 이름을 붙일 수 있다. 프로젝트의 공개 API와 확장 방식에 맞춰 일관된 기준을 정하면 된다. 어느 한쪽이 모든 경우 더 안전하다는 식의 선택은 부정확하다.
선언 병합이 필요한 라이브러리 확장에서는 interface의 특성이 의미가 있다. 반대로 서로 배타적인 상태를 유니온으로 표현하려면 type 별칭이 자연스럽다. 같은 이름을 여러 곳에서 확장할 수 있다는 장점은 의도하지 않은 결합을 만들 수도 있으므로 공개 확장 지점을 명확히 한다.
상속과 교차 타입을 사용해 필드를 합칠 때 충돌이 어떻게 드러나는지도 확인한다. 같은 필드에 모순되는 타입을 요구하면 만족 가능한 값이 없어지거나 이해하기 어려운 오류가 나올 수 있다. 공통 필드를 무조건 큰 기반 타입에 몰아넣기보다 실제로 함께 변하는 계약인지 살핀다.
타입 별칭은 런타임 생성자를 만들지 않는다. type User를 선언했다고 new User를 호출할 수 있는 것은 아니다. 객체 생성과 기본값 적용, 검증이 필요하면 함수나 클래스를 별도로 작성한다. 타입 공간과 값 공간을 구별하면 이름이 존재하는데 실행 코드에서 사용할 수 없는 이유를 이해하기 쉽다.
유틸리티 타입과 업데이트 모델
Pick, Omit, Partial 같은 유틸리티 타입은 기존 타입에서 파생된 계약을 표현하는 데 유용하다. 아래 ProfileUpdate는 User 전체가 아니라 name과 bio만 골라 선택적으로 바꿀 수 있게 한다. id를 업데이트 입력에서 제외한다는 의도를 드러낸다.
Partial<User>를 모든 수정 요청에 그대로 쓰면 서버가 변경하면 안 되는 id나 role까지 허용하는 모델이 될 수 있다. 타입에 없는 값을 런타임 요청에서 자동 제거하는 것도 아니므로 서버는 허용 필드를 실제로 선택하고 권한을 검증해야 한다. 입력 모델을 저장 모델과 분리하는 이유다.
Partial은 일반적으로 직접 속성을 선택적으로 만들 뿐 임의의 깊이 전체를 재귀적으로 처리하는 타입은 아니다. 중첩 설정을 부분 수정할 때 바깥 객체의 생략, 내부 필드의 생략, null로 초기화하는 의미를 각각 정해야 한다. 간단한 타입 조합 뒤에 업무 업데이트 규칙이 숨어 있지 않도록 예제와 테스트를 붙인다.
예제 코드
type User = {id: number; name: string; bio: string};
type ProfileUpdate = Partial<Pick<User, 'name' | 'bio'>>;
function update(user: User, patch: ProfileUpdate): User {
return {...user, ...patch};
}
const changed = update({id: 1, name: 'Ada', bio: ''}, {bio: 'developer'});
console.log(changed.id, changed.name, changed.bio);실행 결과
1 Ada developer리터럴 타입과 satisfies
허용 값이 정해져 있다면 string보다 "small" | "large" 같은 리터럴 유니온이 의도를 더 정확히 표현할 수 있다. 오타를 검사하고 분기를 빠짐없이 다루는 데 도움이 된다. 그러나 외부 문자열을 타입 단언으로 유니온에 넣는 것은 유효성 검사가 아니다.
satisfies는 표현식이 특정 타입 조건을 만족하는지 확인하면서 그 표현식의 추론된 정보를 활용할 수 있게 한다. 아래 설정은 compact와 wide 키를 모두 가진 숫자 사전이라는 조건을 검사한다. as const를 함께 사용해 리터럴 값과 읽기 전용 속성의 타입 정보를 유지한다. 출력은 2다.
satisfies가 객체를 변환하거나 실행 중 검증 코드를 넣지는 않는다. 설정 파일을 코드에 직접 작성할 때의 검사와 외부 JSON을 읽어 검사하는 일은 구분한다. 넓은 타입 주석, as 단언, satisfies, as const는 목적이 서로 다르므로 오류를 없애는 아무 문법이나 고르지 않는다.
예제 코드
const columns = {
compact: 2,
wide: 4
} as const satisfies Record<'compact' | 'wide', number>;
console.log(columns.compact);실행 결과
2함수 타입과 콜백의 계약
함수 타입에는 매개변수와 반환값 사이의 계약이 담긴다. 콜백을 받는 함수는 어떤 값을 언제 전달하고 반환 결과를 어떻게 사용하는지 설명해야 한다. 같은 number를 받는 함수라도 단위와 범위가 다르면 의미상 호환되지 않을 수 있다.
선택적 매개변수는 호출자가 생략할 수 있다는 의미다. 구현에서 항상 전달하는 콜백 인자를 습관적으로 선택적으로 선언하면 콜백 작성자는 undefined를 처리해야 한다. 반대로 실제 생략 가능한 인자를 필수라고 선언하면 실행 중 계약과 다르게 된다. 선언은 구현의 호출 방식과 일치해야 한다.
void 반환 타입을 가진 콜백 문맥에서는 반환값을 사용하지 않는다는 의미가 중요하다. Promise를 반환하는 async 콜백을 동기 API에 넘길 때 그 Promise가 기다려지는지 별도로 확인한다. 타입 검사를 통과했다고 forEach 같은 메서드가 모든 async 콜백의 완료를 기다려 주는 것은 아니다.
오버로드는 입력 형태에 따라 반환 타입이 달라지는 API를 표현할 수 있지만 구현 시그니처와 공개 시그니처가 복잡해질 수 있다. 단순 유니온이나 제네릭으로 관계를 명확히 나타낼 수 있다면 그쪽을 먼저 검토한다. 사용자가 실제로 어떤 입력 조합을 전달할지 예제로 확인하면 과도한 오버로드를 줄일 수 있다.
클래스와 접근 제한: 타입 검사와 런타임 경계
클래스는 상태와 동작을 묶는 JavaScript의 실행 요소이며 TypeScript는 필드와 메서드의 타입을 추가로 검사한다. 생성자에서 필요한 상태를 초기화하고, 공개 메서드가 유지할 불변 조건을 정하는 것이 접근 제한 키워드보다 먼저다.
TypeScript의 private 같은 접근 제한과 JavaScript의 # 비공개 필드는 같은 실행 보장을 갖는 기능이 아니다. 타입 검사 단계의 제한과 런타임에서 적용되는 비공개 동작을 구분한다. 민감한 값을 브라우저 코드에 넣고 private로 표시했다고 사용자가 값을 알 수 없게 되는 것은 아니다.
implements는 클래스가 인터페이스 계약에 맞는지 검사하지만 메서드 구현을 자동 생성하지 않는다. 클래스가 특정 인터페이스를 만족한다는 사실과 실제 메서드가 업무적으로 올바르게 동작한다는 사실도 별개다. 테스트 대역이 같은 인터페이스를 구현해도 실제 네트워크와 데이터베이스의 실패 특성을 모두 대변하지는 않는다.
모든 모델을 클래스로 만들 필요는 없다. 순수 데이터와 변환 함수가 명확한 경우 객체 타입과 함수 조합이 더 단순할 수 있다. 객체의 수명과 상태 변경 책임이 중요할 때 클래스를 선택하고, 상속보다 구성 요소의 조합이 변경 범위를 줄이는지도 비교한다.
모듈·선언 파일과 실행 대상
타입이 정상적으로 해석되어도 실행 환경이 import 경로를 찾지 못하면 프로그램은 실패한다. module과 moduleResolution은 실제 호스트와 빌드 도구에 맞춰 선택해야 한다. 브라우저 번들러용 설정을 Node.js 직접 실행 프로젝트에 그대로 복사하면 확장자나 패키지 해석이 어긋날 수 있다.
import type은 타입만 필요한 의존성을 표현하는 데 쓰인다. 타입이 제거되는 단계와 실제 실행에 필요한 import를 구분하면 순환 의존성과 불필요한 런타임 연결을 이해하기 쉽다. 다만 모듈의 부수 효과가 필요하다면 타입 import로 바꾸는 것만으로 같은 실행이 유지된다고 가정하지 않는다.
.d.ts 선언 파일은 JavaScript 라이브러리의 타입 계약을 제공한다. 선언이 존재한다고 해당 구현이 설치되거나 실행 가능한 전역이 자동 생성되는 것은 아니다. 선언 파일과 실제 라이브러리 버전이 어긋나면 타입 검사는 통과해도 메서드가 없다는 오류가 날 수 있다. 배포 패키지의 진입점과 선언 경로를 함께 검증한다.
target은 출력 문법의 수준에 영향을 주고 lib는 검사할 때 사용할 환경 API 선언에 영향을 준다. lib에 최신 API를 넣었다고 오래된 브라우저에 실제 구현이 생기지는 않는다. 필요한 경우 지원 환경을 제한하거나 폴리필을 제공하는 선택이 별도로 필요하다. 컴파일 성공과 실행 환경 호환성을 같은 검사로 취급하지 않는다.
실전 예제: 외부 주문을 검증된 모델로 바꾸기
이번에는 외부 JSON에서 받은 주문을 내부 Order로 바꾸는 함수를 작성한다. 입력은 unknown이고, 결과는 성공한 Order 또는 실패 이유를 가진 판별 유니온이다. 검증이 실패할 수 있다는 사실을 호출자가 반환 타입에서 읽을 수 있다.
함수는 입력이 객체인지 확인하고 고객 문자열, 금액, 상태를 순서대로 검사한다. 금액은 음이 아닌 안전한 정수이고 상태는 paid 또는 cancelled만 허용한다. 고객 이름은 앞뒤 공백을 제거하여 새 객체에 담는다. 입력 객체를 그대로 반환하지 않으므로 예제에서는 내부 모델에 필요한 세 필드만 남는다.
첫 번째 데이터는 Ada라는 고객의 1200원 결제 완료 주문으로 변환된다. 두 번째 데이터의 금액은 문자열이므로 invalid amount가 반환된다. 자동 숫자 변환을 하지 않는 것이 이 API의 계약이다. 타입 검사가 실행 중 데이터를 보증하지 않기 때문에 실제 조건을 검사하는 코드가 필요한 것이다.
ParseResult를 사용했다고 모든 예외가 사라지는 것은 아니다. JSON.parse 자체의 문법 오류는 이 함수 호출 전에 발생할 수 있고, 임의의 getter나 Proxy가 든 객체는 속성 읽기 중 예외를 던질 수도 있다. 이 예제는 JSON에서 나온 일반 데이터의 형태와 범위를 검증하는 모델이며 파일 읽기나 통신 오류까지 하나로 처리한 전체 시스템은 아니다.
실제 주문에서는 고객 존재 여부, 상품과 가격의 서버 기준, 중복 요청, 권한과 거래 상태 전이도 검사해야 한다. 클라이언트가 보낸 amount를 서버가 그대로 신뢰하는 구현으로 확대하면 안 된다. 여기서는 타입 경계를 설명하기 위한 작은 데이터 예제이며 업무 검증의 범위를 명확히 나눠 둔다.
예제 코드
type Order = {customer: string; amount: number; status: 'paid' | 'cancelled'};
type ParseResult = {ok: true; value: Order} | {ok: false; error: string};
function parseOrder(input: unknown): ParseResult {
if (input === null || typeof input !== 'object') {
return {ok: false, error: 'object required'};
}
if (!('customer' in input) || typeof input.customer !== 'string'
|| input.customer.trim() === '') {
return {ok: false, error: 'invalid customer'};
}
if (!('amount' in input) || typeof input.amount !== 'number'
|| !Number.isSafeInteger(input.amount) || input.amount < 0) {
return {ok: false, error: 'invalid amount'};
}
if (!('status' in input) || (input.status !== 'paid' && input.status !== 'cancelled')) {
return {ok: false, error: 'invalid status'};
}
return {ok: true, value: {
customer: input.customer.trim(), amount: input.amount, status: input.status
}};
}
const inputs: unknown[] = [
JSON.parse('{"customer":" Ada ","amount":1200,"status":"paid"}'),
JSON.parse('{"customer":"Lin","amount":"1200","status":"paid"}')
];
for (const input of inputs) {
const result = parseOrder(input);
console.log(result.ok ? `${result.value.customer}:${result.value.amount}` : result.error);
}실행 결과
Ada:1200
invalid amount타입 테스트와 실행 테스트를 함께 사용하기
실전 파서의 테스트는 정상 입력과 잘못된 입력을 모두 다룬다. 아래 코드는 앞 절의 parseOrder 정의 뒤에 붙여 같은 파일에서 실행한다. 성공 여부, 공백 제거, 허용하지 않은 추가 필드의 제거를 확인하고 null·빈 이름·문자열 금액·음수·범위 초과·잘못된 상태가 거절되는지 확인한다.
이 검사는 실행 결과를 확인한다. 별도로 컴파일러가 잘못된 호출을 거절하는지 확인하는 타입 테스트도 필요할 수 있다. 이 문서의 실패 예제들은 특정 진단 코드가 나오는지 실제로 검사했다. 정상 코드가 컴파일되는 것과 잘못된 코드가 거절되는 것은 서로 다른 품질 기준이다.
@ts-expect-error는 의도한 타입 오류가 있어야 한다는 테스트나 제한적인 우회에 활용할 수 있지만, 오류가 발생하는 정확한 이유와 위치를 관리해야 한다. 주변 코드가 바뀌면서 다른 오류를 가리고 있지는 않은지 확인한다. 타입 오류를 무시하는 주석을 대량으로 추가하면 마이그레이션이 끝난 것처럼 보여도 보장은 약해질 수 있다.
실행 테스트도 작은 예제의 성공만으로 운영 안정성을 증명하지 않는다. 실제 통신, 인증, 데이터베이스와의 통합, 시간에 따른 상태 변화는 별도의 검증 범위다. 타입 검사와 단위 테스트, 통합 테스트가 각각 어떤 오류를 발견하는지 구분하면 중복된 검사보다 빠진 경계를 찾기 쉽다.
예제 코드
function check(condition: boolean, message: string): void {
if (!condition) throw new Error(message);
}
const success = parseOrder({customer: ' Ada ', amount: 0, status: 'paid', extra: true});
check(success.ok, 'valid order');
if (success.ok) {
check(success.value.customer === 'Ada', 'trim customer');
check(!('extra' in success.value), 'copy allowed fields');
}
const invalid: unknown[] = [
null,
{customer: '', amount: 1, status: 'paid'},
{customer: 'A', amount: '1', status: 'paid'},
{customer: 'A', amount: -1, status: 'paid'},
{customer: 'A', amount: Number.MAX_SAFE_INTEGER + 1, status: 'paid'},
{customer: 'A', amount: 1, status: 'pending'}
];
for (const input of invalid) check(!parseOrder(input).ok, 'invalid input accepted');
console.log('9 checks passed');실행 결과
9 checks passed설정과 점진적 도입
새 프로젝트에서는 strict를 기준으로 시작하면 암묵적 any와 null 관련 가정을 초기에 드러내기 쉽다. noUncheckedIndexedAccess와 exactOptionalPropertyTypes는 strict와 별도로 선택하는 옵션이며 모델에 더 엄격한 조건을 줄 수 있다. 옵션을 켰다는 사실보다 발견한 오류를 실제 계약에 맞게 수정했는지가 중요하다.
기존 JavaScript 프로젝트를 옮길 때는 확장자를 모두 바꾸는 것만으로 안전해지지 않는다. 외부 입력과 공개 함수, 데이터 모델처럼 여러 부분에 영향을 주는 경계부터 타입을 정하고 테스트로 기존 동작을 유지한다. allowJs나 checkJs를 활용한 단계적 접근도 검토할 수 있다. 변경 범위를 나누면 기능 수정과 타입 전환 때문에 생긴 오류를 구분하기 쉽다.
타입 오류가 많이 나온다고 즉시 any로 덮기보다 공통 원인을 찾는다. 서버 응답 모델이 부정확하거나 모듈 설정이 실행 환경과 다른 경우 수십 개의 오류가 하나의 문제에서 시작할 수 있다. 반대로 실제로 여러 형태가 존재하는 데이터를 하나의 타입으로 억지로 통일하면 단언이 늘어난다. 데이터의 변형을 유니온과 검증 단계로 명확히 모델링한다.
CI에서는 타입 검사 명령의 종료 상태를 확인하고 실제 배포 대상 코드를 포함하는지 점검한다. 편집기는 일부 열린 파일만 다른 설정으로 검사할 수 있고 개발용 빌드와 배포 빌드의 옵션도 다를 수 있다. compiler 버전과 잠금 파일을 관리하며 업그레이드할 때 새 진단과 라이브러리 선언 변화를 검토한다. 버전 변경에 따른 오류가 반드시 실행 버그를 뜻하지는 않지만 무시하기 전에 차이를 이해해야 한다.
장점·한계와 설계 판단
TypeScript의 장점은 코드가 커지고 여러 사람이 같은 인터페이스를 사용할 때 계약을 찾고 변경 영향을 추적하는 데 있다. 이름 변경, 필드 추가, 반환 형태 변경에 대해 편집기와 컴파일러가 사용 지점을 보여줄 수 있다. 코드 리뷰에서도 함수가 어떤 입력과 실패 가능성을 의도하는지 읽기 쉬워진다.
비용은 타입 모델과 설정을 유지하는 데 있다. 실제 기능보다 복잡한 조건부 타입과 재귀 타입을 만들면 오류 메시지를 이해하기 어려워지고 검사 시간도 늘 수 있다. 공개 타입은 사용자가 읽을 수 있는 수준으로 유지하고, 반복되는 구체적 요구가 확인됐을 때 추상화한다. 가능한 모든 라이브러리를 하나의 범용 타입으로 감싸는 것이 항상 좋은 설계는 아니다.
타입 시스템은 JavaScript 생태계의 현실적인 사용을 수용하며 모든 런타임 오류를 없애는 완전한 증명 시스템은 아니다. any, 단언, 부정확한 선언 파일, 외부 입력은 특히 주의할 경계다. number가 정확한 십진수라는 뜻도 아니고 Promise가 요청 취소와 경쟁 상태를 해결한다는 뜻도 아니다. JavaScript의 실행 의미를 함께 이해해야 한다.
적합성을 판단할 때 코드 규모 하나만 보지 않는다. 장기 유지보수, 라이브러리 품질, 팀 경험, 배포 환경과 변경 빈도를 함께 본다. 짧은 스크립트에서도 입력 계약이 복잡하면 도움이 될 수 있고, 큰 프로젝트에서도 검증 없는 단언을 남발하면 기대한 이점을 얻기 어렵다. 타입의 양보다 정확한 경계와 검증 가능한 동작이 목표다.