Логический вывод типа в TypeScript¶
TypeScript может логически вывести (а затем проверить) тип переменной на основе нескольких простых правил. Потому как эти правила просты, вы можете научить свой мозг распознавать безопасный / небезопасный код (это случилось со мной и моими товарищами по команде довольно быстро).
Поток типов — это то, как я представляю себе в уме распространение информации о типах.
Определение переменной¶
Типы переменной определяются по её определению.
1 2 3
let foo = 123; // foo `число` let bar = 'Hello'; // bar `строка` foo = bar; // Ошибка: невозможно `строке` присвоить `число`
Это пример типов, распространяющихся справа налево.
Типы значений возвращаемых функцией¶
Тип возвращаемого значения определяется инструкцией возврата, например, предполагается, что следующая функция возвращает число .
1 2 3
function add(a: number, b: number) return a + b; >
Это пример типов, распространяющихся снизу вверх.
Присвоение¶
Тип параметров функции / возвращаемых значений также может быть определен посредством присваивания, например, здесь мы говорим, что foo является сумматором , и это делает a и b типом число .
type Adder = (a: number, b: number) => number; let foo: Adder = (a, b) => a + b;
Этот факт может быть продемонстрирован с помощью приведенного ниже кода, который вызывает ошибку, как можно было и ожидать:
1 2 3 4 5
type Adder = (a: number, b: number) => number; let foo: Adder = (a, b) => a = 'hello'; // Ошибка: невозможно `строке` присвоить `число` return a + b; >;
Это пример типов, распространяющихся слева направо.
Логический вывод типов срабатывает с этим же стилем присвоения, если вы создаете функцию с параметром в виде колбэка. В конце концов, argument -> parameter — это просто еще одна форма присвоения переменных.
1 2 3 4 5 6 7 8
type Adder = (a: number, b: number) => number; function iTakeAnAdder(adder: Adder) return adder(1, 2); > iTakeAnAdder((a, b) => // a = "hello"; // Будет ошибка: невозможно `строке` присвоить `число` return a + b; >);
Структурирование¶
Эти простые правила также работают при использовании структурирования (создание литерала объекта). Например, в следующем случае тип foo определяется как
1 2 3 4 5
let foo = a: 123, b: 456, >; // foo.a = "hello"; // Будет ошибка: невозможно `строке` присвоить `число`
Аналогично для массивов:
const bar = [1, 2, 3]; // bar[0] = "hello"; // Будет ошибка: невозможно `строке` присвоить `число`
Ну и конечно же любое вложение:
1 2 3 4
let foo = bar: [1, 3, 4], >; // foo.bar[0] = 'hello'; // Будет ошибка: невозможно `строке` присвоить `число`
Деструктуризация¶
И, конечно же, они также работают с деструктуризацией, оба:
1 2 3 4 5 6
let foo = a: 123, b: 456, >; let a > = foo; // a = "hello"; // Будет ошибка: невозможно `строке` присвоить `число`
1 2 3
const bar = [1, 2]; let [a, b] = bar; // a = "hello"; // Будет ошибка: невозможно `строке` присвоить `число`
И если параметр функции может быть логически выведен, то могут и его деструктурированные свойства. Например, здесь мы деструктурируем параметр на a / b .
1 2 3 4 5 6 7 8 9
type Adder = (numbers: a: number; b: number >) => number; function iTakeAnAdder(adder: Adder) return adder( a: 1, b: 2 >); > iTakeAnAdder(( a, b >) => // Типы `a` и` b` логически выводятся // a = "hello"; // Будет ошибка: невозможно `строке` присвоить `число` return a + b; >);
Защита типа¶
Мы уже видели, как Защита типа помогает изменять и уточнять типы (особенно в случае объединений). Защиты типов — это просто еще одна форма логического вывода типа для переменной в блоке.
Предупреждения¶
Будьте осторожны с параметрами¶
Типы не распространяются в параметры функции, если они не могут быть логически выведены из присвоения. Например, в следующем случае компилятор не знает тип foo , поэтому он не может определить тип a или b .
1 2 3
const foo = (a, b) => /* сделать что-нибудь */ >;
Однако, если был введен foo , тип параметров функции может быть логически выведен ( a , b оба выведены как имеющие тип number в примере ниже).
1 2 3 4
type TwoNumberFunction = (a: number, b: number) => void; const foo: TwoNumberFunction = (a, b) => /* сделать что-нибудь */ >;
Будьте осторожны с возвращаемыми значениями¶
Хотя TypeScript может обычно логически выводить тип возвращаемого значения функции, он может не соответствовать ожидаемому. Например, здесь функция foo имеет тип возврата any .
1 2 3 4 5 6 7
function foo(a: number, b: number) return a + addOne(b); > // Какая-то внешняя функция в библиотеке, которую кто-то написал в JavaScript function addOne(c) return c + 1; >
Это связано с тем, что на возвращаемый тип влияет плохое определение типа для addOne ( c равно any , поэтому возвращаемое от addIn равно any , поэтому отсюда и foo равно any ).
Я считаю, что проще всего всегда быть явным в описании возвращаемых значений функций. Ведь эти описания являются теоремой, а тело функции является доказательством.
Есть и другие случаи, которые нетрудно себе представить, но хорошая новость заключается в том, что есть флаг компилятора, который может помочь отловить такие ошибки.
noImplicitAny ¶
Флаг noImplicitAny указывает компилятору выдавать ошибку, если он не может определить тип переменной (и, следовательно, может иметь ее только как неявный any тип). Далее вы сможете
- либо сказать, что да, я хочу, чтобы это было типом any и явно добавить описание типа : any
- либо помочь компилятору, добавив еще несколько правильных описаний.
Understanding infer in TypeScript
We’ve all been in situations where we used a library that had been typed sparingly. Take the following third-party function, for example:
function describePerson(person: < name: string; age: number; hobbies: [string, string]; // tuple >) < return `$is $ years old and love $.`; >
If the library doesn’t provide a standalone type for the person argument of describePerson , defining a variable beforehand as the person argument would not be inferred correctly by TypeScript.
const alex = < name: 'Alex', age: 20, hobbies: ['walking', 'cooking'] // type string[] != [string, string] >describePerson(alex) /* Type string[] is not assignable to type [string, string] */
TypeScript will infer the type of alex as < name: string; age: number; hobbies: string[] >and will not permit its use as an argument for describePerson .
And, even if it did, it would be nice to have type checking on the alex object itself to have proper autocompletion. We can easily achieve this, thanks to the infer keyword in TypeScript.
const alex: GetFirstArgumentOfAnyFunction = < name: "Alex", age: 20, hobbies: ["walking", "cooking"], >; describePerson(alex); /* No TypeScript errors */
The infer keyword and conditional typing in TypeScript allows us to take a type and isolate any piece of it for later use.
The no-value never type
In TypeScript, never is treated as the “no value” type. You will often see it being used as a dead-end type. A union type like string | never in TypeScript will evaluate to string , discarding never .
To understand that, you can think of string and never as mathematical sets where string is a set that holds all string values, and never is a set that holds no value (∅ set). The union of such two sets is obviously the former alone.
By contrast, the union string | any evaluates to any . Again, you can think of this as a union between the string set and the universal set (U) that holds all sets, which, to no one’s surprise, evaluates to itself.
This explains why never is used as an escape hatch because, combined with other types, it will disappear.
Using conditional types in TypeScript
Conditional types modify a type based on whether or not it satisfies a certain constraint. It works similarly to ternaries in JavaScript.
The extends keyword
In TypeScript, constraints are expressed using the extends keyword. T extends K means that it’s safe to assume that a value of type T is also of type K , e.g., 0 extends number because var zero: number = 0 is type-safe.
Thus, we can have a generic that checks whether a constraint is met, and return different types.
StringFromType returns a literal string based on the primitive type it receives:
type StringFromType = T extends string ? 'string' : never type lorem = StringFromType // 'string' type ten = StringFromType // never
To cover more cases for our StringFromType generic, we can chain more conditions exactly like nesting ternary operators in JavaScript.
type StringFromType = T extends string ? 'string' : T extends boolean ? 'boolean' : T extends Error ? 'error' : never type lorem = StringFromType // 'string' type isActive = StringFromType // 'boolean' type unassignable = StringFromType // 'error'
Conditional types and unions
In the case of extending a union as a constraint, TypeScript will loop over each member of the union and return a union of its own:
type NullableString = string | null | undefined type NonNullable = T extends null | undefined ? never : T // Built-in type, FYI type CondUnionType = NonNullable // evalutes to `string`
TypeScript will test the constraint T extends null | undefined by looping over our union, string | null | undefined , one type at a time.
You can think of it as the following illustrative code:
type stringLoop = string extends null | undefined ? never : string // string type nullLoop = null extends null | undefined ? never : null // never type undefinedLoop = undefined extends null | undefined ? never : undefined // never type ReturnUnion = stringLoop | nullLoop | undefinedLoop // string
Because ReturnUnion is a union of string | never | never , it evaluates to string (see explanation above.)
Over 200k developers use LogRocket to create better digital experiences
Learn more →
You can see how abstracting the extended union into our generic allows us to create the built-in Extract and Exclude utility types in TypeScript:
type Extract = T extends U ? T : never type Exclude = T extends U ? never : T
Conditional types and functions
To check whether a type extends a certain function shape, the Function type must not be used. Instead, the following signature can be used to extend all possible functions:
type AllFunctions = (…args: any[]) => any
…args: any[] will cover zero and more arguments, while => any would cover any return type.
Using infer in TypeScript
The infer keyword compliments conditional types and cannot be used outside an extends clause. Infer allows us to define a variable within our constraint to be referenced or returned.
Take the built-in TypeScript ReturnType utility, for example. It takes a function type and gives you its return type:
type a = ReturnType void> // void type b = ReturnType string | number> // string | number type c = ReturnType any> // any
It does that by first checking whether your type argument ( T ) is a function, and in the process of checking, the return type is made into a variable, infer R , and returned if the check succeeds:
type ReturnType = T extends (. args: any[]) => infer R ? R : any;
As previously mentioned, this is mainly useful for accessing and using types that are not available to us.
React prop types
In React, we often need to access prop types. To do that, React offers a utility type for accessing prop types powered by the infer keyword called ComponentProps .
type ComponentProps < T extends keyof JSX.IntrinsicElements | JSXElementConstructor> = T extends JSXElementConstructor ? P : T extends keyof JSX.IntrinsicElements ? JSX.IntrinsicElements[T] : <>
After checking that our type argument is a React component, it infers its props and returns them. If that fails, it checks that the type argument is an IntrinsicElements ( div , button , etc.) and returns its props. If all fails, it returns <> which, in TypeScript, means “any non-null value”.
Infer keyword use cases
Using the infer keyword is often described as unwrapping a type. Here are some common uses of the infer keyword.
Function’s first argument:
This is the solution from our first example:
type GetFirstArgumentOfAnyFunction = T extends ( first: infer FirstArgument, . args: any[] ) => any ? FirstArgument : never type t = GetFirstArgumentOfAnyFunction <(name: string, age: number) =>void> // string
Function’s second argument:
type GetSecondArgumentOfAnyFunction = T extends ( first: any, second: infer SecondArgument, . args: any[] ) => any ? SecondArgument : never type t = GetSecondArgumentOfAnyFunction <(name: string, age: number) =>void> // number
Promise return type
type PromiseReturnType = T extends Promise ? Return : T type t = PromiseReturnType> // string
Array type
type ArrayType = T extends (infer Item)[] ? Item : T type t = ArrayType <[string, number]>// string | number
Conclusion
The infer keyword is a powerful tool that allows us to unwrap and store types while working with third-party TypeScript code. In this article, we explained various aspects of writing robust conditional types using the never keyword, extends keyword, unions, and function signatures.
LogRocket: Full visibility into your web and mobile apps
LogRocket is a frontend application monitoring solution that lets you replay problems as if they happened in your own browser. Instead of guessing why errors happen, or asking users for screenshots and log dumps, LogRocket lets you replay the session to quickly understand what went wrong. It works perfectly with any app, regardless of framework, and has plugins to log additional context from Redux, Vuex, and @ngrx/store.
In addition to logging Redux actions and state, LogRocket records console logs, JavaScript errors, stacktraces, network requests/responses with headers + bodies, browser metadata, and custom logs. It also instruments the DOM to record the HTML and CSS on the page, recreating pixel-perfect videos of even the most complex single-page and mobile apps.
Share this:
Simohamed Marhraoui Follow Vue and React developer | Linux enthusiast | Interested in FOSS
17 common Node.js errors and how to solve them
David Omotayo
Oct 27, 2023 35 sec read
5 best popover libraries for React
Ibadehin Mojeed
Oct 27, 2023 9 min read
Choosing the best JavaScript sorting algorithm for your project
Ukeje Goodness
Oct 26, 2023 11 min read
6 Replies to “Understanding infer in TypeScript”
Cefn says:
A literal [ ‘hello’ , ‘world’ ] in Typescript code is by default typed as a mutable array not a readonly tuple, but you can resolve this with `as const`. Although it was a two-arg string array when you created it, Typescript models it as a mutable array, because you could push(), pop() and so on. One way to defeat this type-widening, alex should be declared `as const` which prevents it from being considered mutable and makes push(), pop() a compiler error so it can never vary from being a two-value tuple. I really liked the learning associated with infer, (for when you can’t edit the function), but for the case where you can edit the function, I think a better fix is for the person type to be asserted readonly in the first place and to use `as const` when composing person objects, which allows the original code to compile… function describePerson(person: Readonly
hobbies: Readonly; // tuple
>>) return `$ is $ years old and love $
> const alex = name: ‘Alex’,
age: 20,
hobbies: [‘walking’, ‘cooking’] // type is [string, string]
> as const; describePerson(alex) Getting this right means that you haven’t type-widened the alex object, to turn e.g. hobbies into [string,string] by declaring it as a Person. When you use `as const` the hobbies property can still be inferred by the editor as being the narrower [‘walking’,’cooking’]. This has saved me a million times where compiler and editor awareness of the values is needed to guard sensitive logic. For example, some other type might be and the compiler can know that both values of alex.hobbies fulfil the hobby value. This is not possible after type-widening them to string. See also https://learntypescript.dev/10/l4-readonly-function-parameters and https://github.com/typescript-eslint/typescript-eslint/blob/master/packages/eslint-plugin/docs/rules/prefer-readonly-parameter-types.md You can see the above approach in the playground https://www.typescriptlang.org/play?#code/GYVwdgxgLglg9mABAEwKYGcICcYCNUAKqW6CAFAA7GlgBciASqgIbIIA2AngDwDeAsAChEiMMwC2qeuig4wAcwDcQkc3lTRIcfizLhiABZxcuGBnpNWHHgG0Zc+QBpE9mAoC6APkWIA9L8QoEAp2VCEAX08ASkQBfSxUIKwkAAMAEl4qEgQAOjFJcMQYdEQMrJoctVRCzhYSRDh2ZERmMGb2OAA3VFLM6lyjEzN0HIArODcyACIWtpEpqPCclL1woSEIBBkW0IAPRABeWJVRCQ0AcgBBPfPHE6r6ACYABjv9QdNzRBtzgHdmdgAazc8luiHOmzgwIU53cfgCUE4VCKJTsshBzlcHgiLRKmzAMj0QjQmBw+CI2TAZABqF2USAA
PR says:
Thank you. Here I understood the infer
gigby says:
Thank you so much for this great article. I didn’t get a sense of “infer” from official TS guide. But here it described perfectly
e_philo says:
This is so COOL! This article let me understand the concept of ‘infer’. Thanks a lot, Marhraoui
Fatih Al Aziz says:
For you who need to infer Function return Promise, type PromiseReturnType = T extends Promise ? Return : T
type FunctionReturnType = T extends (…args: any[]) => infer R
? PromiseReturnType
: any
Infer typescript что это
Йо, Хабр! Меня зовут Алексей Акулов. Я разрабатываю клиентскую часть продукта BIMeister.
Почти каждый сталкивался с типами Partial или Record. Там таких еще много, но суть в том, что они входят в ту самую группу Utility Types. Такие штуки представляют из себя разные преобразования одних типов в другие. Partial помечает все поля опциональными. Record отдает тип с бесконечным числом полей одного типа. Тут, вроде, понятно, но как писать собственные? Что такое infer? Как он может нам помочь?
Проблема
Представим, что мы пишем библиотеку для рендеринга UI компонентов. Пример условный.
Дан базовый абстрактный класс UI компонента. На вход в конструкторе обязательно получаем параметры и зависимости, нужные для его работы. Конкретные подробности о зависимостях опустим. Также требуем у наследников реализацию метода, который должен вернуть шаблон компонента. Тип шаблона в этой ситуации неважен.
abstract class UIComponent < constructor( public readonly params: P, // Возможно получить только внути класса Renderer, // который представлен далее по тексту protected readonly appState: AppState, . ) < . >// описание шаблона компонента, // который зависит от P, AppState и остальных параметров public abstract getTemplate(): SomeTemplateType; >
Еще дан основной класс рендеринга. Он отвечает за создание UI компонента и его вставку в DOM-дерево. Нам интересна только реализация метода render , остальное опустим. Этот метод принимает на вход родительский элемент, компонент для рендеринга и его параметры. В самом же методе происходит сбор зависимостей компонента, его создание через конструктор, получение шаблона и вставка шаблона в DOM.
type Type = new (. args: any) => T; class Renderer < . public render( parentElement: HTMLElement, component: Type>, params: any ): void < const appState: AppState = this.getAppState(); . // другие зависимости const componentInstance: UIComponent= new component( params, appState, . ); const template: SomeTemplateType = componentInstance.getTemplate(); . // какая-то логика вставки компонента в DOM-дерево > . >
Также у нас есть пара компонентов, которые мы будем рендерить. Каждый наследуем от базового класса компонента, интерфейсами описываем их параметры и реализуем методы для описания шаблонов. Верстку опустим.
interface HeaderComponentParams < hasLogo: boolean; menuItems: MenuItem[]; >class HeaderComponent extends UIComponent < public getTemplate(): SomeTemplateType < . >>
interface AlertComponentParams < hasCloseButton?: boolean; bodyText?: string; headerText?: string; >class AlertComponent extends UIComponent < public getTemplate(): SomeTemplateType < . >>
Вот как будет выглядеть место вызова метода render .
. renderer.render( appBodyElement, HeaderComponent, < hasLogo: true, menuItems: [. ] >); renderer.render( overlayHostElement, AlertComponent, < bodyText: 'Some body text!' >); .
Проблема заключается в отсутствии явных контрактов для использования компонентов. В любой момент у любого из них параметры поменяются и на месте использования не будет никаких ошибок. В новых местах вызова рендеринга, при описании передаваемых параметров, не будет подсказок по полям и их типам. Полный отстой.
Как обезопасить команду от этой проблемы? Очевидный ответ – протипизировать параметры метода render. Как это сделать? Дальше разберем несколько вариантов ответов на этот вопрос.
Решение 1. Обращение к типам по ключу (Indexed Access Types)
Как работает?
Представим, что у нас есть интерфейс MyInterface .
interface MyInterface
TypeScript позволяет доставать типы свойств из сложных типов по ключу. Делается это так же, как из объектов достаются значения свойств. Увы, через точку тип получить нельзя.
type MyProperty1Type = MyInterface['myProperty1']; // type MyProperty1Type = number; type MyProperty2Type = MyInterface['myProperty2']; // type MyProperty1Type = string; type MyProperty3Type = MyInterface['myProperty3']; // type MyProperty1Type = MyOtherInterface;
То же можно провернуть и с классами и с типами, которые описывают объект (аналог интерфейса). В общем, если тип составной, то тип его части можно получить по ключу.
Решение
Для начала давайте добавим utility-тип, который достанет из компонента тип параметров:
- Ограничим типы для параметра T . Оставим возможность передавать только наследников класса UIComponent . Это делается с помощью конструкции T extends UIComponent в угловых скобках;
- Достанем нужный тип по ключу params .
type UIComponentParamsType> = T['params'];
Также доработаем метод render :
- Так как utility-тип принимает только наследников UIComponent , тут так же нужно ограничить тип T . Все в точности, как в utility-типе;
- Так как мы теперь знаем, что T это любой наследник класса UIComponent , можно немного изменить тип параметра component с Type> на Type ;
- И, конечно же, заменим params: any на params: UIComponentParamsType .
public render>( parentElement: HTMLElement, component: Type, params: UIComponentParamsType ): void
Теперь статический анализатор знает, какой тип для какого компонента нужно передавать в render как параметр.
Решение 2. Вывод типов. Ключевое слово infer
Как работает?
Хочется начать с существующего utility-типа Parameters . Данный тип принимает как T тип функции/метода и достает из него типы всех ее параметров по порядку в кортеж. Предлагаю разобрать на примере.
У нас есть функция.
function myFunction( param1: number, param2: boolean, param3: MyInterface ): void < // какое-то действие >
Она, в свою очередь, имеет такой тип.
type MyFunctionType = ( param1: number, param2: boolean, param3: MyInterface ) => void; // type MyFunctionType = typeof myFunction;
Если мы передадим MyFunctionType в Parameters как T , то вся эта конструкция выведет тип [number, boolean, MyInterface] .
type MyFunctionParametersTuple = Parameters // type MyFunctionParametersTuple = [number, boolean, MyInterface];
Но как же этот тип работает? Ответ – через ключевое слово infer .
Данное ключевое слово позволяет вытягивать типы из условных дженериковых типов. Пример условного дженерикового типа:
type MyType = T extends MyEnum.First ? number : T extends MyEnum.Second ? string : never;
Небольшое объяснение. Тип MyType вычисляет итоговый тип в зависимости от переданного параметра T . Это работает по принципу тернарного оператора если что-то ? то это : иначе это . В данном случае extends – это оператор сравнения.
Если посмотреть d.ts файл, в котором лежит Parameters , то там можно увидеть следующее.
type Parameters any> = T extends (. args: infer P) => any ? P : never;
- Применение типа возможно только с функциями. Об этом говорит конструкция T extends (. args: any) => any в угловых скобках;
- Чтобы применить infer , создается проверка типа через extends , поэтому используется конструкция T extends (. args: infer P) => any . Можно обратить внимание, что infer P подставляется именно вместо того типа, который необходимо вывести. Тип аргументов – это кортеж, поэтому infer достанет в P именно кортеж параметров функции T . Если бы нам понадобилось вывести возвращаемый тип, то нужно было бы подставить infer P на место возвращаемого типа. Это можно увидеть в декларации utility-типа ReturnType ;
- Так как нужный тип выводится в новый параметр P , он возвращается в первой ветке;
- В случае если T каким-то образом оказывается не функцией, а значит нельзя наверняка знать куда подставлять конструкцию infer P , следует вернуть какой-то другой тип. В данном случае never .
Вот еще пример, но если мы хотим узнать тип T , который нам пришел откуда-то извне.
type ObservableValueType> = T extends Observable ? V : never;
Если я передам в ObservableValueType тип Observable как T , то ObservableValueType
Решение
Так же, как в первом решении, добавим utility-тип, который достанет из компонента тип параметров, но с помощью ключевого слова infer .
- Ограничиваем T , как в первом решении;
- Прописываем условный тип. Проверим, что T действительно является наследником UIComponent . И в этой же конструкции подставим вместо unknown конструкцию infer P;
- Так как мы знаем, что в P содержится внешний тип, который нам нужен, можем его вернуть;
- В случае, если T не является наследником UIComponent , возвращаем тип never .
type UIComponentParamsType> = T extends UIComponent ? P : never;
И, точно так же, как в первом решении, доработаем метод render .
public render>( parentElement: HTMLElement, component: Type, params: UIComponentParamsType ): void
Теперь просто покажу скрины с ошибками. В первом случае убираем одно из полей, а во втором — меняем значение свойства так, чтобы оно было невалидного типа.
Итог
Мы рассмотрели два варианта динамической типизации параметров.
В первом варианте написали свой utility-тип с помощью получения типа свойства из типа параметра, переданного в метод, по ключу (Indexed Access Types).
Во втором варианте тоже написали свой utility-тип, но с использованием конструкции infer P , подставив ее на место неизвестного типа.
На мой взгляд, лучше использовать вариант с infer , потому что мы не завязываем свой utility-тип на какое-то конкретное поле, лишь на неизвестный тип T .
Теперь при изменении параметров компонента мы сразу узнаем, какие места в кодовой базе поломались. Команде не нужно страдать в поисках мест использования изменившегося компонента. Все счастливы!
Что думаете по поводу ситуации из примера? Пишите в комментах.
Ссылки
- https://www.typescriptlang.org/docs/handbook/2/generics.html
- https://www.typescriptlang.org/docs/handbook/2/indexed-access-types.html
- https://www.typescriptlang.org/docs/handbook/2/conditional-types.html
- https://www.typescriptlang.org/docs/handbook/utility-types.html#parameterstype
- https://www.typescriptlang.org/docs/handbook/release-notes/typescript-2-8.html#type-inference-in-conditional-types
Вывод типов в TypeScript с использованием конструкции as const и ключевого слова infer
TypeScript позволяет автоматизировать множество задач, которые, без использования этого языка, разработчикам приходится решать самостоятельно. Но, работая с TypeScript, нет необходимости постоянно использовать аннотации типов. Дело в том, что компилятор выполняет большую работу по выводу типов, основываясь на контексте выполнения кода. Статья, перевод которой мы сегодня публикуем, посвящена достаточно сложным случаям вывода типов, в которых используется ключевое слово infer и конструкция as const .
Основы вывода типов
Для начала взглянем на простейший пример вывода типов.
let variable;
Переменная, которая объявлена таким способом, имеет тип any . Мы не дали компилятору каких-либо подсказок о том, как мы будем её использовать.
let variable = 'Hello!';
Здесь мы объявили переменную и сразу же записали в неё некое значение. Теперь TypeScript может догадаться о том, что эта переменная имеет тип string , поэтому теперь перед нами вполне приемлемая типизированная переменная.
Похожий подход применим и к функциям:
function getRandomInteger(max: number)
В этом коде мы не указываем того, что функция getRandomInteger возвращает число. Но TypeScript-компилятор очень хорошо об этом знает.
Вывод типов в дженериках
Вышеописанные концепции имеют отношение к универсальным типам (дженерикам). Если вы хотите больше узнать о дженериках — взгляните на этот и этот материалы.
При создании универсальных типов можно сделать много всего полезного. Вывод типов делает работу с универсальными типами более удобной и упрощает её.
function getProperty( object: ObjectType, key: KeyType )
При использовании вышеприведённой дженерик-функции нам не нужно в явном виде указывать типы.
const dog = < name: 'Fluffy' >; getProperty(dog, 'name');
Подобный приём, кроме прочего, весьма полезен при создании универсальных React-компонентов. Вот материал об этом.
Использование ключевого слова infer
Одна из наиболее продвинутых возможностей TypeScript, которая приходит в голову при разговоре о выводе типов, это — ключевое слово infer .
Рассмотрим пример. Создадим следующую функцию:
function call( functionToCall: (. args: any[]) => ReturnType, . args: any[] ): ReturnType
Вызовем, с помощью этой функции, другую функцию, и запишем то, что она вернёт, в константу:
const randomNumber = call(getRandomInteger, 100);
Предыдущее выражение позволяет нам получить то, что вернула функция getRandomInteger , которая получила на вход, в качестве верхней границы возвращаемого ей случайного целого числа, 100. Правда, тут имеется одна небольшая проблема. Она заключается в том, что ничто не мешает нам игнорировать типы аргументов функции getRandomInteger .
const randomNumber = call(getRandomInteger, '100'); // здесь нет ошибки
Так как TypeScript поддерживает spread- и rest-параметры в функциях высшего порядка, мы можем решить эту проблему так:
function call( functionToCall: (. args: ArgumentsType) => ReturnType, . args: ArgumentsType ): ReturnType
Теперь мы указали на то, что функция call может обрабатывать массив аргументов в любой форме, а также на то, что аргументы должны соответствовать ожиданиям переданной ей функции.
Попробуем теперь снова выполнить некорректный вызов функции:
const randomNumber = call(getRandomInteger, '100');
Это приводит к появлению сообщения об ошибке:
Argument of type ‘”100″‘ is not assignable to parameter of type ‘number’.
На самом деле, выполнив вышеописанные действия мы просто создали кортеж. Кортежи в TypeScript — это массивы с фиксированной длиной, типы значений которых известны, но не обязаны быть одинаковыми.
type Option = [string, boolean]; const option: Option = ['lowercase', true];
Особенности ключевого слова infer
Теперь давайте представим, что нашей целью является не получение того, что возвращает функция, а лишь получение сведений о типе возвращаемых ей данных.
type FunctionReturnType ?> = ?;
Вышеприведённый тип пока ещё не готов к работе. Нам нужно решить вопрос о том, как определить возвращаемое значение. Тут можно всё описать вручную, но это идёт вразрез с нашей целью.
type FunctionReturnType ReturnType> = ReturnType; FunctionReturnType;
Вместо того, чтобы делать это самостоятельно, мы можем попросить TypeScript вывести возвращаемый тип. Ключевое слово infer можно использовать только в условных типах. Именно поэтому наш код иногда может оказаться несколько неопрятным.
type FunctionReturnType any> = FunctionType extends (. args: any) => infer ReturnType ? ReturnType : any;
Вот что происходит в этом коде:
- Здесь сказано, что FunctionType расширяет (args: any) => any .
- Мы указываем на то, что FunctionReturnType — это условный тип.
- Мы проверяем, расширяет ли FunctionType (. args: any) => infer ReturnType .
FunctionReturnType; // number
Вышеописанное — это настолько распространённая задача, что в TypeScript имеется встроенная утилита ReturnType, которая предназначена для решения этой задачи.
Конструкция as const
Ещё один вопрос, относящийся к выводу типов, заключается в разнице ключевых слов const и let , используемых при объявлении констант и переменных.
let fruit = 'Banana'; const carrot = 'Carrot';
Переменная fruit — имеет тип string . Это означает, что в ней можно хранить любое строковое значение.
А константа carrot — это строковой литерал (string literal). Её можно рассматривать как пример подтипа string . В этом PR дано следующее описание строковых литералов: «Тип string literal — это тип, ожидаемым значением которого является строка с текстовым содержимым, эквивалентным такому же содержимому строкового литерала».
Это поведение можно изменить. В TypeScript 3.4 появилась новая интересная возможность, которая называется const assertions (константное утверждение) и предусматривает применение конструкции as const . Вот как выглядит её использование:
let fruit = 'Banana' as const;
Теперь fruit — это строковой литерал. Конструкция as const оказывается удобной ещё и тогда, когда некую сущность нужно сделать иммутабельной. Рассмотрим следующий объект:
const user = < name: 'John', role: 'admin' >;
В JavaScript ключевое слово const означает, что нельзя перезаписать то, что хранится в константе user . Но, с другой стороны, можно поменять внутреннюю структуру объекта, записанного в эту константу.
Сейчас объект хранит следующие типы:
const user: < name: string, role: string >;
Для того чтобы система воспринимала бы этот объект как иммутабельный, можно воспользоваться конструкцией as const :
const user = < name: 'John', role: 'admin' >as const;
Теперь типы изменились. Строки стали строковыми литералами, а не обычными строками. Но изменилось не только это. Теперь свойства предназначены только для чтения:
const user: < readonly name: 'John', readonly role: 'admin' >;
А при работе с массивами перед нами открываются ещё более мощные возможности:
const list = ['one', 'two', 3, 4];
Тип этого массива — (string | number)[] . Этот массив, используя as const , можно превратить в кортеж:
const list = ['one', 'two', 3, 4] as const;
Теперь тип этого массива выглядит так:
readonly ['one', 'two', 3, 4]
Всё это применимо и к более сложным структурам. Рассмотрим пример, который Андерс Хейлсберг привёл в своём выступлении на TSConf 2019:
const colors = [ < color: 'red', code: < rgb: [255, 0, 0], hex: '#FF0000' >>, < color: 'green', code: < rgb: [0, 255, 0], hex: '#00FF00' >>, < color: 'blue', code: < rgb: [0, 0, 255], hex: '#0000FF' >>, ] as const;
Наш массив colors теперь защищён от изменений, причём, защищены от изменений и его элементы:
const colors: readonly [ < readonly color: 'red'; readonly code: < readonly rgb: readonly [255, 0, 0]; readonly hex: '#FF0000'; >; >, /// . ]
Итоги
В этом материале мы рассмотрели некоторые примеры использования продвинутых механизмов вывода типов в TypeScript. Здесь использовано ключевое слово infer и механизм as const . Эти средства могут оказаться очень кстати в некоторых особенно сложных ситуациях. Например, тогда, когда нужно работать с иммутабельными сущностями, или при написании программ в функциональном стиле. Если вы хотите продолжить знакомство с этой темой — взгляните на данный материал.
Уважаемые читатели! Пользуетесь ли вы ключевым словом infer и конструкцией as const в TypeScript?
- Блог компании RUVDS.com
- Веб-разработка
- JavaScript
- TypeScript