본문으로 건너뛰기

i18n과 typescript

· 약 40분
Hyunmo Ahn

Intro

현재 참여하고 있는 프로젝트에서 typescript와 i18next을 사용하는데, i18n JSON 파일의 type check를 강하게 적용했던 경험을 정리하고자 한다.

About I18n

먼저 i18n을 어떤식으로 활용하고 있었는지 소개하자면 다음과 같다.

i18n은 같은 화면에서 여러 언어로 된 Text를 보여주기 위해 사용하는 것으로, 하나의 key, 여러개의 value를 가지고 언어에 맞게 보여주는 것을 의미한다.

const i18nJSON = {
'simple-example': 'This is example text',
'values-example': 'I need to show {0} value',
'line-break-example': 'Hello. \n I am FE developer'
} as const

첫번째로 위와 같은 Key-Value 형태의 객체가 존재한다. 객체는 JSON이 될 수도 있고, typescript의 객체가 될 수도 있다.

import tFunction from 'utils';

tFunction('simple-example') // This is example text
tFunction('values-example', [15]) // I need to show 15 value
tFunction('line-break-example') // Hello <br /> I am FE developer

두번째로 tFunction를 사용해서 i18n key를 집어 넣어서 key에 맞는 string을 가져오며, 케이스에 따라서는 values-example과 같이 변수를 넣어 Text별로 맞는 value를 포함한 string을 리턴하는 경우도 있다. 마지막 line-break-example에서는 \n 줄바꿈 문자를 인식하여 <br/> 태그로 변환해서 React에서 줄바꿈을 할 수 있도록 사용하고 있다.

주의

본 article은 i18n을 적용하기 위한 방법으로 tFunction이라는 함수를 가져다 쓴다. JS로직이 아닌, Type 레벨에서의 내용을 주로 다루기 때문에 실제 내부 로직이 어떻게 되는지는 다루지 않을 예정이다. i18next.t와 같은 함수와 같은 역할을 한다고 보면 될 것이다.

How about I18n return type

여기서, tFunction에서 return 된 값이 어떤 값으로 나오는지 알 수 있다. 그렇다면 각 return값의 type은 어떻게 될까?

import tFunction from 'utils';

tFunction('simple-example')
// This is example text
// string
tFunction('values-example', [15])
// I need to show 15 value
// string
tFunction('line-break-example')
// Hello <br /> I am FE developer
// ReactElement

L3, L6는 string 타입이라고 보아도 무방하다. 하지만 L9에서는 i18n text에 \n가 있기 때문에 ReactElement가 리턴된다.

노트

ReactElement로 리턴하지 않고, string을 리턴하면서 줄바꿈을 지원하기 위해서는 dangerouslySetInnerHTML를 사용해야한다. 하지만 여러 한계가 있으므로 string 대신에 ReactElement로 리턴한다.

만약 values-example의 변수로 15가 아니라 a tag와 같은 JSX를 넣는다면, values-example의 리턴 타입 또한 달라진다.

import tFunction from 'utils';

tFunction('values-example', [15])
// I need to show 15 value
// string

tFunction('values-example', [<a href="/about">more</a>])
// I need to show <a href="/about">more</a> value
// ReactElement

위와 같이 같은 values-example I18n key를 사용하더라도 values로 어떤 값이 오느냐에 따라서 타입이 달라져야한다. a tag 또한 <br/>과 마찬가지로 string이 아니라 ReactElement인 컴포넌트로 리턴해야 하기 때문이다.

What is matter?

자, i18n Text 타입이 상황에 따라 string, ReactElement로 달라진다는 것을 알았다. 그렇다면 어떤 것이 문제일까?

tFunction은 위에서 이야기 한 것과 같이 똑똑하게 타입을 추론해주지 않는다는 점이다.

여기서 type이 적절히 추론되지 않는다면 어떤 문제가 있을까?

<input 
type='input'
placeholder={tFunction('line-break-example')} // type error
/>

여러가지 경우가 있겠지만, 대표적으로는 HTML tag를 사용할 때 string으로 정의 된 attribute에 string이 아닌 i18n 값이 포함 될 수 있다. 이러면, placeholder에 xlt text 대신 [object Object]가 보이게 된다.

물론, placeholder에 a tag가 들어가거나 \n 같은 줄바꿈이 들어가는 케이스가 명백히 이상하다. 하지만 i18n key를 잘못 사용하는 경우도 있고, i18n text가 잘못 등록 된 경우도 있을 수 있다.

즉, typescript를 사용하는데 타입이 제대로 추론 되지 않는 다는 점에 있어서 위와 같은 문제점이 있고 또 다른 문제를 야기 할 수 있다.

이번 article에서는 i18n text의 format 별로 타입을 정의하는 방법에 대해서 이야기 해 볼 예정이다. i18n을 예로 들었지만 기본적으로는 typescript Template Literal Types과 관련된 이야기이다.

사전지식
  • typescript 4.1+ 에 대한 전반적인 지식
  • Template Literal Types에 대한 관심
  • i18n system 사용 경험 (optional)

Goal

i18n을 사용하면서 줄바꿈, 변수 등을 대응하기 위해서는 룰을 정해야한다. 줄을 바꾸고 싶을때는 \n을 집어넣는다. 상황에 따라 달라지는 값을 끼워넣고 싶다면 {}를 사용한다 와 같은 rule 말이다.

우리가 사용하고 있는 i18n rule에 대해서 먼저 설명을 하고, rule에 대해서 type을 어떻게 정할지 이야기 해 볼 예정이다.

I18n Rule

줄바꿈

i18n text에서 줄을 바꿔서 보여주고 싶을때는 줄을 바꿀 부분에서 \n를 넣어준다.

// i18n text json
{
"lineBreak": "Hello. \n I am FE developer"
}

// displayed text
Hello
I am FE developer

변수

i18n text에서 정적인 텍스트가 아니라 상황에 따라 다른 Text, 즉 개발 코드의 변수를 넣어주고 싶다면 {}를 감싸서 사용한다. 하나의 i18n text에 여러개의 변수를 넣을 수 있기 떄문에 {}안에 string을 0부터 시작하여 1씩 늘려간다.

// i18n text json
{
"oneValue": "This product is {0}.",
"unit": "${0}",
"twoValue": "This product is {0} and it will be delivered after {1} days"
}
import tFunction from 'utils';

tFunction('oneValue', [tFunction('unit', [1000])])
// This product is $1000.

tFunction('twoValue', [tFunction('unit', [500]), 3])
// This product is $500 and it will be delivered after 3 days

링크

만약 i18n text에 링크를 포함하고 싶을때도 변수와 같은 방식을 사용한다. {}를 감싸서 사용하고, link에 보여줄 text는 별도의 i18n text로 만들어서 사용한다.

// i18n text json
{
"link": "click",
"linkText": "{0} to show more information"
}
import tFunction from 'utils';

tFunction('linkText', [<a href="/more">{tFunction('link')}</a>])
// <a href="/more">click</a> to show more information

I18n Return Type

그렇다면 위 케이스들에 대해서 tFunction은 어떤 타입으로 return 되는 것이 이상적일까?

import tFunction from 'utils';

const i18nJson = {
"lineBreak": "Hello. \n I am FE developer",
"oneValue": "This product is {0}.",
"unit": "${0}",
"twoValue": "This product is {0} and it will be delivered after {1} days",
"link": "click",
"linkText": "{0} to show more information"
} as const;

tFunction('lineBreak')
// Hello <br /> I am FE developer
tFunction('oneValue', [tFunction('unit', [1000])])
// This product is $1000.
tFunction('twoValue', [tFunction('unit', [500]), 3])
// This product is $500 and it will be delivered after 3 days
tFunction('linkText', [<a href="/more">{tFunction('link')}</a>])
// <a href="/more">click</a> to show more information

L12은 <br />을 포함하고 있기 때문에 일반 string으로 추론되면 안된다. 따라서 i18n text에 \n 같은 줄바꿈이 있다면 ReactElement로 타입이 추론되어야 한다.

L14, L16는 values({0}, {1})가 들어가 있지만 추가로 들어간 value 역시 string 혹은 number이다. 따라서 string으로 추론되어도 된다.

마지막 L18는 L14, L16과는 조금 다르다. values로 a tag가 포함되었고 이를 그려주기 위해서는 ReactElement로 타입이 추론되어야한다.

Recap of Goal

따라서 목표를 정리하자면 다음과 같다.

  • i18n key에 따라서 tFunction의 return 타입이 추론되어야한다.
  • tFunction의 return 타입은 i18n key에 매칭되는 i18n text에서 가지고 있는 포맷에 따라 달라진다.
    • i18n text에서 \n 줄바꿈 키워드를 가지고 있다면 ReactElement로 추론한다.
    • i18n text에서 {} 변수 키워드를 가지고 있고, 변수로 string과 number 같은 값이 들어온다면 string으로 추론한다.
    • i18n text에서 {} 변수 키워드를 가지고 있고, 변수로 string과 number 가 아닌 값이 들어온다면 ReactElement으로 추론한다.
  • 추가로, 해당 i18n text가 {} 변수 키워드를 가지고 있는지 여부, 몇개를 가지고 있는지 여부에 따라 tFunciton의 2번째 parameter에 타입을 체크했으면 좋겠다.

Type 정의

자, 이제 우리는 위 조건에 맞추어서 tFunction의 return type을 채워 나갈 예정이다. 아래 코드 예시를 보자.

import React from 'react';

const i18nJson = {
"simple": "Hello World",
"lineBreak": "Hello. \n I am FE developer",
"oneValue": "This product is {0}.",
"unit": "${0}",
"twoValue": "This product is {0} and it will be delivered after {1} days",
"link": "click",
"linkText": "{0} to show more information"
} as const;

type I18nJson = typeof i18nJson;
type I18nKey = keyof I18nJson;

type TFunction = (key: I18nKey, values?: any) => string

const tFunction: TFunction = (key: I18nKey, values: any) => i18nJson[key];

const simple = tFunction('simple'); // string
const lineBreak = tFunction('lineBreak'); // string
const oneValue = tFunction('oneValue', [tFunction('unit', 500)]);
// string
const twoValue = tFunction('twoValue', [tFunction('unit', [500]), 3]);
// string
const linkText = tFunction('linkText', [<a href="/about">{tFunction('link')}</a>])
// string

playground

이전까지 예제로 사용했던 i18n text 예제들이 모두 i18nJson에 포함되어 있고, 이는 I18nJsonI18nKey로 각각의 타입으로 추론된다. 이는, TFunction 함수 타입을 정의하고, 이를 tFunction에 사용한다. 현재는 모두 string 타입으로 리턴하고 있지만, 우리의 목표는 이를 케이스에 따라 각각 다른 타입으로 추론되는 것을 목표로 한다. 즉, 현재는 L20-L27 모두 string으로 타입이 추론되지만 이 글의 마지막에는 각 key에 따른 text의 형식에 맞춰 string과 ReactElement로 타입이 나뉘어지는 것을 목표로 한다.

줄바꿈

먼저 줄바꿈, \n이 i18n text에 포함 될 경우 string 대신 ReactElement으로 리턴하는 방법에 대해서 알아본다. 그를 위해서는 Template Literal Type에 대한 이해가 필요하다.

Template Literal Type

Typescript의 문자열에는 string 타입이 있고, literal type이 있다. 그리고 더 나아가서 literal type을 조합해서 또 다른 타입을 만드는 Template Literal Type이 있다. 예제로 보면 다음과 같다.

let str1 = 'example' // string
const str2 = 'example' // 'example'

type StrPrefix = 'one' | 'two'
type StrPostfix = 'type' | 'sample'

type TemplateStr = `${StrPostfix}_${StrPostfix}`
// 'one_type' | 'two_type' | 'one_sample' | 'two_sample'

L1의 str1let으로 선언되어서 literal type으로 추론되지 않고 string 타입으로 추론된다. 왜냐하면 str1 변수는 다른 값으로 재할당이 가능하기 떄문이다. 반면, L2의 str2const로 선언되어 'example'인 literal type으로 추론된다.

L4-L5에서 선언된 StrPrefixStrPostfix를 합쳐 L7에서 TemplateStr는 4개의 literal type을 가지는 Union 타입으로 추론된다.

이 원리를 이용해서 i18n text에 있는 \n 텍스트를 확인하고 별도의 타입으로 추론할 예정이다.

줄바꿈 타입 정의

import React, { ReactElement } from 'react';

const i18nJson = {
"simple": "Hello World",
"lineBreak": "Hello. \n I am FE developer",
} as const;


type I18nJson = typeof i18nJson;
type I18nKey = keyof I18nJson;

type LineBreakFormat = `${string}\n${string}`;

type TFunction = <Key extends I18nKey,>(
key: Key,
values?: any,
) => I18nJson[Key] extends LineBreakFormat ? ReactElement : string;

const tFunction: TFunction = (key: I18nKey, values: any) =>
i18nJson[key] as any;

const simple = tFunction('simple'); // string
const lineBreak = tFunction('lineBreak'); // ReactElement

playground

L12를 보면 Template Literal String을 사용해서 LineBreakFormat 타입을 별도로 만든다. ${string}\n${string}라고 설정을 하면 string으로 감싸진 \n 문자열이 있을때는 LineBreakFormat으로 추론된다.

Function type inference

우리는 TFunction을 호출시 run-time에 들어오는 key의 literal type을 보고 return type을 추론해야한다. type 영역에서 I18nKey를 그대로 사용하면 key 입력 값에 따라 return type을 달리 할 수 없다. 여기서 Function type inference를 사용하여 아래와 같이 변경한다.

// Before
type TFunction = (key: I18nKey, values?: any) => string

// After
type TFunction = <Key extends I18nKey,>(key: Key, values?: any) => string;

위와 같은 방식으로 Key를 Generic으로 추출하고, TFunction을 호출할때 Generic을 명시적으로 선언하지 않으면, key로 들어오는 literal type을 사용할 수 있게 된다.

마지막으로 Key에 대한 I18n text가 LineBreakFormat인지 판단하여 ReactElement와 string을 분기하면 된다.

// Before
type TFunction = <Key extends I18nKey,>(key: Key, values?: any) => string;

// After
type TFunction = <Key extends I18nKey,>(key: Key, values?: any) =>
I18nJson[Key] extends LineBreakFormat ? ReactElement : string;

typescript에서 extends는 interface에서 사용할때는 상속의 의미이지만, Conditional Types로 활용할 수도 있다. 따라서, i18n Text, I18nJson[Key]LineBreakFormat이면 ReactElement로 리턴하고, 아니라면 string 타입으로 리턴한다.

여기서, I18nJson[Key]의 Key는 run-time에 사용되는 Literal Type 이다.

따라서 Key로 simple이 들어온다면 I18nJson['simple']에 대한 타입으로 추론하여 'Hello World' literal type을 사용한다.
Key로 lineBreak를 사용한다면 I18nJson['lineBreak']의 타입인 'Hello. \n I am FE developer'로 타입 비교를 진행하기 때문에 LineBreakFormat의 비교문이 의미를 가지게 된다.

  • I18nJson[Key]
    • Key: simple -> I18nJson['simple'] -> 'Hello World'
    • Key: lineBreak -> I18nJson['lineBreak'] -> 'Hello. \n I am FE developer'

그 덕분에 L17-L18에서 각각 string과 ReactElement 타입으로 다르게 리턴 타입이 진행된다.

변수로 들어오는 타입 확인하기

다음으로 확인해 볼 것은 TFunction의 2번째 parameter 배열에 number와 string만 있는 경우 string을 리턴하고, 이외의 ReactElement와 같은 값이 포함되어 있다면 ReactElement를 리턴하게 한다. 예제로 볼 코드는 다음과 같다.

import React from 'react';

const i18nJson = {
"oneValue": "This product is {0}.",
"twoValue": "This product is {0} and it will be delivered after {1} days",
} as const;

type I18nJson = typeof i18nJson;
type I18nKey = keyof I18nJson;

type TFunction = (key: I18nKey, values?: any) => string

const tFunction: TFunction = (key: I18nKey, values: any) =>
i18nJson[key] as any;

const oneValue = tFunction('oneValue', [100]);
const twoValue = tFunction('twoValue', [100, '200']);
const twoValueWithReactElement = tFunction(
'twoValue',
[100, <a>Hello</a>],
);

playground

기존 playground 코드에서 필요한 부분만 남겨두었다. 우리의 목표는 L15, L16은 두번째 인자로 number, string 로만 이루어진 배열이 들어오기 때문에 string으로 리턴하고, L17은 a tag를 변수로 넣기 때문에 ReactElement로 리턴하는 것이다.

해결방법은 간단하다. values가 (string | number)[] 타입인지 아닌지 판단하기만 하면 된다.

type TFunction = <Params extends any[],>(key: I18nKey, values?: Params) =>
Params extends (string | number)[]
? string : ReactElement

위 코드에서 values의 타입을 type inference를 사용하기 위해서 Generic type으로 추출하고 tFunction 호출시에 아무런 Generic을 사용하지 않는다. 그렇다면 우리는 Params의 타입으로 condition type을 사용할 수 있다.

Params가 number, string으로만 이루어진 배열이라면 자동으로 string으로 타입 추론이 되고, 다른 값(ReactElement)가 포함되어있다면 ReactElement로 리턴된다. playground에서 확인해보면 다음과 같이 추론된다.

const oneValue = tFunction('oneValue', [100]); // string
const twoValue = tFunction('twoValue', [100, 200]); // string
const twoValueWithReactElement = tFunction(
'twoValue',
[100, <a>Hello</a>],
); // ReactElement

이로써 변수에 number, string이 아닌 값이 들어가면 ReactElement로 추론할 방법을 찾았다.

그런데, 변수가 포함된 i18n을 사용한다면 다음과 같은 케이스도 있을 것 같다.

const i18nJson = {
"normal": "Hello World",
"oneValue": "This product is {0}.",
"twoValue": "This product is {0} and it will be delivered after {1} days",
} as const;

const oneValue = tFunction('oneValue');
// NEED type error!!
// 변수가 사용되어야 하지만 빈 값을 넣은 경우.
const twoValue = tFunction('twoValue', [100]);
// NEED type error!!
// 변수가 2개 들어가야하지만 1개만 들어간 경우.
const normal = tFunction('normal', [100]);
// NEED type error!!
// 변수가 없어야하는데 사용되고 있는 경우.

즉, 변수의 개수가 맞지 않거나 사용해야하는데 사용하지 않거나, 사용하지 않아야하는데 사용하는 경우에 대한 것이다. 이를 위해서는 조금 더 복잡한 타입 선언이 필요하게 되는데, 다음 문단을 이어서 보자.

변수 개수 확인하기

자, 이제 우리는 i18n text에 {}가 감싸진 문자열이 몇개가 되는지 찾아야한다. 이전에 줄바꿈에서 사용한대로 Template Literal Type을 사용할테지만, 그것만으로는 부족하다.

Recursive Conditional Types

우리는 {}로 감싸진 문자열을 찾아내고 다시 {}로 감싸진 문자열이 있는지 확인하는 것이 필요하다. 이를 위해 쓰이는게 Recursive 방법이다.

// This is not working!
type ValuesArray<I18nText extends string> =
I18nText extends `${string}{${string}}${string}`
? [any, ...ValuesArray<I18nText>] : [];

바로 위와 같은 방식으로 사용한다. (물론 위쪽 코드는 틀린 코드이다.)

ValuesArray를 선언하고 타입 추론을 위해 I18nText로 i18n text를 넘겨준다고 생각한다. 그리곤, I18nText에 {} 형식의 literal type이 존재한다면, 배열을 하나 만들고 다시 ValuesArray를 수행하는 것이다.

물론, 지금 이 코드는 정상동작하지 않을 것이다.
Recursive의 input, 즉 I18nText로 다시 온전한 I18nText를 넣고 있기 때문이다.
우리는 {}가 포함된 string 중 남은 오른쪽 string을 추출해서 recursive의 input으로 넣어야한다. 이를 예시로 설명하자면

  1. Hello {0} World {1} Thank {2} you
  2. World {1} Thank {2} you
  3. Thank {2} you

ValuesArray에 1번 text를 사용할때, 리턴 값은 [any, ...ValuesArray<?>]가 될 것이다. 그러고 다음 ValuesArray의 ? 에는 2번 text가 포함되어야한다. 다음 ValuesArray는 2번 text를 사용하고 리턴 값으로 [any, any, ...ValuesArray<?>]를 사용할 것이다. 마지막으로, ValuesArray는 3번 text를 input으로 사용해야하고 리턴 값으로는 [any, any, any]가 되어어야한다.

위와 같은 로직을 위해서는 infer를 사용한다.

Inferring Within Conditional Types

Infer를 사용하면 conditional type에서 사용하는 Generic을 가져다 리턴 타입으로 사용할 수 있다. 즉, 다음과 같이 된다.

type ValuesFormat<Suffix extends string> =
`${string}{${string}}${Suffix}`;
type ValuesArray<I18nText extends string> =
I18nText extends ValuesFormat<infer Rest>
? [any, ...ValuesArray<Rest>] : [];

ValuesFormat은 단순 {}를 포함하는 문자열이 아닌 Suffix라는 Generic을 } 뒤에 포함하는 타입이다. 이는 단독으로 쓰이면 의미가 없지만, 아래의 ValuesArray에서 infer와 쓰이면 의미가 달라진다. 이전 예제와 같이 ValuesArray{}가 있는지 확인을 하고 배열을 채운다. 그러고는 infer Rest를 사용하는데 Rest는 바로 ValuesFormat의 첫번째 Generic인 Suffix를 의미하게 되고 Rest를 다음 recursive에 사용하면 우리가 원하는 바가 완성된다.

type Example = 'Hello {0} World {1} Thank {2} you';

type ValuesFormat<Suffix extends string> =
`${string}{${string}}${Suffix}`;
type ValuesArray<I18nText extends string> =
I18nText extends ValuesFormat<infer Rest>
? [any, ...ValuesArray<Rest>] : [];

type Result = ValuesArray<Example> // [any, any, any];

playground

자, 이제 처음 예제에 ValuesArray를 적용시켜보자.

import React, { ReactElement } from 'react';

const i18nJson = {
"normal": "Hello World",
"oneValue": "This product is {0}.",
"twoValue": "This product is {0} and it will be delivered after {1} days",
} as const;

type I18nJson = typeof i18nJson;
type I18nKey = keyof I18nJson;

type ValuesType = string | number | ReactElement;
type ValuesFormat<Suffix extends string> =
`${string}{${string} }${Suffix}`;
type ValuesArray<I18nText extends string> =
I18nText extends ValuesFormat<infer Rest>
? [ValuesType, ...ValuesArray<Rest>] : [];

type TFunction = <
Key extends I18nKey,
Params extends ValuesArray<I18nJson[Key]>,
>(key: Key, values?: Params) => string

const tFunction: TFunction = (key: I18nKey, values: any) => i18nJson[key] as any;

const oneValue = tFunction('oneValue'); // Error?
const twoValue = tFunction('twoValue', [100]); // Type Error!
// Argument of type '[number]' is not assignable
// to parameter of type '[ValuesType, ValuesType]'.
const normal = tFunction('normal', [100]); // Type Error!
// Type 'number' is not assignable to type 'undefined'.

playground

L19의 TFunction에서 ParmasValuesArray를 적용시켰다. ValuesArray의 Generic으로는 I18nJson[Key]를 넣어서 Key를 타입추론을 통해 동적으로 받아 i18n text가 Generic으로 들어갈 수 있도록 한다. 그렇다면, Params는 각각 length가 고정된 ValuesType 배열로 추론될 것이다.

따라서, L28와 L31의 타입 체크에서 에러가 발생하게 되고 에러의 원인은 적절한 Array length를 맞추지 않았기 때문이다. 하지만 L26에서는 변수를 넣지 않았다고 에러를 발생시키지 않는데, 이는 TFunction의 values에 붙은 ? 키워드로 인하여 발생한다.

조금만 더 완성도를 높여보자.

Rest Parameters

함수에서 arguments의 개수를 동적으로 설정하기 위해서 사용하는건 Rest Parameters이다.

type TFunction = <
Key extends I18nKey,
Params extends ValuesArray<I18nJson[Key]>
>(key: Key, values?: Params) => string

const oneValue = tFunction('oneValue'); // Error?
const twoValue = tFunction('twoValue', [100]); // Type Error!

따라서, 위와 같은 예제에서 oneValue에서 사용한 tFunction?로 없어도 된다는 표시가 아니라 경우에 따라 values 개수가 달라지는 ...(Rest Parameters)를 사용하면된다.

type TFunction = <
Key extends I18nKey,
Params extends ValuesArrayResult<I18nJson[Key]>
>(
key: Key,
...values: Params extends [] ? [undefined?] : [Params]
) => string

const tFunction: TFunction = (key: I18nKey, ...values: any) => i18nJson[key] as any;

const oneValue = tFunction('oneValue'); // Type Error!

playground

L1에서 values...values로 변경하여 Params가 빈 배열이라면 [undefined?]를, 빈 배열이 아니라면 [Params]로 타입 설정을 한다. 그렇다면 values가 필요 없다면 optional로 설정되고, values가 필요하다면 갯수 길이의 array를 받는 방법이다.

여기서 Array의 item에 ?가 들어가있는 것은 Optional elements에 대한 내용으로, 각 element가 optional로 설정될 수 있는 문법이다.

결과

우리는 이전까지의 글에서 다음과 같은 타입 정의들을 구현해내었다.

  • 줄바꿈 키워드의 유무에 따라서 Return Type을 다르게 정의하기.
  • i18n 변수 parameter로 들어오는 runtime 값의 타입에 따라 Return Type을 다르게 정의하기.
  • i18n 변수 키워드의 개수에 따라 TFunction로 들어오는 i18n 변수 갯수 타입 정의하기.

위 3가지 정의들을 합치게 되었을때 우리는 처음 정의했던 i18n Rule에 대해서 타입 단계에서 체크가 가능하게 된다.

import React, { ReactElement } from 'react';

const i18nJson = {
"simple": "Hello World",
"lineBreak": "Hello. \n I am FE developer",
"oneValue": "This product is {0}.",
"unit": "${0}",
"twoValue": "This product is {0} and it will be delivered after {1} days",
"link": "click",
"linkText": "{0} to show more information"
} as const;

type I18nJson = typeof i18nJson;
type I18nKey = keyof I18nJson;

type LineBreakFormat = `${string}\n${string}`;

type TFunction = <Key extends I18nKey,>(
key: Key,
values?: any,
) => I18nJson[Key] extends LineBreakFormat
? ReactElement : string;

const tFunction: TFunction = (key: I18nKey, values: any) => i18nJson[key] as any;

const simple = tFunction('simple'); // string
const lineBreak = tFunction('lineBreak'); // ReactElement
const oneValue = tFunction('oneValue', [tFunction('unit', 500)]); // string
const twoValue = tFunction('twoValue', [tFunction('unit', [500]), 3]); // string
const linkText = tFunction('linkText', [<a href="/about">{tFunction('link')}</a>]) // string
Playground

위의 완성된 코드를 살펴보자. Draft는 최초 예시로 가져왔던 코드를 옮겨놓은 것이다. 여기서는 모든 TFunction이 string타입으로 리턴된다.

Line Break

Line Break 버전에서는 LineBreakFormat의 비교문을 넣음으로써 lineBreak의 타입이 ReactElement로 추론된다.

Values Type Check

Values Type Check에서는 Params를 추론하고 string | number 배열을 비교하면서 linkText가 ReactElement로 추론되게 코드를 추가했다.

Value Number Check

마지막 Value Number Check에서는 Params에 대한 타입을 구체화하고 values를 Rest Parameter로 적용함으로써 리턴 타입은 바꾸지 않았지만, tFunction의 2번째 인자로 들어가는 values의 갯수를 타입 체크 단계에서 체크하는 것으로 타입을 강하게 적용하였다.

Recap

우리는 이로써 i18n Text에서 줄바꿈과 변수가 있는지 여부와, 변수의 runtime 타입 및 변수의 개수까지 type check 단계에서 진행할 수 있도록 타입을 정의해보았다. 이 과정에서 우리는 다음과 같은 Typescript의 기능들을 활용하였다.

상당히 많은 수의 기능을 사용하였다. 평소에 복잡한 타입을 정의하지 않았더라면 모르고 있었던 기능도 있었을 것이며, 개념적으로는 이해하지만 실제로 사용해 볼 일이 없었던 기능들도 있었을 것이다.

우리는 i18n Text를 Runtime에 오류를 확인하는 것이 아닌 TypeCheck때 오류를 확인하기 위해서 복잡하지만 유용한 타입을 정의해서 사용하였고, 각각의 요구사항을 구현하기 위해서 필요한 typescript 지식들도 확인해보았다.

이 글을 보는 독자들은 각자 개발하고 있는 i18n을 적용하는 서비스에 유사한 타입을 구현할 수도 있고, 아니면 i18n은 사용하지 않지만 위 typescript 개념들을 이용하여 타입개선을 진행할 수도 있을 것 이다.

필자는 i18n에 type system을 적용하면서 느끼게 된 점은 생각보다 typescript의 활용범위가 넓으며, runtime에 확인하는 것보다 Type-Check때 확인할 수 있도록 시스템을 구축한다는 건 매우 유용하고 안전한 방법이라는 것이다.

이 글을 읽으며 신선한 자극 혹은 유용한 지식을 얻었길 바라며 만약 values 자리에 배열이 아니라 객체가 들어올 경우 어떤 방식이 될 수 있을지 부록으로 남긴다.

Appendix #1 변수를 Array 대신 Object로 받기

우리는 i18n text에 변수를 넣을때 Array 형식을 사용했다. 하지만 i18next interpolation 예제를 보아도 그렇고 변수를 넣는데 객체를 형식으로 사용하는 경우도 많을 것이다. 따라서, 배열이 아닌 객체를 주입하는 tFunction에 대한 타입정의를 부록으로 정리한다.

예제

import React, { ReactElement } from 'react';

const i18nJson = {
"oneValue": "This product is {value1}.",
"twoValue": "This product is {value1} and it will be delivered after {value2} days",
"link": "click",
"linkText": "{link} to show more information"
} as const;

type I18nJson = typeof i18nJson;
type I18nKey = keyof I18nJson;
type LineBreakFormat = `${string}\n${string}`;

type TFunction = <Key extends I18nKey,>(
key: Key,
values?: any,
) => I18nJson[Key] extends LineBreakFormat
? ReactElement : string;

const tFunction: TFunction = (key: I18nKey, values: any) => i18nJson[key] as any;

const oneValue = tFunction(
'oneValue',
{
value1: '$500'
},
); // string
const twoValue = tFunction(
'twoValue',
{
value1: '$500',
value2: 3,
},
); // string
const linkText = tFunction(
'linkText',
{
link: <a href="/about">{tFunction('link')}</a>,
},
) // string

playground

우리는 위와 같은 예제를 사용할 것이며, 변수로 넣는 형태는 배열에서 객체 타입으로 변경되었다. 이때 우리는 변수로 들어가는 객체의 형식과, 객체의 값을 보고 i18n text가 string으로 추론되기 적합한지 확인해 볼 것이다.

변수들의 key 획득하기

Text의 {} 포맷에 들어가있는 key들을 획득하는건 Recursiveinfer를 사용한다.

type ValueFormat<
Key extends string = string,
Suffix extends string = string,
> = `${string}{${Key}}${Suffix}`

type ValuesKeyArray<I18nText extends string> =
I18nText extends ValueFormat<infer Key, infer Suffix>
? [Key, ...ValuesKeyArray<Suffix>] : [];

type ValuesKeyUnion<I18nText extends string> =
ValuesKeyArray<I18nText>[number];

type result = ValuesKeyArray<'This product is {value1} and it will be delivered after {value2} days'>
// result: 'value1' | 'value2'

playground

여기서도 마찬가지로 ValueFormat을 통해서 key와 suffix 값을 infer할 수 있게 분리하고, ValuesKeyArray에서 {} 안의 값들을 Tuple로 분리한다. 그런 뒤 ValuesKeyUnion에서 union으로 만들어주는 작업을 하는 것이다. 이러면 변수로 들어가야할 key들 추출을 완료하게된다.

획득한 Key를 params로 들어갈 수 있도록 타입 설정하기

import React, { ReactElement } from 'react';

const i18nJson = {
"oneValue": "This product is {value1}.",
"twoValue": "This product is {value1} and it will be delivered after {value2} days",
"link": "click",
"linkText": "{link} to show more information"
} as const;

type I18nJson = typeof i18nJson;
type I18nKey = keyof I18nJson;
type LineBreakFormat = `${string}\n${string}`;
type ValuesType = string | number | ReactElement;

type ValueFormat<
Key extends string = string,
Suffix extends string = string,
> = `${string}{${Key}}${Suffix}`

type ValuesKeyArray<I18nText extends string> =
I18nText extends ValueFormat<infer Key, infer Suffix>
? [Key, ...ValuesKeyArray<Suffix>] : [];

type ValuesKeyUnion<I18nText extends string> =
ValuesKeyArray<I18nText>[number];

type TFunction = <
Key extends I18nKey,
Params extends I18nJson[Key] extends ValueFormat
? [Record<ValuesKeyUnion<I18nJson[Key]>, ValuesType>]
: [undefined?]
>(
key: Key,
...values: Params
) => I18nJson[Key] extends LineBreakFormat
? ReactElement : string;

const tFunction: TFunction = (key: I18nKey, ...values: any) => i18nJson[key] as any;

const oneValue = tFunction(
'oneValue',
{
value1: '$500'
},
); // string
const twoValue = tFunction(
'twoValue',
{
value1: '$500',
value2: 3,
},
); // string
const linkText = tFunction(
'linkText',
{
link: <a href="/about">{tFunction('link')}</a>,
},
) // string

playground

위에서 이야기한 ValuesKeyUnion을 이용해 L22 처럼 Rest Parameters를 사용해서 변수의 Key에 대한 object만 넣을 수 있게 values 타입을 설정한다. 그렇다면 L31-L33 예제에서 변수를 넣지 않거나, 잘못 넣었을때 오류가 발생하게 된다.

I18n text가 string이 되지 않는 경우 ReactElement로 추론

import React, { ReactElement } from 'react';

const i18nJson = {
"oneValue": "This product is {value1}.",
"twoValue": "This product is {value1} and it will be delivered after {value2} days",
"link": "click",
"linkText": "{link} to show more information"
} as const;

type I18nJson = typeof i18nJson;
type I18nKey = keyof I18nJson;
type LineBreakFormat = `${string}\n${string}`;
type ValuesType = string | number | ReactElement;

type ValueFormat<
Key extends string = string,
Suffix extends string = string,
> = `${string}{${Key}}${Suffix}`

type ValuesKeyArray<I18nText extends string> =
I18nText extends ValueFormat<infer Key, infer Suffix>
? [Key, ...ValuesKeyArray<Suffix>] : [];

type ValuesKeyUnion<I18nText extends string> =
ValuesKeyArray<I18nText>[number];

type TFunction = <
Key extends I18nKey,
Params extends I18nJson[Key] extends ValueFormat
? [Record<ValuesKeyUnion<I18nJson[Key]>, ValuesType>] : [undefined?],
>(
key: Key,
...values: Params
) => I18nJson[Key] extends LineBreakFormat
? ReactElement
: I18nJson[Key] extends ValueFormat
? Params extends [Record<string, string | number>]
? string : ReactElement
: string;

const tFunction: TFunction = (key, ...values) => i18nJson[key] as any;

const oneValue = tFunction('oneValue', { value1: '$500' }); // string
const twoValue = tFunction('twoValue', { value1: '$500', value2: 3 }); // string
const linkText = tFunction('linkText', { link: <a href="/about">{tFunction('link')}</a> }) // ReactElement

plaground

L35-L37에서 ValueFormat인 경우 condition을 설정해서 Record<string, string | number>를 확인하여 변수로 string, number 이외의 타입을 사용할 경우 ReactElement를 사용하게 타입을 설정하면 L44처럼 ReactElement로 추론되는 것을 확인 할 수 있다.

이처럼 Array뿐 아니라 Object에 대해서 변수 type을 지정할 수도 있다.