Статична валідація в TypeScript. Коли використовувати перезавантаження функції, мапування типів, рекурсії типів чи умовних типів
Усі статті, обговорення, новини про Front-end — в одному місці. Підписуйтеся на телеграм-канал!
Привіт, мене звати Сергій. Працюю 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/IP або створення u8
Отже, припустимо, ви хочете написати функцію, яка отримуватиме колір в форматі 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.
Тут можна знайти відповідне питання на 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};`
Тільки ми повинні динамічно створити такий тип.
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-компонент я розумію функційний компонент з типом 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-тести
, якщо маєте багато допоміжних типів.
15 коментарів
Додати коментар Підписатись на коментаріВідписатись від коментарів