목차13개 항목
출처와 참고
「Frontend_TIL: TypeScript(4)·(6)·(7)」의 객체·함수 대입, 타입 확장 부분을 바탕으로, TypeScript 핸드북의 Type Compatibility로 확인해 재구성한 노트다. 신선도(freshness) 설명은 basarat의 「TypeScript Deep Dive」의 Freshness 절로 보강했다. 문법 전반은 TypeScript: 개념과 문법 한눈에 보기에, 타입을 변형하는 연산은 TypeScript 타입 연산에 있다.
타입은 값의 집합이다
타입 하나는 그 타입이 될 수 있는 값들의 집합이다. string은 모든 문자열의 집합이고, 'a' | 'b'는 그 부분집합이다. 대입 target = source가 허용되는 조건은 source의 집합이 target의 집합 안에 들어가는가다. 이 관계를 「source가 target에 할당 가능하다(assignable)」고 하고, 조건부 타입의 A extends B도 같은 관계를 묻는다.
flowchart TB
unk["unknown<br/>모든 값 (최상위)"] --> str["string"]
unk --> obj["object ..."]
str --> ab["'a' | 'b'"]
ab --> a["'a'"]
obj --> nm["{ name: string }"]
nm --> nma["{ name: string; age: number }"]
a --> nev["never<br/>값이 하나도 없음 (최하위)"]
nma --> nev- 좁은 타입은 넓은 타입에 대입할 수 있고, 반대는 안 된다.
unknown에는 무엇이든 대입되지만unknown은any·unknown말고는 어디에도 대입되지 않는다.never는 어디에나 대입되지만never에는never만 대입된다.any도never에는 대입되지 않는다.any는 집합 관계를 무시한다. 어디에든 대입되고 무엇이든 받는다.
객체: 구조적 타이핑
TypeScript는 이름이 아니라 구조(모양) 로 호환성을 판단한다(structural typing). 필요한 속성을 모두 가졌으면 다른 이름의 타입이어도 대입된다.
interface Person { name: string; age: number }
interface Employee { name: string; age: number; position: string }
let person: Person = { name: 'John', age: 30 }
let employee: Employee = { name: 'Alice', age: 25, position: 'Manager' }
person = employee // OK: Employee는 Person의 속성을 모두 가진다
employee = person // 오류: position이 없다속성이 많은 Employee가 더 좁은 타입이다. 조건이 많을수록 만족하는 값이 적어진다. 코드가 길다고 넓은 타입이 아니다.
잉여 속성 검사
구조적 타이핑이라도 객체 리터럴을 바로 대입할 때는 대상에 없는 속성을 오류로 잡는다(excess property checking, 오류 코드 TS2353). 갓 만든 리터럴은 「신선(fresh)」하다고 보고, 변수에 담기거나 단언·확장되면 신선도가 사라져 검사도 하지 않는다. 그래서 신선도(freshness) 검사라고도 한다. 리터럴에만 이 검사를 하는 이유는, 그 자리에서 만든 객체의 남는 속성은 받는 쪽이 절대 읽지 않으므로 거의 언제나 오타나 API 오해이기 때문이다.
const p1: Person = { name: 'a', age: 1, position: 'x' } // 오류: position은 Person에 없다
const tmp = { name: 'a', age: 1, position: 'x' }
const p2: Person = tmp // OK: 리터럴이 아니면 검사하지 않는다- 선택적 속성만 있는 타입에서 특히 쓸모 있다.
{ name?: string }에{ neme: 'x' }를 넣으면 구조적으로는 통과하지만(모든 속성이 선택이라), 잉여 속성 검사가 오타를 잡는다. 일부 필드만 바꾸는setState·update(patch)API를 「모든 속성 선택」으로 만들어도 안전한 이유다. - 추가 속성을 정말 허용하려면 인덱스 시그니처를 둔다:
{ foo: number; [key: string]: unknown }.
유니언과 인터섹션의 넓이
| 식 | 집합 연산 | 넓이 |
|---|---|---|
A | B | 합집합 | A보다도, B보다도 넓다 |
A & B | 교집합 | A보다도, B보다도 좁다 |
그래서 A | B 타입 값은 A·B·A & B 어디에도 그대로 대입할 수 없고, 좁히기를 거쳐야 한다. 유니언 값에서는 모든 구성원이 공통으로 가진 속성에만 접근할 수 있다.
객체 인터섹션과 원시 인터섹션
&는 늘 교집합이지만 결과가 직관과 달라 보이는 경우가 있다.
| 식 | 결과 | 이유 |
|---|---|---|
{ yellow: true } & { red: true } | { yellow: true; red: true } | 두 조건을 모두 만족하는 객체의 집합이다. 속성이 합쳐진 모양이 된다 |
number & string | never | 둘 다인 값은 없다 |
(number | string) & (string | boolean) | string | 원시 유니언끼리는 공통 원소만 남는다 |
({ name: string } | { hello: string }) & ({ age: number } | { car: 'Kia' }) | 네 조합의 유니언 | 인터섹션이 유니언 위로 분배된다: {name, age} | {name, car} | {hello, age} | {hello, car} |
interface extends와 &의 차이
둘 다 기존 타입에 속성을 더하지만, 같은 속성의 타입이 충돌할 때 다르게 처리한다.
interface Mango { red: false }
interface MangoApple extends Mango { red: true } // 선언에서 바로 오류
type MangoApple2 = { red: false } & { red: true } // 선언은 통과. 쓸 수 있는 값이 없는 never가 된다extends는 확장한 타입이 원래 타입에 대입 가능한지 선언 시점에 검사한다.&는 속성 타입끼리 교집합을 낸다.string & number처럼 속성 하나가never가 되고, 충돌한 속성이 리터럴 타입(판별 속성)이면 교차 타입 전체가never로 줄어든다. 실수가 사용하는 곳에서야 드러난다.
확장은 「속성 추가」보다 안전하다
새 응답 필드(isVIP, isVVIP)를 기존 타입에 선택 속성으로 계속 붙이면 모든 배열이 같은 타입이 되어, VIP 목록에서 isVVIP를 읽는 실수를 잡지 못한다. 공통 타입을 두고 interface VIPUserInfo extends UserInfo { isVIP: boolean }처럼 경우별로 확장한 타입을 쓰면 잘못된 접근이 컴파일 오류가 된다.
배열과 튜플
| 대입 | 결과 | 이유 |
|---|---|---|
[string, number] → (string | number)[] | OK | 튜플은 길이와 자리별 타입이 정해진, 배열보다 좁은 타입이다 |
(string | number)[] → [string, number] | 오류 | 길이와 순서를 보장할 수 없다 |
string[] → readonly string[] | OK | 읽기 전용 배열이 더 넓다. 「변경 메서드가 없어도 된다」는 약한 조건이다 |
readonly string[] → string[] | 오류 | 받은 쪽이 push할 수 있게 된다 |
함수끼리 대입
함수 타입 (x: P) => R의 호환성은 반환 타입과 매개변수 타입을 따로 본다.
| 자리 | 성질 | 규칙 |
|---|---|---|
| 반환 타입 | 공변(covariant) | 반환 타입이 더 좁은 함수를 넓은 함수 타입에 대입할 수 있다 |
| 매개변수 타입 | 반공변(contravariant), strictFunctionTypes 켰을 때 | 매개변수 타입이 더 넓은 함수를 좁은 함수 타입에 대입할 수 있다 |
| 매개변수 타입, 옵션 끔 | 이변(bivariant) | 넓은 쪽도, 좁은 쪽도 대입된다 |
| 매개변수 개수 | 적게 받는 함수는 많이 주는 자리에 대입된다. arr.forEach((x) => ...)가 되는 이유다 |
type Fn = (x: string) => string | number
const a = (x: string): string => x // 반환이 더 좁다
const b = (x: string | number): number => 1 // 매개변수가 더 넓다
const c = (x: 'a'): number => 1 // 매개변수가 더 좁다
let f: Fn = a // OK
f = b // OK
f = c // 오류: f('z')로 부르면 c가 감당하지 못한다매개변수가 반공변인 이유는 대입받은 변수로 어떤 인자가 들어올지를 생각하면 보인다. Fn으로 부르는 쪽은 아무 string이나 넘기므로, 실제 함수는 그보다 넓게 받아야 안전하다. strictFunctionTypes는 strict: true에 포함된다.
메서드 단축 문법은 여전히 이변이다
strictFunctionTypes는 함수 타입 속성에만 적용된다. 메서드 단축 문법으로 선언한 멤버는 하위 호환을 위해 이변으로 검사한다.
interface SayMethod { say(a: string | number): string } // 메서드 단축 문법
interface SayProperty { say: (a: string | number) => string } // 함수 타입 속성
const sayStr = (a: string) => 'hello'
const m: SayMethod = { say: sayStr } // 통과 (이변)
const p: SayProperty = { say: sayStr } // 오류 (반공변)엄격한 검사를 받고 싶은 콜백·핸들러 멤버는 속성 문법(say: (...) => ...)으로 적는다.
void를 반환하는 함수 타입
반환 타입이 void인 함수 타입에는 값을 반환하는 함수도 대입된다. 반환값을 쓰지 않겠다는 약속일 뿐이기 때문이다. arr.forEach(v => list.push(v))처럼 push의 반환값(number)이 있어도 forEach 콜백 자리에 들어가는 이유다. 반면 함수 선언에 직접 : void를 붙이면 값을 반환하는 순간 오류다.
type Callback = () => void
const cb: Callback = () => 1 // OK, 호출 결과의 타입은 void로 보인다
function log(): void { return 1 } // 오류유니언 객체가 섞이는 문제
type Person1 = { name: string }
type Person2 = { city: string }
declare function compare(p: Person1 | Person2): void
compare({ name: 'alex', city: 'Bucheon' }) // 오류가 나지 않는다유니언은 합집합이라 두 구성원의 속성을 다 가진 객체도 Person1이면서 Person2이므로 통과한다. 잉여 속성 검사도 유니언에서는 어느 구성원에든 있는 속성이면 허용한다. 「정확히 하나」를 강제하는 방법은 둘이다.
| 방법 | 모양 | 비용 |
|---|---|---|
| 구분자(판별 속성, 브랜드) | 구성원마다 __type: 'p1'처럼 고유한 리터럴 속성을 둔다. switch로 좁히고 never로 완전성 검사까지 된다(구분된 유니언) | 값마다 구분자를 넣어야 한다 |
나머지 속성을 ?: undefined로 막기 | { name: string; city?: undefined } | { name?: undefined; city: string }. 매핑 타입으로 자동 생성할 수 있다(정확히 하나만 허용하는 타입) | 타입이 복잡해진다 |
정리
- 타입은 값의 집합이고, 대입은 좁은 집합을 넓은 집합에 넣는 일이다.
never가 맨 아래,unknown이 맨 위다. - 객체는 이름이 아니라 모양으로 비교하지만, 객체 리터럴을 바로 대입할 때만은 잉여 속성을 잡는다.
A | B는 넓어지고A & B는 좁아진다. 객체 인터섹션은 속성을 합치고, 원시 인터섹션은 공통 원소만 남긴다.- 함수는 반환 타입이 공변, 매개변수가 반공변이다. 단 메서드 단축 문법은 이변이다.
- 유니언 객체를 「정확히 하나」로 쓰려면 구분자를 둔다.
관련 노트: TypeScript: 개념과 문법 한눈에 보기, TypeScript 타입 연산, TypeScript로 동적인 코드에 정적 타입 입히기, React와 TypeScript
원본 기록8
- 원문 (새 창) basarat.gitbook.io/typescript/type-system/freshness
- GitHub 원문 (새 창) github.com/AI-Dev-Lex-Kim/Frontend_TIL/blob/master/TypeScript/TypeScript(4) - 맵드 타입(Mapped Types)%2C 템플릿 리터럴 타입(Template Literal Types).md
- GitHub 원문 (새 창) github.com/AI-Dev-Lex-Kim/Frontend_TIL/blob/master/TypeScript/TypeScript(6) - 함수끼리 대입.md
- GitHub 원문 (새 창) github.com/AI-Dev-Lex-Kim/Frontend_TIL/blob/master/TypeScript/TypeScript(7) - satisfies%2C 타입확장.md
- GitHub 원문 (새 창) github.com/AI-Dev-Lex-Kim/Frontend_TIL/blob/master/TypeScript/TypeScript(3) - Union%2C Intersection%2C 리터럴 타입%2C Enum %2C never %2CGenerics.md
- GitHub 원문 (새 창) github.com/AI-Dev-Lex-Kim/Frontend_TIL/blob/master/TypeScript/TypeScript(8) - 유틸리티 타입%2C 커스텀 유틸리티 타입.md
- 원문 (새 창) www.typescriptlang.org/docs/handbook/type-compatibility.html
- 원문 (새 창) www.typescriptlang.org/tsconfig/#strictFunctionTypes