JSX
JSX
JSX는 XML 같은 모습의 내장형 문법이에요. TypeScript가 JSX를 어떻게 지원하는지, 어떤 컴파일러 옵션이 관여하는지, 그리고 타입 검사가 어떤 원리로 동작하는지를 자세히 살펴볼게요.
출처: TypeScript 핸드북
JSX는 임베드할 수 있는 XML 같은 문법이에요. 유효한 JavaScript로 변환되는 것을 전제로 하지만, 그 변환의 의미(semantics)는 구현에 따라 달라집니다. JSX는 React 프레임워크와 함께 인기를 얻었고, 이후 다른 구현들도 등장했어요. TypeScript는 JSX를 JavaScript로 직접 임베드하고, 타입을 검사하고, 컴파일하는 것을 지원합니다.
기본 사용법 (Basic usage)
JSX를 사용하려면 두 가지를 해야 해요.
- 파일 이름을
.tsx확장자로 지정한다 jsx옵션을 켠다
TypeScript는 여러 JSX 모드를 제공해요: preserve, react (classic runtime), react-jsx (automatic runtime), react-jsxdev (automatic development runtime), 그리고 react-native입니다. preserve 모드는 JSX를 출력의 일부로 유지해서 다른 변환 단계(예: Babel)가 더 소비하도록 해요. 그리고 출력 파일 확장자는 .jsx가 됩니다. react 모드는 React.createElement를 생성하며, 사용 전에 JSX 변환을 거칠 필요가 없고, 출력 확장자는 .js예요. react-native 모드는 모든 JSX를 유지한다는 점에서 preserve와 동등하지만, 출력 확장자는 대신 .js입니다.
| Mode | Input | Output | Output File Extension |
|---|---|---|---|
preserve |
<div /> |
<div /> |
.jsx |
react |
<div /> |
React.createElement("div") |
.js |
react-native |
<div /> |
<div /> |
.js |
react-jsx |
<div /> |
_jsx("div", {}, void 0); |
.js |
react-jsxdev |
<div /> |
_jsxDEV("div", {}, void 0, false, {...}, this); |
.js |
이 모드는 jsx 커맨드 라인 플래그나, tsconfig.json 파일의 해당 옵션 jsx로 지정할 수 있어요.
*참고: react JSX emit을 대상으로 할 때 사용할 JSX 팩토리 함수는
jsxFactory옵션으로 지정할 수 있습니다 (기본값은React.createElement)
as 연산자
타입 단언(type assertion)을 어떻게 쓰는지 기억해 볼게요:
const foo = <Foo>bar;
이건 변수 bar가 타입 Foo를 가진다고 단언합니다. TypeScript는 타입 단언에도 꺾쇠 괄호를 사용하므로, JSX 문법과 결합하면 특정 파싱 문제가 생겨요. 그래서 TypeScript는 .tsx 파일에서 꺾쇠 괄호 타입 단언을 허용하지 않습니다.
위 문법은 .tsx 파일에서 쓸 수 없으니, 대체 타입 단언 연산자 as를 사용해야 해요. as 연산자로 예시를 쉽게 다시 쓸 수 있습니다.
const foo = bar as Foo;
as 연산자는 .ts와 .tsx 파일 모두에서 사용 가능하고, 동작은 꺾쇠 괄호 타입 단언 방식과 동일합니다.
타입 검사 (Type Checking)
JSX의 타입 검사를 이해하려면 먼저 내장 요소(intrinsic elements)와 값 기반 요소(value-based elements)의 차이를 이해해야 해요. JSX 표현식 <expr />가 주어졌을 때, expr은 환경에 내장된 무언가(예: DOM 환경의 div나 span)를 가리킬 수도 있고, 여러분이 만든 커스텀 컴포넌트를 가리킬 수도 있습니다. 이건 두 가지 이유로 중요해요:
- React에서 내장 요소는 문자열로 생성돼요 (
React.createElement("div")). 반면 여러분이 만든 컴포넌트는 그렇지 않죠 (React.createElement(MyComponent)). - JSX 요소에 전달되는 속성(attributes)들의 타입은 다르게 조회되어야 해요. 내장 요소의 속성은 내재적으로 알 수 있어야 하는 반면, 컴포넌트는 아마 자신만의 속성 집합을 지정하고 싶을 거예요.
TypeScript는 이를 구분하기 위해 React가 사용하는 것과 동일한 규칙을 사용해요. 내장 요소는 항상 소문자로 시작하고, 값 기반 요소는 항상 대문자로 시작합니다.
JSX 네임스페이스
TypeScript의 JSX는 JSX 네임스페이스로 타입이 지정돼요. JSX 네임스페이스는 jsx 컴파일러 옵션에 따라 여러 위치에서 정의될 수 있습니다.
jsx 옵션 preserve, react, react-native는 클래식 런타임을 위한 타입 정의를 사용해요. 즉 jsxFactory 컴파일러 옵션으로 결정되는 변수가 스코프 내에 있어야 합니다. JSX 네임스페이스는 JSX 팩토리의 최상위 식별자에 지정되어야 해요. 예를 들어 React는 기본 팩토리 React.createElement를 사용합니다. 그 뜻은 JSX 네임스페이스가 React.JSX로 정의되어야 한다는 거예요.
export function createElement(): any;
export namespace JSX {
// …
}
그리고 사용자는 항상 React를 React로 import해야 합니다.
import * as React from 'react';
Preact는 JSX 팩토리 h를 사용해요. 그 뜻은 타입이 h.JSX로 정의되어야 한다는 것입니다.
export function h(props: any): any;
export namespace h.JSX {
// …
}
사용자는 이름 있는 import(named import)로 h를 가져와야 해요.
import { h } from 'preact';
jsx 옵션 react-jsx와 react-jsxdev의 경우, JSX 네임스페이스는 일치하는 엔트리 포인트에서 export되어야 합니다. react-jsx의 경우 이건 ${jsxImportSource}/jsx-runtime이에요. react-jsxdev의 경우 ${jsxImportSource}/jsx-dev-runtime입니다. 이들은 파일 확장자를 사용하지 않으므로, ESM 사용자를 지원하려면 package.json의 exports 필드 매핑을 사용해야 해요.
{
"exports": {
"./jsx-runtime": "./jsx-runtime.js",
"./jsx-dev-runtime": "./jsx-dev-runtime.js",
}
}
그리고 jsx-runtime.d.ts와 jsx-dev-runtime.d.ts에서:
export namespace JSX {
// …
}
JSX 네임스페이스를 export하는 것이 타입 검사에는 충분하지만, 프로덕션 런타임은 런타임에 jsx, jsxs, Fragment export를 필요로 하고, 개발 런타임은 jsxDEV와 Fragment를 필요로 한다는 점에 주의하세요. 이상적으로는 그것들에 대한 타입도 추가하는 게 좋아요.
JSX 네임스페이스가 적절한 위치에 없으면, 클래식 런타임과 자동 런타임 모두 전역 JSX 네임스페이스로 폴백합니다.
내장 요소 (Intrinsic elements)
내장 요소는 특별한 인터페이스 JSX.IntrinsicElements에서 조회돼요. 기본적으로 이 인터페이스가 지정되지 않으면 무엇이든 허용되고, 내장 요소는 타입 검사되지 않습니다. 하지만 이 인터페이스가 존재한다면, 내장 요소의 이름은 JSX.IntrinsicElements 인터페이스의 속성으로 조회됩니다. 예를 들어:
declare namespace JSX {
interface IntrinsicElements {
foo: any;
}
}
<foo />; // ok
<bar />; // error
위 예시에서 <foo />는 잘 동작하지만, <bar />는 JSX.IntrinsicElements에 지정되지 않았으므로 에러가 됩니다.
참고:
JSX.IntrinsicElements에 다음과 같이 아무 문자열 인덱서(catch-all)를 지정할 수도 있습니다:
declare namespace JSX {
interface IntrinsicElements {
[elemName: string]: any;
}
}
값 기반 요소 (Value-based elements)
값 기반 요소는 스코프 내에 있는 식별자로 단순히 조회됩니다.
import MyComponent from "./myComponent";
<MyComponent />; // ok
<SomeOtherComponent />; // error
값 기반 요소를 정의하는 방법에는 두 가지가 있어요:
- 함수 컴포넌트 (Function Component, FC)
- 클래스 컴포넌트 (Class Component)
이 두 종류의 값 기반 요소는 JSX 표현식에서는 서로 구분할 수 없기 때문에, 먼저 TS는 그 표현식을 함수 컴포넌트로 오버로드 해석(overload resolution)을 통해 해석하려고 해요. 성공하면 TS는 그 표현식을 선언으로 해석을 마칩니다. 함수 컴포넌트로 해석에 실패하면, TS는 그 값을 클래스 컴포넌트로 해석하려고 시도해요. 그것도 실패하면 TS는 에러를 보고합니다.
함수 컴포넌트 (Function Component)
이름이 암시하듯, 컴포넌트는 첫 번째 인자가 props 객체인 JavaScript 함수로 정의됩니다. TS는 그 반환 타입이 JSX.Element에 할당 가능해야 한다고 강제해요.
interface FooProp {
name: string;
X: number;
Y: number;
}
declare function AnotherComponent(prop: { name: string });
function ComponentFoo(prop: FooProp) {
return <AnotherComponent name={prop.name} />;
}
const Button = (prop: { value: string }, context: { color: string }) => (
<button />
);
함수 컴포넌트는 단순한 JavaScript 함수이므로, 여기서도 함수 오버로드를 사용할 수 있어요:
// @noErrors
declare module JSX {
interface Element {}
interface IntrinsicElements {
[s: string]: any;
}
}
// ---cut---
interface ClickableProps {
children: JSX.Element[] | JSX.Element;
}
interface HomeProps extends ClickableProps {
home: JSX.Element;
}
interface SideProps extends ClickableProps {
side: JSX.Element | string;
}
function MainButton(prop: HomeProps): JSX.Element;
function MainButton(prop: SideProps): JSX.Element;
function MainButton(prop: ClickableProps): JSX.Element {
// ...
}
참고: 함수 컴포넌트는 예전에 Stateless Function Components(SFC)로 불렸어요. 최근 버전의 react에서 함수 컴포넌트를 더 이상 stateless로 볼 수 없게 되면서,
SFC타입과 그 별칭StatelessComponent는 deprecated(폐지)되었습니다.
클래스 컴포넌트 (Class Component)
클래스 컴포넌트의 타입을 정의하는 것도 가능해요. 다만 그러려면 두 개의 새 용어를 이해하는 게 좋습니다: 요소 클래스 타입(element class type) 과 요소 인스턴스 타입(element instance type) 이에요.
<Expr />가 주어졌을 때, _요소 클래스 타입_은 Expr의 타입입니다. 위 예시에서 MyComponent가 ES6 클래스였다면 클래스 타입은 그 클래스의 생성자와 정적 멤버가 될 거예요. MyComponent가 팩토리 함수였다면, 클래스 타입은 그 함수가 됩니다.
클래스 타입이 확립되면, 인스턴스 타입은 클래스 타입의 construct 또는 call 시그니처(있는 쪽)의 반환 타입들의 유니온으로 결정됩니다. 다시 말해, ES6 클래스의 경우 인스턴스 타입은 그 클래스 인스턴스의 타입이고, 팩토리 함수의 경우 함수가 반환하는 값의 타입이 됩니다.
class MyComponent {
render() {}
}
// use a construct signature
const myComponent = new MyComponent();
// element class type => MyComponent
// element instance type => { render: () => void }
function MyFactoryFunction() {
return {
render: () => {},
};
}
// use a call signature
const myComponent = MyFactoryFunction();
// element class type => MyFactoryFunction
// element instance type => { render: () => void }
요소 인스턴스 타입이 흥미로운 이유는, 그것이 JSX.ElementClass에 할당 가능해야 하며, 그렇지 않으면 에러가 되기 때문이에요. 기본적으로 JSX.ElementClass는 {}이지만, 적절한 인터페이스를 따르는 타입으로만 JSX 사용을 제한하도록 이를 확장(augment)할 수 있습니다.
declare namespace JSX {
interface ElementClass {
render: any;
}
}
class MyComponent {
render() {}
}
function MyFactoryFunction() {
return { render: () => {} };
}
<MyComponent />; // ok
<MyFactoryFunction />; // ok
class NotAValidComponent {}
function NotAValidFactoryFunction() {
return {};
}
<NotAValidComponent />; // error
<NotAValidFactoryFunction />; // error
속성 타입 검사 (Attribute type checking)
속성 타입 검사의 첫 단계는 요소 속성 타입(element attributes type) 을 결정하는 것입니다. 이는 내장 요소와 값 기반 요소 사이에서 조금 다릅니다.
내장 요소의 경우, JSX.IntrinsicElements의 속성 타입입니다.
declare namespace JSX {
interface IntrinsicElements {
foo: { bar?: boolean };
}
}
// element attributes type for 'foo' is '{bar?: boolean}'
<foo bar />;
값 기반 요소의 경우, 좀 더 복잡해요. 이전에 결정된 요소 인스턴스 타입 의 속성 타입에 의해 결정됩니다. 어떤 속성을 사용할지는 JSX.ElementAttributesProperty가 결정해요. 이 속성은 단일 속성으로 선언되어야 하고, 그 속성의 이름이 사용됩니다. TypeScript 2.8부터, JSX.ElementAttributesProperty가 제공되지 않으면, 클래스 요소의 생성자나 함수 컴포넌트의 call의 첫 번째 인자 타입이 대신 사용됩니다.
declare namespace JSX {
interface ElementAttributesProperty {
props; // specify the property name to use
}
}
class MyComponent {
// specify the property on the element instance type
props: {
foo?: string;
};
}
// element attributes type for 'MyComponent' is '{foo?: string}'
<MyComponent foo="bar" />;
요소 속성 타입은 JSX의 속성을 타입 검사하는 데 사용됩니다. 선택적(optional) 속성과 필수(required) 속성이 모두 지원돼요.
declare namespace JSX {
interface IntrinsicElements {
foo: { requiredProp: string; optionalProp?: number };
}
}
<foo requiredProp="bar" />; // ok
<foo requiredProp="bar" optionalProp={0} />; // ok
<foo />; // error, requiredProp is missing
<foo requiredProp={0} />; // error, requiredProp should be a string
<foo requiredProp="bar" unknownProp />; // error, unknownProp does not exist
<foo requiredProp="bar" some-unknown-prop />; // ok, because 'some-unknown-prop' is not a valid identifier
참고: 속성 이름이 유효한 JS 식별자가 아닐 때(예:
data-*속성), 그것이 요소 속성 타입에서 찾을 수 없어도 에러로 간주되지 않습니다.
추가로 JSX.IntrinsicAttributes 인터페이스는 JSX 프레임워크가 사용하는, 컴포넌트의 props나 인자에는 보통 사용되지 않는 추가 속성을 지정하는 데 쓰일 수 있어요. 예를 들어 React의 key 같은 것이죠. 더 특수하게는, 제너릭 JSX.IntrinsicClassAttributes<T> 타입으로 클래스 컴포넌트에만 (함수 컴포넌트는 제외하고) 동일한 종류의 추가 속성을 지정할 수도 있습니다. 이 타입에서 제너릭 파라미터는 클래스 인스턴스 타입에 대응합니다. React에서는 Ref<T> 타입의 ref 속성을 허용하는 데 사용됩니다. 일반적으로 이 인터페이스들의 모든 속성은 선택적이어야 해요. 여러분의 JSX 프레임워크 사용자가 모든 태그에 어떤 속성을 반드시 제공하길 의도하지 않는 한 말이죠.
전개 연산자(spread operator)도 동작해요:
const props = { requiredProp: "bar" };
<foo {...props} />; // ok
const badProps = {};
<foo {...badProps} />; // error
Children 타입 검사 (Children Type Checking)
TypeScript 2.3에서 TS는 children 의 타입 검사를 도입했어요. children 은 요소 속성 타입 의 특별한 속성으로, 자식 JSX 표현식들이 속성에 삽입되는 것으로 취급됩니다. TS가 JSX.ElementAttributesProperty로 props 의 이름을 결정하는 것과 비슷하게, TS는 JSX.ElementChildrenAttribute를 사용해 그 props 안의 children 이름을 결정합니다. JSX.ElementChildrenAttribute는 단일 속성으로 선언되어야 해요.
declare namespace JSX {
interface ElementChildrenAttribute {
children: {}; // specify children name to use
}
}
<div>
<h1>Hello</h1>
</div>;
<div>
<h1>Hello</h1>
World
</div>;
const CustomComp = (props) => <div>{props.children}</div>
<CustomComp>
<div>Hello World</div>
{"This is just a JS expression..." + 1000}
</CustomComp>
children 의 타입을 다른 속성처럼 지정할 수 있어요. 그러면 React 타입 정의를 쓰는 경우 그 기본 타입을 덮어씁니다.
interface PropsType {
children: JSX.Element
name: string
}
class Component extends React.Component<PropsType, {}> {
render() {
return (
<h2>
{this.props.children}
</h2>
)
}
}
// OK
<Component name="foo">
<h1>Hello World</h1>
</Component>
// Error: children is of type JSX.Element not array of JSX.Element
<Component name="bar">
<h1>Hello World</h1>
<h2>Hello World</h2>
</Component>
// Error: children is of type JSX.Element not array of JSX.Element or string.
<Component name="baz">
<h1>Hello</h1>
World
</Component>
JSX 결과 타입 (The JSX result type)
기본적으로 JSX 표현식의 결과는 any로 타입 되요. JSX.Element 인터페이스를 지정해서 타입을 커스터마이즈할 수 있어요. 다만 이 인터페이스로부터 요소, 속성, children의 타입 정보를 얻는 것은 불가능합니다. 그것은 블랙박스(black box)예요.
JSX 함수 반환 타입 (The JSX function return type)
기본적으로 함수 컴포넌트는 JSX.Element | null을 반환해야 해요. 하지만 이게 항상 런타임 동작을 나타내는 것은 아닙니다. TypeScript 5.1부터, JSX.ElementType을 지정해서 유효한 JSX 컴포넌트 타입이 무엇인지 덮어쓸 수 있어요. 이게 props가 무엇인지는 정의하지 않는다는 점에 주의하세요. props의 타입은 항상 전달된 컴포넌트의 첫 번째 인자로 정의됩니다. 기본값은 대략 다음과 같아요:
namespace JSX {
export type ElementType =
// All the valid lowercase tags
| keyof IntrinsicElements
// Function components
| (props: any) => Element
// Class components
| new (props: any) => ElementClass;
export interface IntrinsicAttributes extends /*...*/ {}
export type Element = /*...*/;
export type ElementClass = /*...*/;
}
표현식 임베딩 (Embedding Expressions)
JSX는 태그 사이에 중괄호({ })로 표현식을 감싸서 임베드하는 것을 허용해요.
const a = (
<div>
{["foo", "bar"].map((i) => (
<span>{i / 2}</span>
))}
</div>
);
위 코드는 문자열을 숫자로 나눌 수 없으므로 에러가 됩니다. preserve 옵션을 쓸 때 출력은 다음과 같아요:
const a = (
<div>
{["foo", "bar"].map(function (i) {
return <span>{i / 2}</span>;
})}
</div>
);
React 통합 (React integration)
React와 함께 JSX를 사용하려면 React 타입 정의를 사용해야 해요. 이 타입 정의들은 JSX 네임스페이스를 React에서 사용하기에 적절하게 정의합니다.
/// <reference path="react.d.ts" />
interface Props {
foo: string;
}
class MyComponent extends React.Component<Props, {}> {
render() {
return <span>{this.props.foo}</span>;
}
}
<MyComponent foo="bar" />; // ok
<MyComponent foo={0} />; // error
JSX 설정하기 (Configuring JSX)
여러 컴파일러 플래그로 JSX를 커스터마이즈할 수 있어요. 이들은 컴파일러 플래그로도, 파일별 인라인 프라그마로도 동작합니다. 자세한 내용은 tsconfig reference 페이지를 확인하세요: