Изследване на типовата система
Езиковата услуга на TypeScript
Езиковата услуга на TypeScript, известна още като tsserver, предлага различни функции, като отчитане на грешки, диагностика, компилиране при запазване, преименуване, преминаване към дефиниция, списъци за автодопълване, помощ за сигнатури и други. Тя се използва предимно от интегрираните среди за разработка (IDE), за да осигурява поддръжка на IntelliSense. Тя се интегрира безпроблемно с Visual Studio Code и се използва от инструменти като Conquer of Completion (Coc).
Разработчиците могат да използват специален API и да създават свои собствени плъгини за езиковата услуга, за да подобрят опита при редактиране на TypeScript. Това може да бъде особено полезно при имплементация на специални функции за проверка на кода или за активиране на автодопълване за персонализиран език за шаблони.
Пример за реален потребителски плъгин е “typescript-styled-plugin”, който предоставя докладване на синтактични грешки и IntelliSense поддръжка за CSS свойства в styled components.
За повече информация и ръководства за бързо начало можете да се обърнете към официалната документация на TypeScript Wiki в GitHub: https://github.com/microsoft/TypeScript/wiki/
Структурна типизация
TypeScript се базира на структурна типова система. Това означава, че съвместимостта и еквивалентността на типовете се определят от реалната структура или дефиниция на типа, а не от името му или мястото на декларация, както при номинативните типови системи като C# или C.
Структурната типова система на TypeScript е проектирана въз основа на това как работи динамичната duck typing система на JavaScript по време на изпълнение.
Следният пример е валиден TypeScript код. Както може да се види, “X” и “Y” имат един и същи член “a”, въпреки че имат различни имена на декларациите. Типовете се определят от техните структури и в този случай, тъй като структурите са еднакви, те са съвместими и валидни.
type X = { a: string;};type Y = { a: string;};const x: X = { a: 'a' };const y: Y = x; // ВалидноОсновни правила за сравнение в TypeScript
Процесът на сравнение в TypeScript е рекурсивен и се прилага върху типове, вложени на всяко ниво.
Типът “X” е съвместим с “Y”, ако “Y” има поне същите елементи като “X”.
type X = { a: string;};const y = { a: 'A', b: 'B' }; // Вярно, тъй като има поне същите членове като Xconst r: X = y;Параметрите на функциите се сравняват по типове, а не по имена:
type X = (a: number) => void;type Y = (a: number) => void;let x: X = (j: number) => undefined;let y: Y = (k: number) => undefined;y = x; // Валидноx = y; // ВалидноТиповете на връщаните стойности от функции трябва да са еднакви:
type X = (a: number) => undefined;type Y = (a: number) => number;let x: X = (a: number) => undefined;let y: Y = (a: number) => 1;y = x; // Невалидноx = y; // НевалидноТипът на връщаната стойност на изходната функция трябва да бъде подтип на типа на връщаната стойност на целевата функция:
let x = () => ({ a: 'A' });let y = () => ({ a: 'A', b: 'B' });x = y; // Валидноy = x; // Липсва невалиден елемент bДопуска се изключването на параметри на функции, тъй като това е обичайна практика в JavaScript, например при използването на “Array.prototype.map()“:
[1, 2, 3].map((element, _index, _array) => element + 'x');Следователно следните декларации на типове са напълно валидни:
type X = (a: number) => undefined;type Y = (a: number, b: number) => undefined;let x: X = (a: number) => undefined;let y: Y = (a: number) => undefined; // Липсва невалиден параметър by = x; // ВалидноВсички допълнителни опционални параметри на изходния тип са валидни:
type X = (a: number, b?: number, c?: number) => undefined;type Y = (a: number) => undefined;let x: X = a => undefined;let y: Y = a => undefined;y = x; // Валидноx = y; //ВалидноВсички опционални параметри на целевия тип без съответстващи параметри в изходния тип са валидни и не са грешка:
type X = (a: number) => undefined;type Y = (a: number, b?: number) => undefined;let x: X = a => undefined;let y: Y = a => undefined;y = x; // Валидноx = y; // ВалидноПараметърът “rest” се третира като безкрайна поредица от опционални параметри:
type X = (a: number, ...rest: number[]) => undefined;let x: X = a => undefined; //валидноФункциите с overloads са валидни, ако сигнатурата на overload е съвместима със сигнатурата на неговата реализация:
function x(a: string): void;function x(a: string, b: number): void;function x(a: string, b?: number): void { console.log(a, b);}x('a'); // Валидноx('a', 1); // Валидно
function y(a: string): void; // Невалидно, несъвместимо със сигнатурата на реализациятаfunction y(a: string, b: number): void;function y(a: string, b: number): void { console.log(a, b);}y('a');y('a', 1);Сравнението на параметрите на функциите е успешно, ако изходните и целевите параметри могат да бъдат присвоени към супертипове или подтипове (bivariance).
// Supertypeclass X { a: string; constructor(value: string) { this.a = value; }}// Subtypeclass Y extends X {}// Subtypeclass Z extends X {}
type GetA = (x: X) => string;const getA: GetA = x => x.a;
// Bivariance does accept supertypesconsole.log(getA(new X('x'))); // Валидноconsole.log(getA(new Y('Y'))); // Валидноconsole.log(getA(new Z('z'))); // ВалидноEnum-овете могат да се сравняват и са валидни с числа и обратно, но сравняването на стойности от различни Enum типове е невалидно.
enum X { A, B,}enum Y { A, B, C,}const xa: number = X.A; // Валидноconst ya: Y = 0; // ВалидноX.A === Y.A; // НевалидноИнстанциите на даден клас се подлагат на проверка за съвместимост по отношение на частните и защитените им елементи:
class X { public a: string; constructor(value: string) { this.a = value; }}
class Y { private a: string; constructor(value: string) { this.a = value; }}
let x: X = new Y('y'); // НевалидноПри сравнителната проверка не се взема предвид разликата в йерархията на наследяване, например:
class X { public a: string; constructor(value: string) { this.a = value; }}class Y extends X { public a: string; constructor(value: string) { super(value); this.a = value; }}class Z { public a: string; constructor(value: string) { this.a = value; }}let x: X = new X('x');let y: Y = new Y('y');let z: Z = new Z('z');x === y; // Валидноx === z; // Валидно дори ако z е от друга йерархия на наследяванеGenerics се сравняват чрез техните структури въз основа на резултатния тип след прилагане на generic параметъра; сравнява се само крайният резултат като не-generic тип.
interface X<T> { a: T;}let x: X<number> = { a: 1 };let y: X<string> = { a: 'a' };x === y; // Невалидно тъй като аргументът за типа се използва в крайната структураinterface X<T> {}const x: X<number> = 1;const y: X<string> = 'a';x === y; // Валидно тъй като аргументът за типа не се използва в крайната структураКогато generics нямат зададен аргумент за тип, всички незададени аргументи се третират като типове “any”:
type X = <T>(x: T) => T;type Y = <K>(y: K) => K;let x: X = x => x;let y: Y = y => y;x = y; // ВалидноНе забравяйте:
let a: number = 1;let b: number = 2;a = b; // Валидно, всичко може да се присвои на себе си
let c: any;c = 1; // Валидно, всички типове могат да се присвоят на any
let d: unknown;d = 1; // Валидно, всички типове могат да се присвоят на unknown
let e: unknown;let e1: unknown = e; // Валидно, unknown може да бъде присвоен само на себе си и на anylet e2: any = e; // Валидноlet e3: number = e; // Невалидно
let f: never;f = 1; // Невалидно, нищо не може да се присвои на never
let g: void;let g1: any;g = 1; // Невалидно, променливата void не може да бъде присвоявана към или от нищо, освен от самата себе сиg = g1; // ВалидноИмайте предвид, че когато е активирана опцията “strictNullChecks”, “null” и “undefined” се третират по подобен начин като “void”; а в противен случай те се третират като “never”.
Типовете като множества
В TypeScript типът е множеството от възможни стойности. Това множество се нарича още домейн на типа. Всяка стойност на типа може да се разглежда като елемент в множеството. Типът определя ограниченията, които всеки елемент в множеството трябва да удовлетворява, за да се счита за член на това множество. Основната задача на TypeScript е да проверява и потвърждава дали едно множество е подмножество на друго.
TypeScript поддържа различни типове множества:
| Термин от теорията на множествата | TypeScript | Бележки |
|---|---|---|
| Празно множество | never | ”never” не съдържа нищо освен самото себе си |
| Множество с един елемент | undefined / null / literal type | |
| Крайно множество | boolean / union | |
| Безкрайно множество | string / number / object | |
| Универсално множество | any / unknown | Всеки елемент е член на “any”, а всяко множество е негово подмножество / “unknown” е типово безопасна алтернатива на “any” |
Ето няколко примера:
| TypeScript | Термин от теорията на множествата | Пример |
|---|---|---|
| never | ∅ (празно множество) | const x: never = ‘x’; // Грешка: Тип ‘string’ не е присвоим към тип ‘never’ |
| Literal type | Множество с един елемент | type X = ‘X’; |
| type Y = 7; | ||
| Стойност присвоима към T | Стойност ∈ T (член на) | type XY = ‘X’ | ‘Y’; |
| const x: XY = ‘X’; | ||
| T1 присвоим към T2 | T1 ⊆ T2 (подмножество на) | type XY = ‘X’ | ‘Y’; |
| const x: XY = ‘X’; | ||
| const j: XY = ‘J’; // Тип ‘“J”’ не е присвоим към тип ‘XY’. | ||
| T1 extends T2 | T1 ⊆ T2 (подмножество на) | type X = ‘X’ extends string ? true : false; |
| T1 | T2 | T1 ∪ T2 (обединение) | type XY = ‘X’ | ‘Y’; |
| type JK = 1 | 2; | ||
| T1 & T2 | T1 ∩ T2 (сечение) | type X = { a: string } |
| type Y = { b: string } | ||
| type XY = X & Y | ||
| const x: XY = { a: ‘a’, b: ‘b’ } | ||
| unknown | Универсално множество | const x: unknown = 1 |
Обединение, (T1 | T2) създава по-широко множество (и двете):
type X = { a: string;};type Y = { b: string;};type XY = X | Y;const r: XY = { a: 'a', b: 'x' }; // ВалидноСечение, (T1 & T2) създава по-тясно множество (само общите елементи):
type X = { a: string;};type Y = { a: string; b: string;};type XY = X & Y;const r: XY = { a: 'a' }; // Невалидноconst j: XY = { a: 'a', b: 'b' }; // ВалидноКлючовата дума extends може да се разглежда като “подмножество на” в този контекст. Тя задава ограничение за даден тип. Когато extends се използва с generic, generic типът се разглежда като безкрайно множество и се ограничава до по-специфичен тип.
Имайте предвид, че extends няма нищо общо с йерархия в смисъла на ООП (такова понятие на практика не съществува в TypeScript).
TypeScript работи с множества и няма строга йерархия. Всъщност, както е показано в примера по-долу, два типа могат да се припокриват, без нито един от
тях да е подтип на другия (TypeScript разглежда структурата и формата на обектите).
interface X { a: string;}interface Y extends X { b: string;}interface Z extends Y { c: string;}const z: Z = { a: 'a', b: 'b', c: 'c' };interface X1 { a: string;}interface Y1 { a: string; b: string;}interface Z1 { a: string; b: string; c: string;}const z1: Z1 = { a: 'a', b: 'b', c: 'c' };
const r: Z1 = z; // ВалидноПрисвояване на тип: Декларации и проверки на типове
Тип може да бъде зададен по различни начини в TypeScript:
Декларация на тип
В следния пример използваме x: X (”: Type”), за да декларираме тип за променливата x.
type X = { a: string;};
// Декларация на типconst x: X = { a: 'a',};Ако променливата не е в посочения формат, TypeScript ще отчете грешка. Например:
type X = { a: string;};
const x: X = { a: 'a', b: 'b', // Error: Object literal may only specify known properties};Проверка на тип (Type Assertion)
Може да се добави проверка чрез ключовата дума as. Това указва на компилатора, че разработчикът разполага с повече информация за даден тип, и потиска евентуалните грешки.
Например:
type X = { a: string;};const x = { a: 'a', b: 'b',} as X;В горния пример обектът x е деклариран като тип X чрез използването на ключовата дума as. Това информира TypeScript компилатора, че обектът отговаря на посочения тип, въпреки че има допълнително свойство b, което не присъства в дефиницията на типа.
Type assertions са полезни в ситуации, когато трябва да се посочи по-специфичен тип, особено при работа с DOM. Например:
const myInput = document.getElementById('my_input') as HTMLInputElement;Тук type assertion as HTMLInputElement се използва, за да се укаже на TypeScript, че резултатът от getElementById трябва да бъде третиран като HTMLInputElement. Type assertions могат също да се използват за пренасочване (remap) на ключове, както е показано в примера по-долу с template literals:
type J<Type> = { [Property in keyof Type as `prefix_${string & Property}`]: () => Type[Property];};type X = { a: string; b: number;};type Y = J<X>;В този пример типът J<Type> използва mapped type с template literal, за да пренасочи ключовете на Type. Той създава нови свойства с добавен префикс “prefix_” към всеки ключ, като съответните им стойности са функции, които връщат оригиналните стойности на свойствата.
Струва си да се отбележи, че при използване на type assertion TypeScript няма да извърши excess property checking. Затова обикновено е за предпочитане да се използва декларация на тип, когато структурата на обекта е известна предварително.
Ambient Declarations
Ambient declarations са файлове, които описват типове за JavaScript код. Те имат файлов формат .d.ts. Обикновено се импортират и използват за анотиране на съществуващи JavaScript библиотеки или за добавяне на типове към съществуващи JS файлове във вашия проект.
Типовете за много често използвани библиотеки могат да бъдат намерени на: https://github.com/DefinitelyTyped/DefinitelyTyped/
и могат да бъдат инсталирани с помощта на:
npm install --save-dev @types/library-nameЗа вашите дефинирани Ambient Declarations, можете да ги импортирате, използвайки “triple-slash” reference:
/// <reference path="./library-types.d.ts" />Можете да използвате Ambient Declarations дори и в JavaScript файлове, като използвате // @ts-check.
Ключовата дума declare позволява дефиниране на типове за съществуващ JavaScript код, без да е необходимо да се импортира, като служи като заместител за типове от друг файл или глобално.
Проверка на свойства и проверка за излишни свойства
TypeScript се базира на структурна типова система, но проверката за излишни свойства е характеристика на TypeScript, която му позволява да проверява дали обектът има точно определените свойства, посочени в типа.
Проверката за излишни свойства се извършва например при присвояване на обектни литерали на променливи или при предаването им като аргументи на функцията за излишни свойства.
type X = { a: string;};const y = { a: 'a', b: 'b' };const x: X = y; // Валидно благодарение на структурното типизиранеconst w: X = { a: 'a', b: 'b' }; // Невалидно поради проверка за излишък на свойстваСлаби типове
Един тип се счита за слаб, когато съдържа единствено множество от изцяло опционални свойства:
type X = { a?: string; b?: string;};TypeScript счита за грешка присвояването на каквото и да е към слаб тип, когато няма припокриване, например следният код ще хвърли грешка:
type Options = { a?: string; b?: string;};
const fn = (options: Options) => undefined;
fn({ c: 'c' }); // НевалидноВъпреки че не се препоръчва, ако е необходимо, е възможно да се заобиколи тази проверка, като се използва type assertion:
type Options = { a?: string; b?: string;};const fn = (options: Options) => undefined;fn({ c: 'c' } as Options); // ВалидноИли като добавите unknown към сигнатурата на индекса на слабия тип:
type Options = { [prop: string]: unknown; a?: string; b?: string;};
const fn = (options: Options) => undefined;fn({ c: 'c' }); // ВалидноСтрога проверка на обектни литерали (Freshness)
Strict object literal checking, понякога наричана “freshness”, е функционалност в TypeScript, която помага да се откриват излишни или грешно изписани свойства, които иначе биха останали незабелязани при стандартните структурни проверки на типовете.
Когато се създава обектен литерал, TypeScript компилаторът го счита за “fresh”. Ако обектният литерал бъде присвоен на променлива или подаден като параметър, TypeScript ще върне грешка, ако са зададени свойства, които не съществуват в целевия тип.
Въпреки това “freshness” изчезва, когато обектният литерал бъде разширен (widened) или когато се използва type assertion.
Ето няколко примера за илюстрация:
type X = { a: string };type Y = { a: string; b: string };
let x: X;x = { a: 'a', b: 'b' }; // Проверка за валидност: Невалидно присвояванеvar y: Y;y = { a: 'a', bx: 'bx' }; // Проверка за валидност: Невалидно присвояване
const fn = (x: X) => console.log(x.a);
fn(x);fn(y); // Разширяване: Без грешки, съвместимост на типовете по структура
fn({ a: 'a', bx: 'b' }); // Проверка за валидност: Невалидно присвояване
let c: X = { a: 'a' };let d: Y = { a: 'a', b: '' };c = d; // Разширяване: Без проверка за валидностИзвеждане на типове
TypeScript може да извежда типове, когато не е предоставена анотация при:
- Инициализация на променлива.
- Инициализация на член (member).
- Задаване на стойности по подразбиране за параметри.
- Типа на връщаната стойност от функция.
Например:
let x = 'x'; // Типът, който се извежда, е stringКомпилаторът на TypeScript анализира стойността или израза и определя неговия тип въз основа на наличната информация.
По-сложни изводи
Когато при извеждането на типове се използват няколко израза, TypeScript търси “най-добрите общи типове”. Например:
let x = [1, 'x', 1, null]; // Типът, който се извежда, е (string | number | null)[]Ако компилаторът не успее да намери най-подходящите общи типове, той връща тип “union”. Например:
let x = [new RegExp('x'), new Date()]; // Типът, който се извежда, е (RegExp | Date)[]TypeScript използва “contextual typing”, базирано на местоположението на променливата, за да изведе типовете. В следния пример компилаторът знае, че e е от тип MouseEvent, благодарение на типа на събитието click, дефиниран във файла lib.d.ts, който съдържа общи декларации за различни често срещани конструкции в JavaScript и DOM:
window.addEventListener('click', function (e) {}); // Типът, който се извежда за "e" е "MouseEvent"Разширяване на типовете
Разширяването на типовете е процесът, при който TypeScript присвоява тип на инициализирана променлива, когато не е зададена типова анотация. То позволява преминаване от по-тесен към по-широк тип, но не и обратното. В следния пример:
let x = 'x'; // TypeScript извежда като string, широк типlet y: 'y' | 'x' = 'y'; // Типът на y е "union" на литерални типовеy = x; // Невалиден Тип 'string' не може да бъде присвоен на типа '"x" | "y"'.TypeScript присвоява string на x въз основа на единствената стойност, предоставена по време на инициализацията (x), това е пример за разширяване.
TypeScript предоставя начини за контрол на процеса на разширяване, например чрез използване на “const”.
Const
Използването на ключовата дума const при деклариране на променлива води до по-тясно извеждане на типове в TypeScript.
Например:
const x = 'x'; // TypeScript извежда типа на x като 'x', по-тесен типlet y: 'y' | 'x' = 'y';y = x; // Валидно: Типът на x е изведен като 'x'Чрез използването на const за деклариране на променливата x, нейният тип се стеснява до конкретната литерална стойност ‘x’. Тъй като типът на x е по-тесен, той може да бъде присвоен на променливата y без грешка. Причината е, че const променливите не могат да бъдат преназначавани, затова техният тип може да бъде стеснен до конкретен литерален тип — в този случай ‘x’.
Const Modifier on Type Parameters
От версия 5.0 на TypeScript е възможно да се зададе const атрибут върху generic параметър. Това позволява извеждане на възможно най-точния тип. Нека видим пример без използване на const:
function identity<T>(value: T) { // Няма const тук return value;}const values = identity({ a: 'a', b: 'b' }); // Изведения тип е: { a: string; b: string; }Както се вижда, свойствата a и b са изведени като тип string.
Сега нека видим разликата при използване на const:
function identity<const T>(value: T) { // Използване на const modifier върху type параметри return value;}const values = identity({ a: 'a', b: 'b' }); // Изведения тип е: { a: "a"; b: "b"; }Тук свойствата a и b са изведени като const, т.е. се третират като string литерали, а не просто като тип string.
Const assertion
Тази функционалност позволява да декларирате променлива с по-прецизен литерален тип въз основа на нейната начална стойност, като указва на компилатора, че стойността трябва да се третира като неизменим литерал. Ето няколко примера:
За отделно свойство:
const v = { x: 3 as const,};v.x = 3;За цял обект:
const v = { x: 1, y: 2,} as const;Това е особено полезно при дефиниране на тип за tuple:
const x = [1, 2, 3]; // number[]const y = [1, 2, 3] as const; // Tuple от readonly [1, 2, 3]Явна типова анотация
Можем изрично да зададем тип. В следния пример свойството x е от тип number:
const v = { x: 1, // Предполагаем тип: число (разширяване)};v.x = 3; // ВалидноМожем да направим типовата анотация по-конкретна, като използваме обединение на литерални типове:
const v: { x: 1 | 2 | 3 } = { x: 1, // x вече е обединение от литерални типове: 1 | 2 | 3};v.x = 3; // Валидноv.x = 100; // НевалидноСтесняване на типове
Стесняването на типове е процес в TypeScript, при който един по-общ тип се стеснява до по-специфичен. Това се случва, когато TypeScript анализира кода и установи, че определени условия или операции могат да уточнят типовата информация.
Стесняването на типове може да се случи по различни начини, включително:
Условия
Чрез използване на условни конструкции, като if или switch, TypeScript може да стесни типа въз основа на резултата от условието. Например:
let x: number | undefined = 10;
if (x !== undefined) { x += 100; // Типът е число, което е било ограничено от условието}Изключване на грешка или връщане от разклонение
Изключването на грешка или преждевременното връщане от разклонение може да се използва, за да помогне на TypeScript да стесни типа. Например:
let x: number | undefined = 10;
if (x === undefined) { throw 'error';}x += 100;Други начини за стесняване на типове в TypeScript включват:
- операторът
instanceof: използва се за проверка дали даден обект е инстанция на конкретен клас. - операторът
in: използва се за проверка дали дадено свойство съществува в обект. - операторът
typeof: използва се за проверка на типа на стойност по време на изпълнение. - вградени функции като
Array.isArray(): използват се за проверка дали дадена стойност е масив.
Discriminated Union
Използването на “Discriminated Union” е патърн в TypeScript, при който се добавя изричен “таг” към обектите, за да се разграничат различните типове в едно обединение. Този патърн се нарича още “tagged union”. В следния пример “тагът” е представен чрез свойството “type”:
type A = { type: 'type_a'; value: number };type B = { type: 'type_b'; value: string };
const x = (input: A | B): string | number => { switch (input.type) { case 'type_a': return input.value + 100; // типът е A case 'type_b': return input.value + 'extra'; // типът е B }};Потребителски type guards
В случаите, когато TypeScript не може да определи тип, е възможно да се напише помощна функция, известна като “user-defined type guard”. В следния пример ще използваме type predicate, за да стесним типа след прилагане на определено филтриране:
const data = ['a', null, 'c', 'd', null, 'f'];
const r1 = data.filter(x => x != null); // Типът е (string | null)[], TypeScript не успя да определи типа правилно
const isValid = (item: string | null): item is string => item !== null; // Потребителски type guard
const r2 = data.filter(isValid); // Типът вече е string[], като използвахме predicate type guard успяхме да стесним типаSwitch-true narrowing
TypeScript 5.3 добавя switch-true narrowing, което ви позволява да замените сложни if/else вериги с switch (true), използвайки булеви условия. Това подобрява четимостта и все още стеснява типовете. Подобно е на pattern matching, но по-просто.
function classify(x: unknown) { switch (true) { case typeof x === 'string': return `"${x.toUpperCase()}"`; case typeof x === 'number': return x > 0 ? 'positive' : 'negative'; case Array.isArray(x): return `[${x.length} items]`; default: return 'something else'; }}