유니언 연산자(|)와 반대되는건 인터섹션 연산자(&)이다.
유니언 연산자가 합집합이라면 인터섹션 연산자는 교집합이다.
타입스크립트의 타입을 집합 관계로 볼 수 있다.
전체집합은 unknown이다.
공집합은 never이다.
타입스크립트에서는 좁은 타입을 넓은 타입에 대입할 수 있다.
any타입은 타입스크립트 범위 밖을 벗어나는 타입이기에 집합관계에 포함되지 않는다고 봐야한다.
type A = string | boolean;
type B = boolean | number;
type C = A & B; // C = boolean
type D = {} & (string | null); // D = string
type E = string & boolean; // E = never
type F = unknown | {}; // F = unknown
type G = never & {}; // G = never
type H = {a: 'b'} & number; // H = {a : 'b'} & number
type I = null & {a: 'b'}; // I = never
type J = {} & string; // J = string
특이한 점
null/undefined를 제외한 원시자료형 & 비어 있지 않은 객체 != never
이는 예외사항으로, 이를 활용해서 브랜딩이라는 기법을 사용한다.
뒤에 28절에서 배우기로 한다.
타입스크립트에서도 객체 타입 간에 상속하는 방법이 있다.
인터페이스를 이용한 상속은 다음과 같다.
interface Animal{
name: string;
}
interface Dog extends Animal{
bark(): void;
}
interface Cat extends Animal{
meow(): void;
}
타입 별칭을 이용한 상속은 다음과 같다.
type Animal = {
name: string;
}
type Dog = Animal & {
bark(): void;
}
type Cat = Animal & {
meow(): void;
}
"객체타입A & 객체타입B" 을 하면 속성이 "합"쳐지기에 합집합인 | 와 헷갈릴 수 있다.
| 를 사용하면 "객체타입A 이거나 객체타입B"가 된다.
교집합인 &사용이 맞다.
타입별칭과 인터페이스 간에 교차로 상속도 가능하다.
타입스크립트에서는 타입별칭으로 선언한 객체타입과 인터페이스로 선언한 객체타입은 대부분의 경우 서로 호환가능하다.
두 개 이상의 타입을 상속할 수도 있다.
interface DogCat extends Dog, Cat {}
상속할 때 부모 속성의 타입을 좁힐 수도 있다. 하지만 완전 다른 타입일 경우에는 에러가 발생한다.
interface Parent{
one: string;
two: string;
}
interface Child extends Parent{
one: 'h' | 'w'; // 더 좁은 타입
two: 123; // 에러
}
객체 리터럴을 대입할 때는 잉여 속성 검사(속성의 개수와 타입이 완벽하게 일치)가 진행되지만, 변수를 대입할 때는 진행되지 않는다고 했다. 알아보자.
변수를 대입할 때는 대입 가능성만 본다.
대입 가능성은 앞에서 배웠듯이 "넓은 타입은 좁은 타입에 대입할 수 없다" 라는 것만 보면 된다.
interface N {
name: string;
}
interface NA {
name: string;
age: number;
}
let n: N = {
name: "kdh",
};
let na: NA = {
name: "kdh",
age: 20,
};
n = na; // NA타입은 N타입에 대입 가능, 좁은 범위가 넓은 범위로 대입
na = n; // 에러, N타입은 NA타입에 대입 불가능, 넓은 범위가 좁은 범위로 대입
마찬가지로 튜플은 배열보다 좁은 타입이다.
따라서 튜플은 배열에 대입 가능하다. 물론 반대는 불가능하다.
readonly 를 붙이면 범위가 넓어진다.let readonlyObj: readonly string[] = ["hi", "readonly"];
let mutableObj: string[] = ["hi", "normal"];
readonlyObj = mutableObj;
mutableObj = readonlyObj; // 에러
그럼 튜플에 readonly 를 붙여서 기존의 튜플 타입보다 넓어지게 한 뒤에 배열과 서로 대입을 해보면? 둘 다 안됨.
정리하자면
로 범위가 설정되고 그 외에는 호환안됨.
type Optional = {
a?: string;
};
type Mandatory = {
a: string;
};
let optional: Optional = {
a: "abc",
};
let mandatory: Mandatory = {
a: "def",
};
optional = mandatory;
mandatory = optional; // 에러
튜플 배열과 달리 객체 속성에 readonly 는 대입가능성에 영향을 주지 않는다.
type ReadOnly = {
readonly a: string;
};
type Mandatory = {
a: string;
};
let readOnly: ReadOnly = {
a: "abc",
};
let mandatory: Mandatory = {
a: "def",
};
readOnly = mandatory; // ok
mandatory = readOnly; // ok
타입스크립트에서는 모든 속성이 동일하면 타입의 이름이 달라도 같은 타입으로 취급한다.
구조적 타이핑은 이전에 본 대입가능성에서도 드러난다.
interface N {
name: string;
}
interface NA {
name: string;
age: number;
}
NA타입은 N타입에 대입가능했다.
즉, 구조적 타이핑 관점에서는 NA 타입은 N타입이라고 볼 수 있다.
왜냐하면 N타입이 갖고 있는 name 속성을 NA타입도 갖고 있기 때문이다.
반대로 N타입은 NA타입이 아니다.
서로 대입하지 못하게 하려면 어쩔 수 없이 구분하기 위한 속성을 추가해야 한다.
interface Money{
_type: 'money';
amount: number;
}
interface Liter{
_type: 'liter';
amount: number;
}
앞에서 잠깐 언급했었는데 _type과 같은 속성을 브랜드(brand) 속성이라고 부른다.
그리고 브랜드 속성을 사용하는 것을 브랜딩한다고 표현한다.
타입스크립트도 타입 간에 중복이 발생할 수 있다.
interface KDH{
type: 'human',
race: 'yellow',
name: 'kdh',
age: 28
}
interface LDH{
type: 'human',
race: 'yellow',
name: 'ldh',
age: 30
}
이때 제네릭을 사용하여 중복을 제거할 수 있다.
interface Person<N,A>{
type: 'human',
race: 'yellow',
name: N,
age: A
}
interface KDH extends Person<'kdh', 28>{}
interface LDH extends Person<'ldh', 30>{}
type PDH = Person<"pdh", 31>; // 타입 별칭은 이렇게 사용
N, A 를 타입 매개변수라고 부르고 kdh, 28 을 타입 인수라고 부른다.
인터페이스 뿐만 아니라 클래스와 타입 별칭, 함수도 제네릭을 가질 수 있다.
type Person1<N, A> = {
type: "human";
name: N;
age: A;
};
type KDH = Person1<'kdh', 28>;
class Person2<N, A> {
name: N;
age: A;
constructor(name: N, age: A) {
this.name = name;
this.age = age;
}
}
// 함수 표현식
const personFactory1 = <N, A>(name: N, age: A) => ({
type: "human",
name,
age,
});
// 함수 선언문
function personFactory2<N, A>(name: N, age: A) {
return {
type: "human",
name,
age,
};
}
객체나 클래스의 메서드에서 따로 제네릭을 표기할 수도 있다.
class Person<N, A> {
name: N;
age: A;
constructor(name: N, age: A) {
this.name = name;
this.age = age;
}
method<B>(param: B) {}
}
interface IPerson<N, A> {
type: "human";
name: N;
age: A;
method1<C>(param: C): void;
method2: <B>(param: B) => void;
method3: {
<E>(param: E): void;
};
}
type TPerson<N, A> = {
type: "human";
name: N;
age: A;
method1<C>(param: C): void;
method2: <B>(param: B) => void;
method3: {
<E>(param: E): void;
};
};
제네릭의 자리를 기억해두자.
interface Person<N = string, A = number>{
name: N,
age: A
}
타입스크립트는 타입 인수에 타입을 직접 넣지않아도 매개변수를 통해 타입을 추론할 수 있다.
const personFactory1 = <N, A>(name: N, age: A) => ({
type: "human",
name,
age,
});
const person = personFactory1("kdh", 30);
// person: {
// type: string;
// name: string;
// age: number;
//};
function values<T>(param: T[]) {
return {
hasValue(value: T) {},
};
}
const result = values(["a", "b", "c"]);
result.hasValue("d");
param 인수 타입이 string[]이 되고 T는 string타입이 된다.
hasValue()의 value 매개변수의 타입은 string이 된다.
그래서 "d" 를 인수로 전달해도 문제가 없다.
하지만 더 좁게 타입을 설정하고 싶을 수 있다.
['a', 'b', 'c'] 를 배열 타입이 아닌, 튜플 타입으로, 튜플 타입도 기본 타입의 조합이 아닌 리터럴 타입으로 최대한 좁게 잡고 싶을 수 있다.
타입스크립트 5.0 이전에는 다음과 같이 설정했다.
function values2<T>(param: readonly T[]) {
return {
hasValue(value: T) {},
};
}
const result2 = values2(["a", "b", "c"] as const);
result2.hasValue("d"); // 에러
하지만 5.0 부터는 const T 타입이 추가되었다.
function values3<const T>(param: T[]) {
return {
hasValue(value: T) {},
};
}
const result3 = values3(["a", "b", "c"]);
result3.hasValue("d"); // 에러
const 수식어를 타입 매개변수 앞에 추가하면 타입 매개변수를 추론할 때 as const 를 붙인 값으로 추론된다.
상속때 사용했던 extends 키워드로 타입 매개변수를 제약할 수 있다.
타입의 상속을 의미하던 extends와는 사용법이 다르다.
아래의 extends는 타입 매개변수 A의 타입의 최대 범위가 number 여야 한다는 뜻이다.
최대 범위가 number라는 것은 집합 관점에서 보았을때, 공집합인 never, 부분집합인 숫자리터럴 타입, 자기자신인 number라는 것을 의미한다.
interface Example<A extends number, B = string> {
a: A;
b: B;
}
type UseCase1 = Example<string, boolean>; // 에러
type UseCase2 = Example<1, boolean>; // 더 좁은 타입 통과
type UseCase3 = Example<number>;
하나의 타입 매개변수가 다른 타입 매개변수의 제약이 될 수도 있다.
interface Example<A, B extends A> {
a: A;
b: B;
}
type UseCase1 = Example<string, string>;
type UseCase2 = Example<string, "abc">;
type UseCase3 = Example<string, never>;
다음의 제약이 의미하는 것이 무엇인지 생각해보자.
<T extends object> : 최대 범위가 기본 타입을 제외한 모든 객체 집합인 제네릭 타입<T extends any[]> : 최대 범위가 모든 배열인 제네릭 타입<T extends (...args: any) => any> : 최대 범위가 모든 함수인 제네릭 타입<T extends abstract new (...agrs: any) => any> : 최대 범위가 추상 클래스도 포함한 모든 생성자 타입인 제네릭 타입<T extends keyof any> : 최대범위가 string | number | symbol인 제네릭 타입interface V0 {
value: any;
}
const returnV0 = <T extends V0>(): T => {
return { value: "test" }; // 에러
};
V0 타입으로 제약된 T는 아직 무슨 타입인지 모른다. V0 보다 좁은 타입이라는 제약만 주어졌을 뿐 never 타입이 될 수도, {value: any, another: any} 타입이 될 수도 정해진게 없다.
즉, T 나 extends로 제약된 T나 같은 제네릭 타입이다.
T extends V0를 하였다고 T가 V0라고 생각해서는 안된다.
그런 상황에서 반환하는 {value: "test"} 값은 T의 타입이라고 확신할 수 없다.
function f<T>(): T{
return "abc"; // 에러
}
T를 리턴하는 함수인데 문자열을 특정해서 리턴하면 타입에러가 발생한다.
이것과 본질적으로 동일하다.
참고로 제네릭 타입은 "호출" 시점에 정해진다.
호출시점에 정해진 T를 기반으로 타입을 작성하면 오류가 발생하지 않는다.
const returnV0 = <T extends V0>(param: T): T => {
return param;
};
위의 코드는 호출시점에 파라미터의 값에 따라 T가 정해지고 파라미터를 그대로 리턴하므로 타입오류가 발생하지않는다.
// 에러
function onlyBoolean<T extends boolean>(arg: T = false): T {
return arg;
}
위의 코드가 에러나는 이유를 해석해보자.
T는 boolean의 하위 타입 집합니다. ( 아직 그 중에서 뭔지 모름. 호출시점이 아니기때문)
매개변수 자리에 arg: T = false 는 타입 매개변수에 기본값을 넣은게 아니라 자바스크립트의 arg 매개변수에 기본값 false를 넣는 것이고 그것의 타입이 T라고 설정한 것이다.
호출 전부터 arg에 false로 세팅해놓았다. 뭘믿고? never 타입일 수도 있는데?
onlyBoolean(undefined as never) 라고 호출하면 T는 never타입이 되는데 T 타입에 false를 대입하는 꼴이 되버린다. 그래서 에러나는거다.
어렵게 생각할 것 없다. 제약된 타입 매개변수가 아닌 일반 타입 매개변수로 생각해보자.
const func: <T>(param: T) => T = (param) => {
return param; // ok
};
const func1: <T>(param: T) => T = (param = "abc") => {
return param; //에러
};
const func2: <T>(param: T) => T = (param) => {
return "abc"; //에러
};
제약된 타입 매개변수는 범위가 제약된 것일 뿐이다.
괜히 extends 뒤에 나오는 "타입 값" 때문에 헷갈리지 말자.
타입스크립트에는 조건에 따라 타입이 달라지는 컨디셔널 타입이 있다.
type A1 = string;
type B1 = A1 extends string ? number : boolean;
조금 복잡한 타입을 보자
interface X {
x: number;
}
interface XY extends X {
y: number;
}
interface Z {
x: number;
}
type A = XY extends X ? string : number; // string
type B = Z extends X ? string : number; // string
XY타입은 X를 상속하기 때문에 A타입이 string이 되는건 당연하다.
하지만 구조적 타이핑 기억나나?
상속 받든 안받든 중요하지 않다. 상속은 그냥 속성을 받아 올 뿐.
Z타입은 X타입의 하위 타입이기 때문에 Z extends X 는 참이다.
컨디셔널 타입을 이용해서 객체 속성을 필터할 수 있다.
키가 never타입이 된다면 해당 키는 제거된다.
매핑된 객체 타입 기법을 이용해 키를 제거할 수 있다.
type OmitByType<O, T> = {
[K in keyof O as O[K] extends T ? never : K]: O[K];
};
type Result = OmitByType<
{
name: string;
age: number;
married: boolean;
rich: boolean;
},
boolean
>;
K in keyof O => K in "name" | "age" | "married" | "rich"
Mapped Type의 평가 순서에 따라 in 연산자 우측에 오는 유니언 타입의 각 요소타입이 개별적으로 K에 대입된다.
즉, K가 "name"일 때, K가 "married"일 때, K가 "rich"일 때 각각 실행된다.
먼저 K에 "name"이 대입 되었다고 가정한다.
"name" as O["name"] => string
string extends T ? never : "name" => T가 boolean이므로 K는 "name"타입이 된다.
K가 각각 "age", "married", "rich" 타입인 경우도 똑같이 실행하면 결국 K는 never | "name" | "age" 가 된다.
never타입인 키는 제거되므로 { name: string; age: number } 객체타입으로 필터링된다.
type Start = string | number;
type Result = Start extends string ? Start[] : never; // never
Start 타입은 유니언 타입이므로 string타입의 하위 타입이 아니다.
그래서 Result 타입은 never가 된다.
유니언 타입의 각각의 타입에 대해 연산을 실행하고 싶다면 즉, 분배법칙을 사용하고 싶다면 제네릭을 이용하면 된다.
조건식의 좌변에 제네릭 타입이 그 자체로 직접 들어오면 분배법칙이 실행된다.
type Start = string | number;
type Result<T> = T extends string ? T[] : never;
let n: Result<Start> = ["hi"];
string extends string ? string[] : never
number extends string ? number[] : never
가 되고 정리하면 string[] | never = string[] 가 된다.
특이한 점1
boolean에 분배법칙이 적용될 때는 조심해야 한다.
type Start = string | number | boolean;
type Result<T> = T extends string | boolean ? T[] : never;
let n: Result<Start> = ["hi"]; // string[] | false[] | true[]
string[] | boolean[] 이 아니라 string[] | false[] | true[] 이 된다.
boolean타입은 다른 기본타입과 달리 사실 true | false의 타입 별칭이기 때문이다.
분배법칙을 막고싶을 수도 있다.
type isString<T> = T extends string ? true : false;
type Result = IsString<'hi' | 3>; // Result: boolean
'hi' | 3 이라는 유니언 타입을 string 타입의 하위타입인지를 검사해서 false가 나오도록 하고 싶을 수 있다.
하지만 위의 코드에서는 true | false 가 되어 결국 boolean 타입이 된다.
분배 법칙이 일어나는 조건을 보면 제네릭 타입이 그 자체로 좌변에 와야한다.
즉, 제네릭타입에 [] 옷을 입히면 분배 법칙이 일어나지 않는다.
type isString<T> = [T] extends [string] ? true : false;
type Result = IsString<'hi' | 3>; // Result: false
특이한 점2
string 타입은 string | never 의 유니언이라고 생각 할 수 있다.
string extends number? true : false => false
기존의 string 타입의 조건식 결과는 false 타입이다.
하지만 유니언으로 적용된다면
string extends number? true : false => false
never extends number? true : false => true
false | true인 boolean 타입이 된다.
never 타입 때문에 결과가 이상해진다.
그래서 never는 분배법칙이 일어나면 never 타입이 반환되도록 정해져있다.
따라서 위의 조건 결과는 false | never 가 된다.
type R<T> = T extends string ? true : false;
type RR= R<never>; // RR: never
간단하게 컨디셔널 타입에서 never과 제네릭이 만나면 never가 된다고 생각하자.
분배법칙이 일어나지 않도록 설정하면 never 도 조건평가의 대상이 될 수 있다.
type R<T> = [T] extends [string] ? true : false;
type RR= R<never>; // RR: true
분배 법칙이 일어나는 타입도 타입이다
type R<T> = T extends string ? T : T;
function func<T>(param: T) {
const v: R<T> = param; // 에러
}
위의 R<T>는 이렇든 저렇든 결국 T 타입이다.
그래서 지역변수 v는 T타입이 되고 T타입인 param이 대입될 수 있을꺼라 생각할 수 있다.
하지만 에러가 난다.
타입스크립트는 분배법칙이 일어나는 타입을 “분배 조건부 타입”이라는 새로운 타입 표현식으로 취급한다.
분배법칙을 위한 원형을 유지하므로 타입스크립트 입장에서는 R<T> 와 T 가 서로 다른 타입니다.
따라서 위와 같이 사용하고 싶다면 분배법칙을 막으면 정상동작한다.
function example(a: string, b?: number, c = false){}
기본값이 제공된 매개변수는 자동으로 옵셔널이 된다.
function example1(a: string, ...b: number[]){}
function example2(...args: [number, string, boolean]){}
function example3(...args: [a: number, b: string, c: boolean]) {
const [a, b, c] = args;
console.log(a); // number
console.log(b); // string
console.log(c); // boolean
}
나머지 매개변수 문법을 이용해서 매개변수를 정의할 때는 배열과 튜플로 타이핑한다.
튜
example3은 이름을 붙인 튜플인데 타입스크립트에서만 사용하는 것으로 가독성을 위해 사용될 뿐 자바스크립트와는 무관하다.
function example1() {
console.log(this); // 에러
}
function example2(this: typeof globalThis) {
console.log(this);
}
example2(); // 에러
example2.call(this); // ok
function example3(this: Document, a: string) {}
example3("hello"); // 에러
example3.call(document, "hello"); // ok
함수 내부에서 this를 사용하는 경우에는 명시적으로 표기해야 한다.
표기하지 않으면 any로 추론되고 에러가 난다.
this의 타입은 매개변수 첫 번째 자리에 표기하면 된다.
나머지 매개변수는 한자리씩 뒤로 밀려난다.
타입스크립트에서만 동작되는 것이며 this는 실제 매개변수가 아니다.
또한 this 타입을 표기했다고 끝나는 것이 아니다.
this의 값을 명시적으로 넘겨줘야 한다.
일반적으로 메서드 내의 this는 객체 자신으로 추론되므로 this를 명시적으로 타이핑할 필요가 없다.
하지만 this가 바뀔 수 있을 때는 명시적으로 타이핑해야 한다.
type Animal = {
age: number;
_type: "dog";
};
const person = {
name: "kdh",
age: 28,
sayName() {
this.name;
},
sayAge(this: Animal) {
this.age;
},
};
person.sayAge.call({ age: 28, _type: "dog" });
자바스크립트에서는 함수를 생성자 함수로 사용할 수 있다.
타입스크립트에서는 기본적으로 불가능하다.
class를 써야하는 데 이 방법은 20절에서 다루기로 한다.
두 문자열을 합치거나 두 숫자를 합하는 add함수를 만들어보자.
function add(x: string | number, y: string | number): string | number {
return x + y; // 에러
}
string | number 타입끼리의 + 연산이 안된다고 에러가 나온다.
number는 number끼리, string은 string끼리 연산해야 한다.
이럴때 필요한 기법이 오버로딩이다.
function add(x: number, y: number): number; // 선언부
function add(x: string, y: string): string; // 선언부
function add(x: any, y: any) { // 구현부
return x + y;
}
처음 두 선언은 타입만 있고 구현부는 없다.
마지막 선언은 구현부는 있으나 매개변수의 타입이 any이다.
any를 명시적으로 사용한 처음이자 마지막 사례이다.
매개변수의 타입체크는 선언부에 나온 타입을 검사하며, 구현부에서 any라고 작성했다고 any타입으로 호출할 수는 없다.
위에 선언한 number조합, string조합으로만 x,y 매개변수로 대입이 가능하다.
구현부는 선언부에 작성한 모든 타입을 포함 할 수 있어야 해서 any타입을 많이 사용하지만 유니언으로도 작성할 수 있다.
다만, 유니언으로 작성할 경우 타입 네로잉으로 코드를 작성해야해서 복잡해진다.
물론 이게 더 안전한 방식이긴 하지만 확실한 경우 any를 사용해도 된다.
function add(x: number, y: number): number;
function add(x: string, y: string): string;
function add(x: number | string, y: number | string) {
if (typeof x === "number" && typeof y === "number") return x + y;
else if (typeof x === "string" && typeof y === "string") return x + y;
else throw new Error("Invalid arguments"); // 명시적으로 반환 보장
}
위에 예외를 던지지 않으면 반환 타입이 number | string | undefined 가 되어서 타입에러가 발생한다.
다음은 인터페이스로 오버로딩을 한 예시이다.
interface Add {
(x: number, y: number): number;
(x: string, y: string): string;
}
const add: Add = (x: any, y: any) => x + y;
다음은 타입 별칭으로 오버로딩을 한 예시이다.
type Add = {
(x: number, y: number): number;
(x: string, y: string): string;
};
const add: Add = (x: any, y: any) => x + y;
오버로딩의 순서도 중요한 데, 타입 추론이 위에서 부터 이루어지므로 타입 범위가 겹칠때는 항상 좁은 범위의 타입이 먼저 선언되어야 한다.
오버로딩을 남용하지 말자.
유니언이나 옵셔널 매개변수를 활용할 수 있는 경우는 오버로딩을 쓰지 않는 것이 좋다.
참고로 타입 뿐 아니라 매개변수의 개수를 다르게 해도 오버로딩이 적용된다.
function f(): void;
function f(name: string): number;
function f(name: string, age: number): boolean;
function f(name?: string, age?: number): number | boolean | void {
if (name && age) {
return true;
} else if (name) {
return 1;
}
}
매개변수의 개수와 타입에 맞춰서 함수의 반환타입도 결정된다.
하지만 마지막 구현부는 위의 선언한 파라미터를 전부 포함할 수 있어야 한다.
함수가 콜백함수로 사용될 때 발생하는 타입스크립트의 특징에 대해 알아보자.
function example(callback: (error: Error, result: string) => void) {}
// 첫 번째 호출 방식
example((e, r) => {
console.log(e); // e: Error
console.log(r); // r: string
});
// 두 번째 호출 방식
example(() => {}); // 인자 생략 가능
// 세 번째 호출 방식
example(() => true); // void 타입이 리턴인 경우
콜백함수를 매개변수로 가지는 example을 여러 방식으로 호출했다.
첫 번째 호출 방식을 보면 example 함수의 매개변수인 콜백함수에 대한 타입을 지정하지 않았다.
이미 example 함수를 선언할 때 타입을 표기했기 때문이다.
두 번째 호출 방식을 보면 콜백 함수의 매개변수를 생략했다.
인자를 넘기지 않으면 자연적으로 undefined가 대입될 꺼라 생각이 되고 error는 Error | undefined가 아니기에 타입에러가 날 꺼라 생각할 수도 있다.
하지만 타입스크립트는 콜백함수의 호출 편의성을 위해 이를 지원한다.
오히려 error?: Error 로 하면 error의 타입이 넓어지게 되므로 조심하자.
세 번째 호출 방식을 보면 콜백 함수의 반환값이 void인데 true를 반환하는 타입으로 호출했다.
A ➡️ B 일 때 T<A> ➡️ T<B> 인 경우A ➡️ B 일 때 T<A> ⬅️ T<B> 인 경우A ➡️ B 일 때 T<A> ↔️ T<B> 인 경우A ➡️ B 일 때 T<A> ❌↔️ T<B> 인 경우기본적으로 타입스크립트는 공변성을 갖지만, 함수의 매개변수는 반공변성을 갖고 있다.
TSConfig 메뉴에서 strictFunctionTypes 옵션이 체크되어야 한다.
strictFunctionTypes 옵션은 strict 옵션이 체크되어 있을 때 자동으로 활성화된다.
strict와 strictFunctionTypes 모두 체크되어 있지 않다면 타입스크립트는 매개변수에 대해 이변성을 갖는다.
함수의 반환값이 공변성인지 반공변성인지 알아보기 위해 매개변수를 동일하게 제한해보자.
type A = () => number;
type B = () => number | string;
let a: A = () => 3;
let b: B = () => 3;
b = a; // ok
a = b; // 에러
둘 다 매개변수가 없고 반환값만 다르다.
A의 반환타입은 number로, B의 반환타입인 number | string 의 하위타입이다.
대입 가능성을 따져보면 A의 반환타입은 B의 반환타입에 대입이 된다. (A ➡️ B)
T<A> 를 "A타입을 반환하는 함수" 라고 정의할 때
A타입을 반환하는 함수는 B타입을 반환하는 함수에 대입이 됨을 확인했다.
즉, T<A> ➡️ T<B> 이다
따라서 함수의 반환 타입은 공변성을 갖는다.
이번엔 함수의 매개변수가 공변성인지 반공변성인지 알아보기 위해 반환타입을 제한해보자.
type A = (x: number) => void;
type B = (x: number | string) => void;
let a: A = (x) => {};
let b: B = (x) => {};
b = a; // 에러
a = b; // ok
A타입의 매개변수는 number타입이고 B타입의 매개변수는 number | string이다.
대입 가능성을 따져보면 A의 매개변수 타입은 B의 매개변수 타입에 대입이 된다. (A ➡️ B)
하지만, 반환 타입과는 달리 T<A> ➡️ T<B> 가 아닌, T<A> ⬅️ T<B> 가 된다.
즉, 함수의 매개변수는 반공변성을 가진다.
만약 strict 옵션을 해제하면 A ➡️ B 일때 T<A> ↔️ T<B>가 된다.
객체의 매서드를 타이핑 할 때도 공변성 / 반공변성이 적용된다.
그러나 메서드를 어떻게 선언하느냐에 따라 매개변수의 변성이 달라진다.
interface A { // 매개변수가 이변성
say(a: string | number): string;
}
interface B { // 매개변수가 반공변성
say: (a: string | number) => string;
}
interface C { // 매개변수가 반공변성
say: {
(a: string | number): string;
};
}
const sayFunc = (a: string) => "hello";
const MyA: A = {
say: sayFunc, // 매개변수가 이변성이라 ok
};
const MyB: B = {
say: sayFunc, // 에러
};
const MyC: C = {
say: sayFunc, // 에러
};
타입스크립트에서의 클래스는 다음과 같이 정의한다.
// 타입스크립트
class Person {
name: string;
age: number;
married: boolean;
constructor(name: string, age: number, married: boolean) {
this.name = name;
this.age = age;
this.married = married;
}
}
// 자바스크립트
class Person {
constructor(name, age, married) {
this.name = name;
this.age = age;
this.married = married;
}
}
생성자 함수의 매개변수를 통해 타입 추론이 가능하므로, 클래스 멤버의 타입은 생략할 수 있으나, 변수 자체를 생략할 수는 없다.
class Person {
name;
age;
married;
constructor(name: string, age: number, married: boolean) {
this.name = name;
this.age = age;
this.married = married;
}
}
다음과 같이 표현식으로도 클래스 작성이 가능하다.
const Person = class {
name;
age;
married;
constructor(name: string, age: number, married: boolean) {
this.name = name;
this.age = age;
this.married = married;
}
};
멤버 변수는 항상 constructor 내부와 짝이 맞아야 한다.(옵셔널인 경우 제외)
조금 더 엄격하게 클래스의 멤머가 들어있는지 검사할 수 있다.
인터페이스와 implements 예약어를 사용하면 된다.
interface Human {
name: string;
age: number;
married: boolean;
sayName(): void;
}
// sayName()없어서 에러
class Person implements Human {
name;
age;
married;
constructor(name: string, age: number, married: boolean) {
this.name = name;
this.age = age;
this.married = married;
}
}
타입스크립트는 생성자 함수 방식으로 객체를 만드는 것을 지원하지 않는다. (어거지로 만들 수는 있다. 근데 굳이?)
따라서 new를 붙여 호출할 수 있는 객체는 클래스뿐이라고 생각하자.
class Person {
name;
age;
married;
constructor(name: string, age: number, married: boolean) {
this.name = name;
this.age = age;
this.married = married;
}
}
const person: Person = new Person("kdh", 30, true);
const P1: Person = Person; // 에러
const P2: typeof Person = Person; // ok
클래스 자체를 값으로 사용할 때의 해당 타입은 typeof 클래스이름 으로 정의한다.
class Parent {
name?: string;
readonly age: number;
protected married: boolean;
private value: number;
constructor(name: string, age: number, married: boolean) {
this.name = name;
this.age = age; // readonly라 한번 설정하고는 변경 불가
this.married = married;
this.value = 0;
}
changeAge(age: number) {
this.age = age; // 에러, readonly라 수정 불가
}
}
class Child extends Parent {
constructor(name: string, age: number, married: boolean) {
super(name, age, married);
}
sayName() {
console.log(this.name); // public이라 접근 가능
}
sayMarried() {
console.log(this.married); // protected라 자손 클래스에서 접근 가능
}
sayValue() {
console.log(this.value); // 에러, private 접근 불가
}
}
const child = new Child("kdh", 28, false);
child.name; // public 접근 가능
child.married; // 에러, protected는 자손 클래스 내부까지만 접근가능
child.value; // 에러, private
| 접근제한자 | 자기자신class | 자손class | 인스턴스 |
|---|---|---|---|
| public | O | O | O |
| protected | O | O | X |
| private | O | X | X |
public 키워드는 기본설정이라 따로 사용할 일이 없음
private 키워드는 자바스크립트의 # 때문에 사용할 일이 없음
protected만 사용함.
# VS private
private 으로 선언한 멤버 변수는 자손 클래스에서 같은 이름으로 선언 못함.
#으로 선언한 멤버 변수는 자손 클래스에서 같은 이름으로 선언 가능함.
이 부분을 제외하면 나머진 같음.
#(private field)가 더 자바스크립트의 원래 기능과 좀 더 가깝기 때문에 이걸로 쓰자.
implements로 구현한 속성은 전부 public
implemenets 예약어를 이용해 클래스를 정의할 때는 public이다.
애초에 인터페이스의 속성은 접근제한자를 설정할 수가 없기 때문이다.
클래스 메서드에는 override 예약어가 있다.
이 기능을 활용하려면 수동으로 TS Config메뉴에서 noImplicitOverride 옵션을 체크해야한다.
체크하지 않으면 타입검사를 하지 않는다.
class Human {
eat() {
console.log("human eat");
}
sleep(){
console.log("human sleep");
}
}
class Teacher extends Human {
eat() { // 에러
console.log("teacher eat");
}
override sleep(){ // ok
console.log("teacher sleep");
}
}
위에서 배운 오버로딩도 적용할 수 있다.
클래스 생성자 함수에도 가능하다.
클래스의 속성에도 인덱스 시그니처를 사용할 수 있다.
class Signature{
[propName: string]: string | number | undefined;
static [propName: string]: boolean;
}
static 멤버 변수도 가능하다.
클래스나 인터페이스의 메서드에서는 this를 값 말고 타입으로도 사용가능하다.
class A {
// thisMethod(콜백함수){
// 콜백함수() <- this(현재 객체)를 전달하려면?
// }
thisMethod(callback: (this: this) => void) { // 매개변수 이름: A 타입
callback.call(this); // A 인스턴스 객체
}
}
const a = new A();
a.thisMethod(function () {
console.log(this); // A 출력
});
앞에서 함수에서의 this사용법을 배웠다.
콜백함수는 결국 함수를 호출하는 것이므로 this를 명시적으로 표기해야한다.
콜백함수의 매개변수인 this:this 에서 앞의 this는 매개변수이고 뒤의 this는 타입 즉 A 타입 이다.
callback.call(this)의 this는 A 인스턴스 객체이다.
호출해보면 A가 출력된다.
인터페이스로 클래스 생성자를 타이핑할 수도 있다.
interface PersonConstructor {
new (
name: string,
age: number,
): {
name: string;
age: number;
};
}
class Person {
name: string;
age: number;
constructor(name: string, age: number) {
this.name = name;
this.age = age;
}
}
const createPerson: PersonConstructor = Person;
const a = new createPerson("kdh", 29);
이를 이용하면 생성자 함수를 어거지로 타이핑 할 수 있다.
interface MyThis {
name: string;
}
function Func(this: MyThis, name: string) {
this.name = name;
console.log(this);
}
type MyFunc = typeof Func & {
// Func 함수일때의 타입에 생성자 타입 추가
new (name: string): MyThis;
};
const a = new (Func as MyFunc)("kdh");
Func함수를 정의할 때는 내부에 this를 사용했기 때문에 매개변수 첫 번째 위치에 this를 표기했다.
함수호출 같았으면 Func.call(this)를 했겠지만 생성자 함수로써 호출할때는 필요없다.
근데 class가 있는데 굳이 이러한 방식으로 코딩할 이유가 없다.
implements와 다르게 abstract 클래스는 실제 자바스크립트 코드로 변환된다.
abstract class AbstractPerson {
name: string;
age: number;
married: boolean = false;
abstract value: number;
constructor(name: string, age: number, married: boolean) {
this.name = name;
this.age = age;
this.married = married;
}
sayName() {
console.log(this.name);
}
abstract sayAge(): void;
abstract sayMarried(): void;
}
class RealPerson extends AbstractPerson {
value: number = 0;
sayAge() {
console.log(this.age);
}
sayMarried() {
console.log(this.married);
}
}
abstract 키워드가 있는 속성과 메서드는 반드시 자손 클래스에서 구현해야 한다.