함수형 프로그래머를 위한 TypeScript
함수형 프로그래머를 위한 TypeScript (TypeScript for Functional Programmers)
TypeScript는 전통적인 객체지향 타입을 JavaScript에 가져오려는 시도로 시작했어요. 그래서 Microsoft의 프로그래머들이 전통적인 객체지향 프로그램을 웹으로 가져올 수 있게 하려고 했던 거죠. 하지만 발전하면서 TypeScript의 타입 시스템은 네이티브 JavaScript 개발자들이 쓰는 코드를 모델링하는 방향으로 진화했어요. 그 결과물은 강력하고, 흥미롭고, 또 지저분하기도 해요. 이 소개는 TypeScript를 배우고 싶은 현직 Haskell 또는 ML 프로그래머를 위해 설계됐어요. TypeScript의 타입 시스템이 Haskell의 타입 시스템과 어떻게 다른지, 그리고 TypeScript가 JavaScript 코드를 모델링하면서 생겨난 고유한 타입 시스템의 특징을 서술할게요.
출처: TypeScript 핸드북
이 소개는 객체지향 프로그래밍을 다루지 않아요. 실제로 TypeScript의 객체지향 프로그램은 OO 기능을 가진 다른 인기 언어들과 비슷해요.
사전 지식 (Prerequisites)
이 소개에서는 다음을 알고 있다고 가정할게요:
- JavaScript 프로그래밍 방법, 좋은 부분만 골라서요.
- C 계열 언어의 타입 문법.
JavaScript의 좋은 부분을 배울 필요가 있다면 JavaScript: The Good Parts를 읽어보세요. 뮤터빌리티가 많고 그 외에는 별로 없는, 값 호출 방식의 어휘적 스코프 언어로 프로그램을 작성하는 방법을 안다면 이 책을 건너뛰어도 될 거예요. R⁴RS Scheme이 좋은 예시에요.
C++ 프로그래밍 언어는 C 스타일 타입 문법을 배우기 좋은 자료예요. 다만 C++와 달리 TypeScript는 string x가 아니라 x: string처럼 후위(postfix) 타입을 사용해요.
Haskell에 없는 개념들
내장 타입 (Built-in types)
JavaScript는 8개의 내장 타입을 정의해요:
| 타입 | 설명 |
|---|---|
Number |
배정밀도 IEEE 754 부동소수점. |
String |
불변 UTF-16 문자열. |
BigInt |
임의 정밀도 형식의 정수. |
Boolean |
true와 false. |
Symbol |
보통 키로 쓰이는 고유한 값. |
Null |
유닛 타입과 동일. |
Undefined |
유닛 타입과도 동일. |
Object |
레코드와 비슷. |
TypeScript에는 내장 타입에 대응하는 원시 타입이 있어요:
numberstringbigintbooleansymbolnullundefinedobject
그 외 중요한 TypeScript 타입
| 타입 | 설명 |
|---|---|
unknown |
top 타입. |
never |
bottom 타입. |
| 객체 리터럴 | 예: { property: Type } |
void |
문서화된 반환 값이 없는 함수용. |
T[] |
가변 배열, Array<T>라고도 씀. |
[T, T] |
튜플, 고정 길이지만 가변. |
(t: T) => U |
함수. |
참고:
-
함수 문법에 매개변수 이름이 포함돼요. 이건 익숙해지기 꽤 어려워요!
let fst: (a: any, b: any) => any = (a, b) => a; // or more precisely: let fst: <T, U>(a: T, b: U) => T = (a, b) => a; -
객체 리터럴 타입 문법은 객체 리터럴 값 문법을 밀접하게 반영해요:
let o: { n: number; xs: object[] } = { n: 1, xs: [] }; -
[T, T]는T[]의 서브타입이에요. 튜플이 리스트와 관련이 없는 Haskell과는 다른 점이죠.
박싱된 타입 (Boxed types)
JavaScript에는 프로그래머들이 그 타입과 연관지어 생각하는 메서드를 담고 있는, 원시 타입의 박싱된(boxed) 대응물이 있어요. TypeScript는 예를 들어 원시 타입 number와 박싱된 타입 Number의 차이로 이를 반영해요. 박싱된 타입은 그들의 메서드가 원시 값을 반환하기 때문에 거의 필요하지 않아요.
(1).toExponential();
// equivalent to
Number.prototype.toExponential.call(1);
숫자 리터럴에 메서드를 호출하려면 파서를 돕기 위해 괄호로 감싸야 한다는 점에 주의하세요.
점진적 타이핑 (Gradual typing)
TypeScript는 표현식의 타입이 무엇이어야 하는지 알 수 없을 때마다 any 타입을 사용해요. Dynamic과 비교하면, any를 타입이라고 부르는 건 과장이에요. 그냥 나타나는 곳마다 타입 검사기를 꺼 버릴 뿐이죠. 예를 들어 any[]에 어떤 값이든 별다른 표시 없이 push할 수 있어요:
// with "noImplicitAny": false in tsconfig.json, anys: any[]
const anys = [];
anys.push(1);
anys.push("oh no");
anys.push({ anything: "goes" });
그리고 any 타입의 표현식은 어디서든 사용할 수 있어요:
anys.map(anys[1]); // oh no, "oh no" is not a function
any는 전염성이 있기도 해요. any 타입의 표현식으로 변수를 초기화하면 그 변수도 any 타입이 돼요.
let sepsis = anys[0] + anys[1]; // this could mean anything
TypeScript가 any를 만들 때 에러를 얻으려면 tsconfig.json에서 "noImplicitAny": true 또는 "strict": true를 사용하세요.
구조적 타이핑 (Structural typing)
구조적 타이핑은 대부분의 함수형 프로그래머에게 익숙한 개념이에요. 다만 Haskell과 대부분의 ML은 구조적 타입이 아니에요. 그 기본 형태는 꽤 단순해요:
// @strict: false
let o = { x: "hi", extra: 1 }; // ok
let o2: { x: string } = o; // ok
여기서 객체 리터럴 { x: "hi", extra: 1 }은 일치하는 리터럴 타입 { x: string, extra: number }를 가져요. 그 타입은 필요한 모든 프로퍼티를 갖고 있고 그 프로퍼티들이 할당 가능한 타입을 가지므로 { x: string }에 할당 가능해요. 여분의 프로퍼티는 할당을 막지 않아요. 그냥 그것을 { x: string }의 서브타입으로 만들 뿐이죠.
명명된 타입은 타입에 이름을 줄 뿐이에요. 할당 가능성 측면에서 아래의 타입 별칭 One과 인터페이스 타입 Two 사이에는 아무 차이가 없어요. 둘 다 p: string 프로퍼티를 가져요. (다만 타입 별칭은 재귀 정의와 타입 매개변수에 관해서는 인터페이스와 다르게 동작해요.)
// @errors: 2322
type One = { p: string };
interface Two {
p: string;
}
class Three {
p = "Hello";
}
let x: One = { p: "hi" };
let two: Two = x;
two = new Three();
유니언 (Unions)
TypeScript에서 유니언 타입은 태그가 없어요. 다른 말로, Haskell의 data 같은 판별된 유니언(discriminated union)이 아니에요. 하지만 내장 태그나 다른 프로퍼티를 사용해 유니언의 타입들을 판별할 수 있는 경우가 많아요.
function start(
arg: string | string[] | (() => string) | { s: string }
): string {
// this is super common in JavaScript
if (typeof arg === "string") {
return commonCase(arg);
} else if (Array.isArray(arg)) {
return arg.map(commonCase).join(",");
} else if (typeof arg === "function") {
return commonCase(arg());
} else {
return commonCase(arg.s);
}
function commonCase(s: string): string {
// finally, just convert a string to another string
return s;
}
}
string, Array, Function은 내장 타입 조건부(predicate)를 가져서, 편리하게 객체 타입은 else 분기에 남겨 둬요. 하지만 런타임에 구별하기 어려운 유니언을 생성하는 것도 가능해요. 새 코드에서는 판별된 유니언만 만드는 게 가장 좋아요.
다음 타입들은 내장 조건부를 가져요:
| 타입 | 조건부 (Predicate) |
|---|---|
| string | typeof s === "string" |
| number | typeof n === "number" |
| bigint | typeof m === "bigint" |
| boolean | typeof b === "boolean" |
| symbol | typeof g === "symbol" |
| undefined | typeof undefined === "undefined" |
| function | typeof f === "function" |
| array | Array.isArray(a) |
| object | typeof o === "object" |
함수와 배열은 런타임에 객체이지만, 고유한 조건부를 가진다는 점에 주의하세요.
교집합 (Intersections)
유니언 외에도 TypeScript는 교집합도 가져요:
type Combined = { a: number } & { b: string };
type Conflicting = { a: number } & { a: string };
Combined는 a와 b라는 두 프로퍼티를 가져요. 마치 하나의 객체 리터럴 타입으로 쓴 것처럼요. 교집합과 유니언은 충돌 시 재귀적이므로 Conflicting.a: number & string이 돼요.
유닛 타입 (Unit types)
유닛 타입은 정확히 하나의 원시 값을 포함하는 원시 타입의 서브타입이에요. 예를 들어 문자열 "foo"는 타입 "foo"를 가져요. JavaScript에는 내장 enum이 없으므로, 잘 알려진 문자열 집합을 쓰는 게 흔해요. 문자열 리터럴 타입의 유니언은 TypeScript가 이 패턴을 타이핑할 수 있게 해줘요:
declare function pad(s: string, n: number, direction: "left" | "right"): string;
pad("hi", 10, "left");
필요할 때 컴파일러는 유닛 타입을 원시 타입으로 넓히며(widen) — 슈퍼타입으로 변환하며 — "foo"를 string으로 바꿔요. 이는 뮤터빌리티를 사용할 때 일어나는데, 가변 변수의 특정 용도를 방해할 수 있어요:
// @errors: 2345
declare function pad(s: string, n: number, direction: "left" | "right"): string;
// ---cut---
let s = "right";
pad("hi", 10, s); // error: 'string' is not assignable to '"left" | "right"'
에러가 나는 과정은 이래요:
"right": "right""right"가 가변 변수에 할당되면서string으로 넓혀지므로s: string.string은"left" | "right"에 할당 불가.
이 문제는 s에 타입 주석을 달아 해결할 수 있지만, 그러면 반대로 "left" | "right" 타입이 아닌 변수를 s에 할당할 수 없게 돼요.
declare function pad(s: string, n: number, direction: "left" | "right"): string;
// ---cut---
let s: "left" | "right" = "right";
pad("hi", 10, s);
Haskell과 비슷한 개념들
문맥적 타이핑 (Contextual typing)
TypeScript에는 변수 선언처럼 타입을 추론할 수 있는 명백한 곳이 몇 군데 있어요:
let s = "I'm a string!";
하지만 다른 C 문법 언어를 다뤄봤다면 예상하지 못할 곳에서도 타입을 추론해요:
declare function map<T, U>(f: (t: T) => U, ts: T[]): U[];
let sns = map((n) => n.toString(), [1, 2, 3]);
여기서 T와 U가 호출 전에 추론되지 않았음에도, 이 예시의 n: number도 그래요. 실제로 [1,2,3]이 T=number를 추론하는 데 사용된 뒤, n => n.toString()의 반환 타입이 U=string을 추론하는 데 사용돼서 sns가 string[] 타입을 갖게 돼요.
추론은 어떤 순서로든 동작하지만, 인텔리센스는 왼쪽에서 오른쪽으로만 동작해요. 그래서 TypeScript는 map을 배열을 먼저 선언하는 쪽을 선호해요:
declare function map<T, U>(ts: T[], f: (t: T) => U): U[];
문맥적 타이핑은 객체 리터럴을 통해서도 재귀적으로 동작하고, 그렇지 않으면 string이나 number로 추론될 유닛 타입에도 동작해요. 그리고 문맥에서 반환 타입을 추론할 수도 있어요:
declare function run<T>(thunk: (t: T) => void): T;
let i: { inference: string } = run((o) => {
o.inference = "INSERT STATE HERE";
});
o의 타입이 { inference: string }으로 결정되는 이유는:
- 선언 초기화 구문은 선언의 타입
{ inference: string }에 의해 문맥적으로 타이핑돼요. - 호출의 반환 타입이 추론에 문맥적 타입을 사용하므로, 컴파일러는
T={ inference: string }이라고 추론해요. - 화살표 함수는 문맥적 타입을 사용해 매개변수를 타이핑하므로, 컴파일러는
o: { inference: string }을 부여해요.
그리고 이는 여러분이 입력하는 동안 이루어져서, o.라고 입력한 뒤 실제 프로그램에서 가질 다른 프로퍼티들과 함께 inference 프로퍼티에 대한 완성을 얻을 수 있어요. 종합하면, 이 기능은 TypeScript의 추론이 통합 타입 추론 엔진처럼 보이게 만들 수 있지만 사실은 그렇지 않아요.
타입 별칭 (Type aliases)
타입 별칭은 Haskell의 type처럼 그저 별칭일 뿐이에요. 컴파일러는 소스 코드에서 별칭이 사용된 곳마다 별칭 이름을 사용하려 시도하지만, 항상 성공하는 건 아니에요.
type Size = [number, number];
let x: Size = [101.1, 999.9];
newtype과 가장 가까운 동등물은 태그된 교집합(tagged intersection) 이에요:
type FString = string & { __compileTimeOnly: any };
FString은 보통 문자열과 똑같아요. 단지 컴파일러가 실제로는 존재하지 않는 __compileTimeOnly라는 프로퍼티가 있다고 생각할 뿐이에요. 이는 FString이 여전히 string에 할당될 수 있지만, 그 반대는 안 된다는 뜻이에요.
판별된 유니언 (Discriminated Unions)
data와 가장 가까운 동등물은 판별 프로퍼티를 가진 타입들의 유니언이에요. TypeScript에서는 보통 판별된 유니언이라고 불러요:
type Shape =
| { kind: "circle"; radius: number }
| { kind: "square"; x: number }
| { kind: "triangle"; x: number; y: number };
Haskell과 달리, 태그 또는 판별자는 각 객체 타입의 한 프로퍼티일 뿐이에요. 각 변형은 서로 다른 유닛 타입을 가진 동일한 프로퍼티를 가져요. 이것은 여전히 일반 유니언 타입이에요. 앞의 |는 유니언 타입 문법의 선택적 부분이에요. 보통 JavaScript 코드로 유니언의 멤버들을 판별할 수 있어요:
type Shape =
| { kind: "circle"; radius: number }
| { kind: "square"; x: number }
| { kind: "triangle"; x: number; y: number };
function area(s: Shape) {
if (s.kind === "circle") {
return Math.PI * s.radius * s.radius;
} else if (s.kind === "square") {
return s.x * s.x;
} else {
return (s.x * s.y) / 2;
}
}
area의 반환 타입이 number로 추론되는 건 TypeScript가 그 함수가 전적(total)임을 알기 때문이에요. 어떤 변형이 다루어지지 않으면 area의 반환 타입은 대신 number | undefined가 돼요.
또한 Haskell과 달리, 공통 프로퍼티는 어떤 유니언에서든 나타나므로 유니언의 여러 멤버를 유용하게 판별할 수 있어요:
type Shape =
| { kind: "circle"; radius: number }
| { kind: "square"; x: number }
| { kind: "triangle"; x: number; y: number };
// ---cut---
function height(s: Shape) {
if (s.kind === "circle") {
return 2 * s.radius;
} else {
// s.kind: "square" | "triangle"
return s.x;
}
}
타입 매개변수 (Type Parameters)
대부분의 C 계열 언어처럼 TypeScript는 타입 매개변수의 선언을 요구해요:
function liftArray<T>(t: T): Array<T> {
return [t];
}
대소문자 요구사항은 없지만, 타입 매개변수는 관례적으로 단일 대문자로 써요. 타입 매개변수는 타입으로 제약될 수도 있는데, 이는 타입 클래스 제약과 비슷하게 동작해요:
function firstish<T extends { length: number }>(t1: T, t2: T): T {
return t1.length > t2.length ? t1 : t2;
}
TypeScript는 보통 인자 타입을 바탕으로 호출에서 타입 인자를 추론할 수 있으므로, 타입 인자는 대개 필요하지 않아요.
TypeScript는 구조적이기 때문에 명목적 시스템만큼 타입 매개변수를 많이 필요로 하지 않아요. 구체적으로 말하면, 함수를 다형적으로 만드는 데 타입 매개변수가 필요하지 않아요. 타입 매개변수는 매개변수가 같은 타입이 되도록 제약하는 것처럼 타입 정보를 전파(propagate) 하는 데만 사용해야 해요:
function length<T extends ArrayLike<unknown>>(t: T): number {}
function length(t: ArrayLike<unknown>): number {}
첫 번째 length에서 T는 필요하지 않아요. 단 한 번만 참조되는 것에 주목하세요. 반환 값이나 다른 매개변수의 타입을 제약하는 데 사용되지 않으니까요.
고차 타입 (Higher-kinded types)
TypeScript에는 고차 타입이 없어서 다음은 유효하지 않아요:
function length<T extends ArrayLike<unknown>, U>(m: T<U>) {}
포인트프리 프로그래밍 (Point-free programming)
포인트프리 프로그래밍 — 커링과 함수 합성을 많이 사용하는 것 — 은 JavaScript에서 가능하지만 장황할 수 있어요. TypeScript에서는 포인트프리 프로그램에 대해 타입 추론이 실패하는 경우가 많아서, 값 매개변수 대신 타입 매개변수를 지정하게 돼요. 결과가 너무 장황해서 보통은 포인트프리 프로그래밍을 피하는 게 나아요.
모듈 시스템 (Module system)
JavaScript의 현대 모듈 문법은 Haskell과 조금 비슷한데, import나 export가 있는 파일은 암시적으로 모듈이라는 점이 달라요:
import { value, Type } from "npm-package";
import { other, Types } from "./local-package";
import * as prefix from "../lib/third-package";
commonjs 모듈 — node.js의 모듈 시스템으로 작성된 모듈 — 도 import할 수 있어요:
import f = require("single-function-package");
export 목록으로 내보낼 수도 있어요:
export { f };
function f() {
return g();
}
function g() {} // g is not exported
또는 각 export를 개별적으로 표시해서요:
export function f() { return g() }
function g() { }
후자의 스타일이 더 흔하지만 둘 다 허용돼요. 같은 파일 안에서 섞어 써도 돼요.
readonly와 const
JavaScript에서는 뮤터빌리티가 기본이에요. 다만 const 변수 선언으로 참조(reference) 가 불변이라고 선언하는 건 허용돼요. 참조 대상은 여전히 가변적이죠:
const a = [1, 2, 3];
a.push(102); // ):
a[0] = 101; // D:
TypeScript는 추가로 프로퍼티용 readonly 수정자를 가져요.
interface Rx {
readonly x: number;
}
let rx: Rx = { x: 1 };
rx.x = 12; // error
모든 프로퍼티를 readonly로 만드는 매핑된 타입 Readonly<T>도 함께 제공돼요:
interface X {
x: number;
}
let rx: Readonly<X> = { x: 1 };
rx.x = 12; // error
그리고 부수 효과가 있는 메서드를 제거하고 배열의 인덱스에 쓰는 것을 막는 특정 ReadonlyArray<T> 타입과, 이 타입을 위한 특별한 문법도 있어요:
let a: ReadonlyArray<number> = [1, 2, 3];
let b: readonly number[] = [1, 2, 3];
a.push(102); // error
b[0] = 101; // error
배열과 객체 리터럴에 동작하는 const-단언(as const)도 사용할 수 있어요:
let a = [1, 2, 3] as const;
a.push(102); // error
a[0] = 101; // error
하지만 이 옵션들 중 어느 것도 기본이 아니므로, TypeScript 코드에서 일관되게 사용되지는 않아요.
다음 단계 (Next Steps)
이 문서는 일상 코드에서 사용할 문법과 타입에 대한 높은 수준의 개요였어요. 여기서부터 다음을 해야 해요:
- 전체 핸드북을 처음부터 끝까지 읽기
- Playground 예시 탐색하기