Привіт, мене звати Сергій. Працюю 5 років як Front End програміст, здебільшого з TypeScript. Ця стаття буде присвячена статичній валідації в TypeScript. Я спробую показати коли краще використовувати перезавантаження функції, мапування типів, рекурсії типів чи умовних типів. Усі приклади взяті з практики.
Розглянемо наступний приклад:
const foo = <T,>(a: T) => a
// const foo: <42>(a: 42) => 42
const result = foo(42) // 42
Як бачимо, T
репрезентує тип літералу 42
а не просто number
. У випадку примітивів, вивести строгий тип літералу не складно. Складніше коли маємо об'єкт.
const foo = <T,>(a: T) => a
// const foo: <{ a: number; }> (a: { a: number; }) => { a: number; }
foo({ a: 42 })
Тепер, T
репрезентує {a: number}
а не {a: 42}
. То як вивести {a: 42}
? Потрібно додати більше обмежень (constraints
) до T
і використати ще один generic
для значення з ключем a
.
const foo = <A extends number, T extends { a: A }>(arg: T) => arg
// const foo: <number, {a: 42}>(arg: { a: 42 }) => {a: 42}
const result = foo({ a: 42 })
Вищевказана техніка є важливою, тому що дозволяє перевіряти правильність введених аргументів. Більш складніші приклади ви можете знайти тут.
Тепер, коли ми знаємо як вивести літерали типів, можемо перейти до наступного розділу.
Уявімо, що маємо функцію, яка очікує два аргумента: об'єкт та ключ який відповідає значенню типу number
. Для прикладу:
const obj = {
age: 42,
name: 'John'
}
numericalKey(obj, 'age') // ok
numericalKey(obj, 'name') // error
Передавання ключа age
є допустиме тому що він відповідає значенню 42
яке має тип number
, тоді як передавання ключ name
до функції є неправильним, оскільки він відповідає значенню з типом string
.
Перед тим як приступити до написання функції, необхідно написати тип, який буде фільтрувати необхідні нам ключі.
type NumberKey<Obj> = {
[Key in keyof Obj]: Obj[Key] extends number ? Key : never
}[keyof Obj]
// Tests
type _ = NumberKey<{ age: 42 }> // "age"
type __ = NumberKey<{ age: 42, name: 'John' }> // "age"
type ___ = NumberKey<{ a: 42, b: 'John', c: -0 }> // "a" | "c"
Щоб краще зрозуміти як цей тип працює, спробуйте усунути [keyof Obj]
:
type NumberKey<Obj> = {
[Key in keyof Obj]: Obj[Key] extends number ? Key : never
}
// type Result = {
// age: "age";
// name: never;
// }
type Test = NumberKey<{ age: 42, name: 'John' }> // "age"
Як бачимо, NumberKey
ітерує по усім ключам і перевіряє чи значення з необхідним ключем Obj[Key]
розширює тип number
. Іншими словами, чи Obj[Key]
є суб-типом number
.
На виході отримуємо об'єкт з ідентичними ключами проте з різними вартостями. То чому ж нам на кінці потрібно ужити [keyof Obj]
? Це є доволі частим питанням.
Справа в тому, що коли ми передамо "age" | "name"
до Result["age" | "name"]
- ми отримуємо тільки "age"
тому що never
не впливає на значення union
оскільки він вважається пустим empty union
або іншими словами - нейтральними елементом . Це так само як 1 + 0 = 1
. Ми можемо усунути операцію додавання 0
і вираз від цього не зміниться.
Тепер коли ми знаємо, як отримати усі дозволені ключі, можемо приступити до написання функції.
const obj = {
age: 42,
name: 'John'
}
type NumberKey<Obj> = {
[Key in keyof Obj]: Obj[Key] extends number ? Key : never
}[keyof Obj]
const numericalKey = <Obj, Key extends NumberKey<Obj>>(obj: Obj, key: Key) => obj[key]
numericalKey(obj, 'age') // ok
numericalKey(obj, 'name') // error
Як бачимо, усе працює як треба. Разом з тим, obj[key]
в тілі функції numericalKey
не вважається значенням з типом number
. Тобто ми не можемо викликати obj[key].toFixed()
. TypeScript взагалі не дозволить нам викликати будь який метод тому що TypeScript не знає нічого про цей тип Obj[Key]
.
Для того щоб TS розумів з чим він має справу ми мусимо додати певні обмеження до Obj
.
const numericalKey = <
Obj extends Record<string, number | string>, // обмеження
Key extends NumberKey<Obj>
>(obj: Obj, key: Key) => {
obj[key].toLocaleString() // ok
obj[key].toString() // ok
obj[key].valueOf() // ok
}
Тепер TypeScript дозволить нам викликати тільки ті методи для obj[key]
які є спільними для типів string
та number
. Схожі питання можна знайти на stackoverflow тут і тут.
Давайте зробимо задачу дещо складнішою. Давайте заборонимо використання ключів які починаються з нижнього підкреслення.
type NoUnderscore<T> = T extends `_${infer _}` ? never : T extends string ? T : never
type Test = NoUnderscore<'_hello'> // never
type Test2 = NoUnderscore<'hi'> // hi
NoUnderscore
- перевіряє чи перший символ в T
розширює тип _${string}
. Якщо так, значить наш ключ є заборонений. В іншому випадку ми повинні переконатись чи T
є взагалі string
.
type NumberKey<Obj> = {
[Key in keyof Obj]: Obj[Key] extends number ? Key : never
}[keyof Obj]
type NoUnderscore<T> = T extends `_${infer _}` ? never : T extends string ? T : never
const numericalKey = <
Obj,
Key extends NumberKey<Obj>
>(obj: Obj, key: NoUnderscore<Key>) => obj[key]
numericalKey({
_age: 42,
name: 'John'
}, 'age') // error
numericalKey({
count: 42,
name: 'John'
}, 'count') // ok
numericalKey({
count: 42,
name: 'John'
}, 'name') // error
Тепер, коли ми знаємо як здійснювати валідацію ключів, можемо перейти до валідації значень. Додамо ще одну вимогу до нашого коду. Нам дозволено використовувати ключ, якщо його значення ділиться на 5
. Доволі дивна вимога, але все ж таки є попит. Цей випадок є дещо складніший тому що нам потрібно вивести точний (literal
) тип аргументу. Щоб це зробити, необхідно використати або оператор as const
(immutable assertion
) або додати додатковий дженерик (generic
).
Усі числа які закінчуються на 5 або нуль - діляться на 5.
Щоб переконатись чи число ділиться на 5 потрібно перетворити його в string
.
type ZeroOrFive = '0' | '5'
type DividedByFive<Obj, Prop extends keyof Obj> =
Obj[Prop] extends number
? `${Obj[Prop]}` extends '5' | `${number}${ZeroOrFive}`
? Prop
: never
: never
type Test = DividedByFive<{ age: 35 }, 'age'> // 5
type Test2 = DividedByFive<{ age: 352 }, 'age'> // never
Тепер можемо написати функцію:
type ZeroOrFive = '0' | '5'
type DividedByFive<Obj, Prop extends keyof Obj> =
Obj[Prop] extends number
? `${Obj[Prop]}` extends '5' | `${number}${ZeroOrFive}`
? Prop
: never
: never
const numericalKey = <
Key extends string,
Value extends string | number,
Obj extends Record<Key, Value>,
>(obj: Obj, key: DividedByFive<Obj, Key>) => obj[key]
numericalKey({
age: 4,
name: 'John'
}, 'age') // error
numericalKey({
age: 45,
name: 'John'
}, 'age') // ok
Якщо ви часто використовуєте рекурсію в типах то варто переконатись що версія TypeScript є не меншою за 4.5.
Отже, припустимо ви хочете написати функцію, яка отримуватиме колір в форматі RGB
. Пригадаю, що цей формат складається з трьох октетів (u8
), тобто максимальне значення кожної цифри - 255 (0xff
).
Давайте почнемо з написання типу до одного октету. Тобто маємо написати тип який репрезентує unsigned integer
.
В TS u8
можна репрезентувати через union всіх можливих значень. Тобто 0 | 1 | 2 .. 254 | 255
.
type MAXIMUM_ALLOWED_BOUNDARY = 256
type ComputeRange<
N extends number,
Result extends Array<unknown> = [],
> =
(Result['length'] extends N
? Result
: ComputeRange<N, [...Result, Result['length']]>
)
type Octal = ComputeRange<MAXIMUM_ALLOWED_BOUNDARY>[number] // 0 - 255
ComputeRange
отримує два аргументи. Перший аргумент N
незмінний протягом усієї ітерації, це є власне максимально допустиме число. Другий аргумент, це є таблиця, яка кожної ітерації збільшується в розмірі на один елемент і цей один елемент відповідає довжині таблиці.
Тепер можемо написати функцію.
type MAXIMUM_ALLOWED_BOUNDARY = 256
type ComputeRange<
N extends number,
Result extends Array<unknown> = [],
> =
(Result['length'] extends N
? Result
: ComputeRange<N, [...Result, Result['length']]>
)
type Octal = ComputeRange<MAXIMUM_ALLOWED_BOUNDARY>[number]
type Digits = 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9
type AlphaChanel = `0.${Digits}` | '1.0'
type RGBA<Alpha extends number = 1.0> = [Octal, Octal, Octal, (`${Alpha}` extends AlphaChanel ? Alpha : never)?]
function getContrastColor<Alpha extends number>(...[R, G, B, a]: RGBA<Alpha>) {}
getContrastColor(10, 20, 30, 0.2); // ok
getContrastColor(256, 20, 30, 0.2); // error, 256 is out of the range
getContrastColor(255, 20, 30, 0.22); // error, 0.22 should be 0.2
Я додав ще AlphaChanel
, який приймає значення від 0.1 до 0.998
Не слід забувати й про валідацію IP адреси:
type IpAddress = `${Octal}.${Octal}.${Octal}.${Octal}`
Тут можна знайти відповідне питання на stackoverflow.
Тут можна знайти мою відповідь щодо створення діапазону чисел в TypeScript.
Цей паттерн можна широко застосовувати, якщо маєму повторюваність. Наприклад, маємо string
який складається з послідовності ${number}, ${number};
. Тобто значення 45,65; 78,12; 98,34;
є дозволене, а 23,45,56;11,11;
- ні. Алгоритм такий: нам потрібно створити максимально довгий union
де кожний наступний елемент буде злитя попереднього з ${number}, ${number};
.
Щось на кшталт такого:
type Coordinates = `${number},${number};`;
type Result =
| `${number},${number};`
| `${number},${number};${number},${number};`
| `${number},${number};${number},${number};${number},${number};`
Тільки ми повинні динамічно cтворити такий тип.
type MAXIMUM_ALLOWED_BOUNDARY = 10
type Coordinates = `${number},${number};`;
type Last<T extends string[]> =
T extends [...infer _, infer Last]
? Last
: never;
type ConcatPrevious<T extends any[]> =
Last<T> extends string
? `${Last<T>}${Coordinates}`
: never
type Repeat<
N extends number,
Result extends Array<unknown> = [Coordinates],
> =
(Result['length'] extends N
? Result
: Repeat<N, [...Result, ConcatPrevious<Result>]>
)
type MyLocation = Repeat<MAXIMUM_ALLOWED_BOUNDARY>[number]
const myLocation1: MyLocation = '02,56;67,68;' // ok
const myLocation2: MyLocation = '45,56;67,68;1,2;3,4;5,6;7,8;9,10;' // ok
const myLocation3: MyLocation = '45,56;67,68;1,2;3,4;5,6;7,8;9,10,' // expected error no semicolon at the end
Last
- виводить останній елемент з таблиці.
Repeat
- працює подібно до попереднього прикладу, тільки замість додавання в кінець довжини таблиці - ми додаємо Coordinates
до останнього елементу.
Я розумію, що часом такі типи важко відразу зрозуміти, тому тримайте runtime
відповідник :
/**
* JS representation of Repeat type
*/
const mapped = (N: number, Result: any[] = []): string => {
if (N === Result.length) {
return Result.join('')
}
const x = Math.random();
const y = Math.random()
return mapped(N, [...Result, `${x}${y}`])
}
Тут ви зможете знайти відповідну відповідь.
Припустимо ми хочемо створити об'єкт з рекурсивною властивістю children
. Наприклад такий:
const result = {
level: 0,
children: {
level: 1,
children: {
level: 2,
children: {
level: 3,
children: {
level: 4,
children: undefined
}
}
}
}
}
У цьому прикладі мені не йде мова про створення просто рекурсивного типу:
type Layer = {
level: number;
children: Layer | undefined
}
Йде мова про створення типу в якому можемо заздалегідь вказати кількість вкладеностей. Можна вказати менше варств, але не більше. Всі пояснення знайдете в коментарях до типу.
type Test<T, Length extends number, Tuple extends number[] = []> =
/**
* Якщо Length відповідає довжині Tuple
* - повертаємо перший аргумент T
*/
(Length extends Tuple['length']
? T
/**
* В іншому разі ітеруємо через всі ключі + "level"
*/
: {
[Prop in keyof T | 'level']:
/**
* Якщо поточний ключ це "level"
*/
(Prop extends 'level'
/**
* повертаємо довжину Tuple, таки чином отримуємо { length: 0, .... length: 2}
*/
? Tuple['length']
/**
* Якщо поточний елемент це "children"
*/
: (Prop extends 'children'
/**
* Перевіряємо чи поточна довжина Tuple + 1 є рівна Length
*/
? (Length extends [...Tuple, 1]['length']
/**
* якщо так - викликаємо Test з undefined
*/
? undefined | Test<undefined, Length, [...Tuple, 1]>
/**
* в іншому разі (якщо не досягнутий максимум)
* ітеруєми далі при цьому збільшуючи довжину Tuple на 1
*/
: undefined | Test<T, Length, [...Tuple, 1]>)
: never)
)
})
const result: Test<{ children: 0 }, 2> = {
level: 0,
children: {
level: 1,
children: undefined
}
}
Tuple
- відіграє роль індекса. Довжина Tuple
дорівнює рівню вкладеності. Тут можна знайти відповідь.
Перезавантаження функцій (function overloads)
Розглянемо наступний приклад.
const conditional = <T,>(arg: T): T extends number ? string : T =>
typeof arg === 'number' ? arg.toString() : arg
Дуже часто на stackoverflow
люди питають чому цей код не працює та як змусити його працювати. TypeScript не підтримує умовні типи в місці де очікується тип повернення. Натомість, ми можемо перезавантажити функцію.
function conditional<T,>(arg: T): T extends number ? string : T
function conditional<T,>(arg: T) {
return typeof arg === 'number' ? arg.toString() : arg
}
conditional(1) // string
conditional('str') // 'str'
Чому перезавантаження працює ? Тому що воно є біваріантним відносно функції. Це значить, що тип повернення в перезавантаженій функції може бути присвоєний до типу повернення основної функції або навпаки - typescript дозволить такий тип.
Погодьтеся, умовні типи часом важко читаються, тому в цьому випадку ми можемо написати функцію по іншому:
function conditional<T extends number>(arg: T): string
function conditional<T extends string>(arg: T): T
function conditional<T,>(arg: T) {
return typeof arg === 'number' ? arg.toString() : arg
}
conditional(1) // string
conditional('str') // 'str'
Дуже часто перезавантаження стають в пригоді коли працюємо з таблицями. Розглянемо наступний приклад:
type Stringify<Tuple extends number[]> = {
[Prop in keyof Tuple]: `${Tuple[Prop] & number}`
}
type Test = Stringify<[1, 2, 3]> // ["1", "2", "3"]
const handler = <Tuple extends number[]>(tuple: [...Tuple]): Stringify<Tuple> => {
return tuple.map(elem => elem.toStirng()) // error
}
// ["1", "2", "3"]
const result = handler([1, 2, 3])
Функція handler
повертає нам нову таблицю, де кожний має тип string
. Як ви вже здогадались, щоб виправити помилку, потрібно перезавантажити функцію.
function handler<Tuple extends number[]>(tuple: [...Tuple]): Stringify<Tuple>
function handler(tuple: number[]) {
return tuple.map(elem => elem.toString()) // ok
}
// ["1", "2", "3"]
const result = handler([1, 2, 3])
Stringify
- ітерує через всі елементи таблиці і замінює їх на нову версію з типом string
.
Припустимо наша функція не дозволяє аргументів де є нулі. Крім того, якщо в таблиці є нуль, ми хочемо щоб тільки він був підкреслений а не цілий аргумент. Разом з тим, тип повернення має бути без нулів взагалі.
// Замінимо кожний 0 на never
type ZeroValidation<Tuple extends number[]> = {
[Prop in keyof Tuple]: Tuple[Prop] extends 0 ? never : Tuple[Prop]
}
type FilterZero<Tuple extends any[], Result extends any[] = []> =
/**
* Якщо перший аргумент це пуста таблиця
* повертаємо Result
*/
(Tuple extends []
? Result
/**
* В іншому випадку виводимо перший елемент з таблиці і всі інщі елементи
*/
: (Tuple extends [infer Head, ...infer Rest]
/**
* Якщо перший елемент це 0
*/
? (Head extends 0
/**
* Викликаємо рекурсивно FilterZero і губимо 0
*/
? FilterZero<Rest, Result>
/**
* Викликаємо рекурсивно FilterZero і додаємо поточний елемент до Result
*/
: FilterZero<Rest, [...Result, Head]>)
: never)
)
function handler<Tuple extends number[]>(tuple: ZeroValidation<[...Tuple]>): FilterZero<Tuple>
function handler<Tuple extends number[]>(tuple: ZeroValidation<[...Tuple]>) {
return tuple.filter(elem => elem ! == 0)) // ok
}
// [2, 3]
const result = handler([0, 2, 3]) // 0 підкреслений як недозводлений елемент
У цьому розділі під поняттям React компонент я розумію функційний компонент з типом React.FC
Дуже часто ми забуваємо, що React компоненти це є ті ж самі функції, які також можна перезавантажити. Візьмімо тривіальний приклад. Маємо один компонент, який може приймати властивості або Checkbox
або Dropdown
.
import React, { FC } from 'react'
type Checkbox = {
checked: boolean;
};
type Dropdown = {
options: Array<any>;
selectedOption: number;
};
const Component: FC<Dropdown | Checkbox> = (props) => <></>
const CheckboxComponent = <Component checked={true} /> // ok
const DropdownComponent = <Component options={[]} selectedOption={0} /> // ok
const MixComponent = <Component checked options={[]} selectedOption={0} /> // ok, but should be error
Як бачимо, union
типи в TypeScript працюють не так як ми очікуємо. Ми немаємо помилки в останньому прикладі тому що TypeScript має структуральну систему типів. Для того, щоб викликати помилку в останньому прикладі ми можемо додати властивість type
, яка буде відігравати роль дискримінанта
.
type Checkbox = {
type: 'Checkbox';
checked: boolean;
};
type Dropdown = {
type: 'Dropdown'
options: Array<any>;
selectedOption: number;
};
Ця техніка спрацює, тільки в цьому випадку, властивість type
нам взагалі не потрібна в runtime
. Ми можемо просто перезавантажити функцію. Візьміть до уваги, що крім стандартного синтаксису , перезавантаження можна створити шляхом злиття двох функцій.
import React, { FC } from 'react'
type Checkbox = {
checked: boolean;
};
type Dropdown = {
options: Array<any>;
selectedOption: number;
};
const Component: FC<Dropdown> & FC<Checkbox> = (props) => <></>
const MixComponent = <Component checked options={[]} selectedOption={0} /> // expected error
Перезавантаження - FC<Dropdown> & FC<Checkbox>
. Давайте зробимо завдання дещо складнішим. Заборонимо використання 0
як значення для selectedOption
.
type Checkbox = {
checked: boolean;
};
type Dropdown = {
options: Array<any>;
selectedOption: number;
};
type Validation<N extends number> = N extends 0 ? never : N
function Component(props: Checkbox): null
function Component<Option extends number>(props: Dropdown & { selectedOption: Validation<Option> }): null
function Component(props: Checkbox | Dropdown) {
return null
}
const NonZero = <Component options={[]} selectedOption={1} /> // ok
const WithZero = <Component options={[]} selectedOption={0} /> // expected error
Як бачимо, ми були змушені переключитись на стандартний синтаксис перезавантажень тому що нам необхідно вивести тип selectedOption
властивості. Подібний приклад ви можете знайти на stackoverflow та в моїй статті на dev.to.
У цій статті, я хотів показати проблеми з якими часто стикаються інші розробники. Я не говорю, що вам необхідно вже і зараз переходити на TypeScript. Хотілося б уникнути будь яких суперечок. Разом з тим, існує декілька цікавих альтернатив, наприклад ReScript чи Fable
- З мого досвіду виникає, що TypeScript найкраще себе показує коли працюємо з незмінними (
immutable
) структурами даних. - Умовні типи з багатьма рівнями вкладеності так само важко читаються як і вкладені
if/else
блоки, тому їх варто розбивати на невеликі допоміжні типи. - Перед тим як писати перезавантаження функції, подумайти чи не варто просто переказати
union
. - Варто писати невеличкі
type-тести
, якщо маєте багато допоміжних типів.