Съдържание на курса
Урок 3 — Обекти и моделът на данните
От Dorian Chávez · основател на Hábil и архитект на интеграции ·
Време: 2 × 45 мин
Какво изграждаш: модела Servicio / Estado
Какво научаваш: type срещу interface, структурно типизиране, readonly, дискриминирани обединения за състояния
След урока ще можеш да
- Дефинираш обект
Servicioсъс задължителни свойства и обясниш какъв договор представлява всяко от тях. - Избираш между
typeиinterface, когато моделираш форма на данни или обединение. - Обясниш защо TypeScript приема обекти по формата им, а не по номинален етикет.
- Защитаваш свойства, които не бива да се присвояват наново, с
readonlyи разпознаваш границата му по време на изпълнение. - Представяш успешни и неуспешни резултати чрез дискриминирано обединение.
- Поправяш диагностиките TS2540, TS2339 и TS2741, без да изключваш
strict.
Защо, преди как
Досега revisor е имал прости стойности: име, URL, низ, който описва състояние. Това стига, за да се обясни една функция или да се провери, че средата се компилира, но оставя нерешен един важен въпрос: как да не допуснеш данните, които принадлежат на една и съща услуга, да се окажат разделени, смесени или използвани с различни имена?
В JavaScript можеш да пазиш информацията за една услуга в няколко отделни променливи:
const nombre = "catálogo";
const url = "https://catalogo.example";
const timeoutMs = 1500;
В тези три реда няма нищо неправилно. Проблемът се появява, когато има няколко услуги. Ще имаш nombreCatalogo, urlCatalogo, timeoutCatalogo, после nombrePagos, urlPagos, timeoutPagos и после ще трябва да помниш кои стойности си съответстват. JavaScript сам по себе си не различава името на една услуга от URL на друга. Една функция може да получи три аргумента в грешен ред и, ако всички са низове или съвместими числа, грешката може да остане незабелязана.
Обектът решава първата част от проблема: групира данни, които описват едно и също нещо. Вместо да пренасяш три несвързани стойности, пренасяш една Servicio. Името на свойствата показва какво представлява всяка стойност, а компилаторът може да провери, че обектът носи всички данни, от които програмата се нуждае.
Но един обект сам по себе си още не изразява всички правила на домейна. revisor не само познава конфигурирани услуги: произвежда и резултати. Наличният резултат има HTTP код и продължителност; неуспешният може би няма HTTP код, но има подробност за грешката. Ако моделираш двата резултата като един обект, пълен с незадължителни свойства, логиката завършва пълна с двусмислени въпроси: „кодът липсва, защото мрежата е отказала, или защото никой не го е присвоил?“, „мога ли да отпечатам detalle, макар състоянието да е налично?“, „какво значи, че двете полета съществуват едновременно?“.
Уроците се занимават с това да превърнат тези въпроси във видими договори. interface и type позволяват да се назоват форми на обект. Структурното типизиране позволява една функция да приеме стойност, защото има нужните свойства, а не защото идва от клас или е декларирала, че принадлежи към йерархия. readonly съобщава, че определена част от конфигурацията не бива да се променя след създаването ѝ. Дискриминираните обединения позволяват да се опишат взаимно изключващи се състояния и те принуждават да обработиш всеки път, преди да достъпиш специфични данни.
В Go struct групира полета под тип с име. TypeScript също използва обекти, за да групира данни, но се опира на една важна разлика: типовете му се проверяват преди изпълнението и се изтриват, когато се издава JavaScript. interface Servicio не създава клас, не конструира обекти и не съществува за Node, когато програмата работи. Това е статично описание на формата, която обектите трябва да имат вътре в TypeScript кода.
Тази разлика обяснява две последици. Първата е положителна: можеш да приложиш договор към обикновени обекти на JavaScript, без да ги пренаписваш като класове и без да ги караш да наследяват обща основа. Втората изисква внимание: не стига да напишеш тип, за да валидираш JSON, променлива на средата или HTTP отговор. Валидацията на тези граници ще дойде в урок 6. Тук ще моделираш стойности, които вече са надеждни вътре в програмата.
Целта не е да напълниш проекта с дълги типове. Целта е да направиш изрични решенията, които променят поведението на revisor: какво трябва да има една услуга, за да може да се проверява, кои данни не бива да се променят по време на проверка и каква информация съществува във всеки възможен резултат. Когато тези решения остават в типа, компилаторът може да открие невъзможни комбинации, преди таблото или API да се опитат да ги използват.
Понятията
Обекти: едно нещо с данни, които вървят заедно
Един обект в JavaScript събира двойки свойство и стойност. Фигурните скоби създават обекта; всяко свойство има име и стойност. Можеш да четеш свойство с точка, като servicio.nombre, или с квадратни скоби, като servicio["nombre"]. TypeScript тръгва от същия механизъм на JavaScript и добавя възможността да се опише кои свойства очаква програмата.
Разликата между „обект, който днес носи тези свойства“ и „обект, който програмата разпознава като Servicio“ е важна. Първият може да расте, да се променя или да пристигне непълен. Вторият е договор: трябва да има декларираните свойства и всяко от тях трябва да съдържа стойност от посочения тип. Компилаторът не проверява мрежа и не пита URL; проверява обаче дали обектен литерал, написан в програмата, изпълнява обещаната форма.
// fig03_01.ts
type Servicio = {
nombre: string;
url: string;
timeoutMs: number;
};
function etiqueta(servicio: Servicio): string {
return `${servicio.nombre} -> ${servicio.url}`;
}
const catalogo: Servicio = {
nombre: "catálogo",
url: "https://catalogo.example",
timeoutMs: 1500,
};
console.log(etiqueta(catalogo));
$ npx tsc --strict --target ES2022 --module nodenext fig03_01.ts
$ node fig03_01.js
catálogo -> https://catalogo.example
Функцията не получава три параметъра, чиято връзка трябва да помниш. Получава една Servicio, а типът документира, че тази единица има nombre, url и timeoutMs. Освен това е по-лесно да разширяваш договора съзнателно. Ако по-нататък програмата се нуждае от политика за повторни опити, можеш да добавиш reintentos към типа и да оставиш TypeScript да посочи местата, които сега трябва да решат стойността му.
В revisor обектът за конфигурация трябва да описва услугата, а не резултата от запитването ѝ. Servicio е стабилна, докато трае едно изпълнение: идентифицира какво се иска да се провери и с какъв лимит. Резултатът ще се представя с друг тип, наречен Estado. Разделянето на двете идеи избягва объркан обект, в който конфигуриран URL, наблюдаван HTTP код и съобщение за грешка се смесват, сякаш са един и същ вид данни.
// fig03_02.ts
interface Servicio {
nombre: string;
url: string;
timeoutMs: number;
}
function prepararConsulta(servicio: Servicio): string {
return `${servicio.nombre}: límite de ${servicio.timeoutMs} ms`;
}
const servicios: Servicio[] = [
{
nombre: "catálogo",
url: "https://catalogo.example",
timeoutMs: 1500,
},
{
nombre: "pagos",
url: "https://pagos.example",
timeoutMs: 3000,
},
];
for (const servicio of servicios) {
console.log(prepararConsulta(servicio));
}
$ npx tsc --strict --target ES2022 --module nodenext fig03_02.ts
$ node fig03_02.js
catálogo: límite de 1500 ms
pagos: límite de 3000 ms
Не превръщай всяка свързана данна в клас по навик. За повечето стойности на revisor обект с добре избран тип е достатъчен. Класовете добавят поведение при изпълнение, конструктори, прототипи и понякога наследяване. Нищо от това не е необходимо, за да се изрази, че една услуга има име, URL и лимит. Обикновен обект с ясен договор обикновено е по-директен и по-лесен за превръщане в JSON.
Не използвай и обектите като безформени торби със свойства, измисляни в движение. Анотация като Record<string, unknown> върши работа, когато наистина не познаваш ключовете, но една услуга има познат речник. Ако приемаш всякакви ключове за нещо, което има три конкретни свойства, губиш помощта, която типът можеше да ти даде.
type и interface: два близки инструмента, не два лагера
Псевдоним, създаден с type, дава име на всеки тип. Може да назове обект, обединение, литерал, масив или комбинация от други типове. Интерфейсът описва най-вече формата на обект: свойства, методи и връзки, които може да разширява. За проста форма на данни двете изглеждат почти еднакви.
// fig03_03.ts
interface Punto {
x: number;
y: number;
}
type Etiqueta = string;
function describir(punto: Punto, etiqueta: Etiqueta): string {
return `${etiqueta}: ${punto.x},${punto.y}`;
}
console.log(describir({ x: 4, y: 7 }, "origen de prueba"));
$ npx tsc --strict --target ES2022 --module nodenext fig03_03.ts
$ node fig03_03.js
origen de prueba: 4,7
Практическият избор за този курс е прост. Използвай interface за същности с форма на обект, които представляват разширяеми договори на програмата, като Servicio. Използвай type за обединения, композиции и имена на типове, които не са непременно обекти, като Estado, "disponible" | "falla" или string | undefined. Това не е закон на компилатора: и двата могат да описват много обекти. Това е конвенция, за да вижда този, който чете кода, веднага дали е пред същност или пред комбинация от възможности.
Има разлики, които е добре да знаеш, без да ги превръщаш в религиозен спор. Интерфейс може да разшири друг с extends и може да се декларира повече от веднъж; TypeScript комбинира декларации на интерфейс с едно и също име. Тази комбинация, наречена declaration merging, е полезна главно когато се разширяват декларации на библиотека. Псевдоним type не се отваря наново по този начин: ако го декларираш два пъти в един и същ обхват, е грешка. В замяна type може директно да представи обединение, нещо, което интерфейс не може.
Не декларирай два пъти интерфейс от домейна само защото компилаторът позволява да се комбинира. Ако една част на проекта добави timeoutMs, а друга добави equipo, крайният договор остава разпръснат между файлове и е трудно да се открие откъде идва всяко задължение. В revisor всяка същност от домейна ще има една основна декларация, разположена до останалите споделени типове.
Избягвай и да извеждаш несъществуваща разлика: interface не прави обектите по-бързи, не генерира валидация и не създава специален екземпляр. В издадения JavaScript двете декларации изчезват. Изборът е за съобщаване на намерение и за помощ на компилатора, а не за промяна на поведението на Node.
В revisor Servicio използва интерфейс, защото изразява стабилната форма на една конфигурация. Estado, напротив, ще бъде псевдоним на обединение, защото функцията му е да декларира взаимно изключващи се алтернативи. Прочитането на type Estado = EstadoDisponible | EstadoFalla съобщава идея, която единичен интерфейс не може да изрази сам: един резултат винаги принадлежи на конкретна алтернатива.
// fig03_04.ts
interface Servicio {
readonly nombre: string;
readonly url: string;
timeoutMs: number;
}
type EstadoInicial = {
servicio: Servicio;
tipo: "pendiente";
};
function presentarInicio(estado: EstadoInicial): string {
return `${estado.servicio.nombre}: pendiente`;
}
const estado: EstadoInicial = {
servicio: {
nombre: "catálogo",
url: "https://catalogo.example",
timeoutMs: 1500,
},
tipo: "pendiente",
};
console.log(presentarInicio(estado));
$ npx tsc --strict --target ES2022 --module nodenext fig03_04.ts
$ node fig03_04.js
catálogo: pendiente
Типът EstadoInicial още има само една алтернатива, защото revisor още не е запитал нищо. По-надолу ще разшириш модела, за да описва налични и неуспешни резултати. Важното е имената да не се преизползват за различни идеи: Servicio описва входа на запитването; Estado описва това, което запитването е наблюдавало.
Във fig03_04 timeoutMs още е променливо, за да покаже конфигурация по време на нормализирането ѝ; от fig03_07 моделът се променя и трите свойства на Servicio са readonly, защото вече представлява окончателната конфигурация на едно запитване.
readonly: защитава референция, не замразява света
Модификаторът readonly забранява ново присвояване на свойство от място, където TypeScript познава този договор. Полезен е за данни за идентичност и конфигурация, които не бива да се променят по време на операцията. В revisor промяната на nombre или url по средата на една проверка би затруднила тълкуването на отчета: би могъл да започнеш запитване за catálogo и да завършиш с отпечатването, че си проверил pagos.
// fig03_05.ts
interface Registro {
readonly id: string;
cliente: {
nombre: string;
};
}
const registro: Registro = {
id: "catalogo",
cliente: { nombre: "catálogo" },
};
registro.cliente.nombre = "catálogo público";
console.log(`${registro.id}: ${registro.cliente.nombre}`);
$ npx tsc --strict --target ES2022 --module nodenext fig03_05.ts
$ node fig03_05.js
catalogo: catálogo público
Примерът показва съществен нюанс: readonly е повърхностен. Пречи да се присвои наново registro.id и би попречил да се замени изцяло registro.cliente, ако това свойство беше readonly. Не пречи да се променят вътрешните свойства на cliente, защото cliente.nombre не е декларирано като само за четене. Не бъркай „свойство не може да сочи към друг обект“ с „обектът, към който сочи, е неизменяем“.
Това прилича на фиксиран етикет върху папка. Не можеш да заместиш папката, свързана с етикета, но можеш да редактираш лист вътре в нея, ако правилата ѝ го позволяват. Ако ти трябва цяла структура да е неизменяема, ще трябва да изразиш readonly на съответните нива, да използваш помощен тип като Readonly<T> или да проектираш операции, които построяват нови стойности. Това решение зависи от домейна; не е автоматична последица от това да сложиш една дума пред свойство.
readonly не съществува и като преграда при изпълнение. TypeScript го изтрива при компилация. Ако външен JavaScript получи референция към същия обект или ако някой използва твърдение, за да заобиколи договора, Node сам няма да блокира промяната. За да се предотвратят промени по време на изпълнение, съществува Object.freeze, макар и той да е повърхностен и да има други последици. На този етап readonly служи да изрази правило на дизайна и да даде диагностики преди изпълнението.
С TypeScript 7.0.2 следният файл отпечатва тази грешка, ако се опиташ да промениш свойство, декларирано като само за четене.
// fig03_06.ts
interface Registro {
readonly id: string;
}
const registro: Registro = { id: "catalogo" };
registro.id = "pagos";
$ npx tsc --strict --target ES2022 --module nodenext fig03_06.ts
fig03_06.ts(8,10): error TS2540: Cannot assign to 'id' because it is a read-only property.
В revisor отбележи като readonly свойствата, които идентифицират какво ще се пита: nombre и url. Не отбелязвай всичко автоматично. Лимитът timeoutMs би могъл да се променя от функция, която нормализира конфигурацията преди започването на запитванията; след тази граница би могъл да построиш окончателна Servicio с неизменяеми стойности. Полезният въпрос е „кой може да промени тази данна и в кой момент?“, а не „колко свойства мога да замразя?“.
// fig03_07.ts
interface Servicio {
readonly nombre: string;
readonly url: string;
readonly timeoutMs: number;
}
function destinoDe(servicio: Servicio): string {
return `${servicio.nombre}: ${servicio.url} (${servicio.timeoutMs} ms)`;
}
const pagos: Servicio = {
nombre: "pagos",
url: "https://pagos.example",
timeoutMs: 3000,
};
console.log(destinoDe(pagos));
$ npx tsc --strict --target ES2022 --module nodenext fig03_07.ts
$ node fig03_07.js
pagos: https://pagos.example (3000 ms)
Функцията destinoDe само трябва да чете услугата, затова може да приеме договора само за четене. Това съобщава на този, който я извиква, че функцията не бива да променя целта на запитването, нито да променя лимитите му. Ако една функция трябва да построи променена версия, за предпочитане е да върне нов обект с изричната промяна, вместо тихомълком да променя конфигурацията, която други части на програмата продължават да използват.
Структурно типизиране: важи формата, от която се нуждаеш
TypeScript има структурно типизиране. На практика, ако една стойност има изискваните свойства със съвместими типове, може да се използва там, където се иска тази форма. Не е нужно да декларира, че „имплементира“ интерфейса, нито да принадлежи към семейство от класове. Тази идея прилича на интерфейсите на Go: стойността е приемлива, защото удовлетворява това, от което функцията се нуждае, а не защото носи специален етикет.
// fig03_08.ts
interface ConNombre {
nombre: string;
}
function saludar(valor: ConNombre): string {
return `revisando ${valor.nombre}`;
}
const servicioCompleto = {
nombre: "catálogo",
url: "https://catalogo.example",
timeoutMs: 1500,
};
console.log(saludar(servicioCompleto));
$ npx tsc --strict --target ES2022 --module nodenext fig03_08.ts
$ node fig03_08.js
revisando catálogo
servicioCompleto има допълнителни свойства, но това не пречи да бъде подадено на saludar. Функцията е обещала само да чете nombre; да се изискват URL и таймаут би било добавяне на зависимост, от която не се нуждае. Тази възможност позволява да се проектират малки функции и малки договори.
Въпреки това структурното типизиране не значи, че трябва да правиш всички договори минимално възможни. Функция, която стартира HTTP запитване, наистина се нуждае от URL и лимит; параметърът ѝ трябва да е Servicio, а не само ConNombre. Принципът е да искаш точно това, което използваш, нито по-малко, нито повече. Ако искаш по-малко, може да скриеш реална зависимост; ако искаш повече, привързваш прости функции към подробности, които не им принадлежат.
Има допълнителна защита за обектни литерали, написани директно в извикване или присвояване. Ако напишеш saludar({ nombre: "catálogo", nombreVisible: "Catálogo" }), TypeScript може да предупреди, че nombreVisible не принадлежи на ConNombre. Тази проверка за излишни свойства улавя чести правописни грешки. Не противоречи на предишния пример: стойност, вече запазена в променлива, може да има повече свойства и да продължи да изпълнява по-малка форма.
В revisor един обобщен ред може да се нуждае само от името на услугата, докато операцията по запитване се нуждае от цялата конфигурация. Не е нужно да се създава йерархия от класове за тази разлика. Достатъчно е да се опише всеки договор според потребителя му.
// fig03_09.ts
interface ConNombre {
nombre: string;
}
interface Servicio extends ConNombre {
readonly url: string;
readonly timeoutMs: number;
}
function encabezado(servicio: ConNombre): string {
return `Servicio: ${servicio.nombre}`;
}
const catalogo: Servicio = {
nombre: "catálogo",
url: "https://catalogo.example",
timeoutMs: 1500,
};
console.log(encabezado(catalogo));
$ npx tsc --strict --target ES2022 --module nodenext fig03_09.ts
$ node fig03_09.js
Servicio: catálogo
Servicio extends ConNombre преизползва форма, защото всяка услуга има име. Въпреки това encabezado не е нужно да познава Servicio; зависи от по-малката форма, която консумира. Това разделяне ще е полезно, когато API и таблото споделят типове: всяка функция ще може да импортира договора, от който се нуждае, без да получава обект, по-свързан, отколкото е необходимо.
Избягвай да използваш структурното типизиране като разрешение да смесваш различни понятия само защото случайно имат една и съща форма. Два обекта с { nombre: string } са съвместими, дори единият да представлява услуга, а другият отговорно лице. Ако домейнът изисква да се различават дори когато споделят структура, ще ти трябва по-специфичен дизайн. За този курс имената на свойствата и ясните типове на домейна са достатъчни; техниките с номинални маркери се пазят за случаи, в които рискът оправдава тази сложност.
Дискриминирани обединения: всяко състояние носи свои данни
Обединението декларира, че една стойност може да е една от няколко алтернативи. Вече използва обединения от литерали като "disponible" | "falla". Дискриминираното обединение отива крачка напред: всяка алтернатива е обект, който споделя едно литерално свойство, наречено дискриминант, но съдържа собствени данни. Дискриминантът позволява на TypeScript да стесни типа, когато провериш стойността му.
За revisor дискриминантът ще бъде tipo. В този първи минимален пример наличното състояние носи codigoHttp, а неуспешното носи detalle. В пълния модел на следващата фигура се добавя duracionMs към наличния случай. Това не са незадължителни данни на общ обект; това са данни, които съществуват заради вида резултат, който е настъпил.
// fig03_10.ts
type EstadoDisponible = {
tipo: "disponible";
codigoHttp: number;
};
type EstadoFalla = {
tipo: "falla";
detalle: string;
};
type Estado = EstadoDisponible | EstadoFalla;
function descripcion(estado: Estado): string {
if (estado.tipo === "disponible") {
return `HTTP ${estado.codigoHttp}`;
}
return `falla: ${estado.detalle}`;
}
console.log(descripcion({ tipo: "disponible", codigoHttp: 204 }));
console.log(descripcion({ tipo: "falla", detalle: "tiempo agotado" }));
$ npx tsc --strict --target ES2022 --module nodenext fig03_10.ts
$ node fig03_10.js
HTTP 204
falla: tiempo agotado
Вътре в if TypeScript знае, че estado е EstadoDisponible, защото само тази алтернатива може да има tipo: "disponible". След if знае, че е останало EstadoFalla, защото обединението е имало точно две алтернативи. Това стесняване се нарича narrowing. Не е преобразуване на данни: обектът вече е имал конкретна форма; условието позволява на компилатора да определи коя.
Дизайнът избягва комбинации без смисъл. С слаб тип като този:
type EstadoDebil = {
tipo: "disponible" | "falla";
codigoHttp?: number;
detalle?: string;
};
би могъл да създадеш налично състояние без код, грешка без подробност или налично състояние, което освен това има подробността на грешка. Всички тези комбинации биха се компилирали, защото типът допуска незадължителни свойства, без да ги свързва с tipo. Дискриминираното обединение включва връзката в договора.
В revisor пълното състояние пази услугата заедно с резултата. Това дава възможност да се отпечата отчет, без да се възстановява коя услуга е произвела всяка данна. Забележи, че всеки вариант повтаря servicio; по-нататък ще можеш да извлечеш тази обща част, ако подобрява яснотата, но повторението на няколко свойства е за предпочитане пред скриването на труден за четене модел.
{
"name": "revisor",
"private": true,
"type": "module",
"scripts": {
"compilar": "tsc",
"verificar": "tsc --noEmit",
"arrancar": "node dist/main.js"
}
}
{
"compilerOptions": {
"strict": true,
"target": "ES2022",
"module": "nodenext",
"rootDir": "./src",
"outDir": "./dist",
"types": ["node"],
"sourceMap": true
},
"include": ["src"]
}
// fig03_11/src/modelo.ts
export interface Servicio {
readonly nombre: string;
readonly url: string;
readonly timeoutMs: number;
}
export type EstadoDisponible = {
servicio: Servicio;
tipo: "disponible";
codigoHttp: number;
duracionMs: number;
};
export type EstadoFalla = {
servicio: Servicio;
tipo: "falla";
detalle: string;
};
export type Estado = EstadoDisponible | EstadoFalla;
export function lineaReporte(estado: Estado): string {
if (estado.tipo === "disponible") {
return `${estado.servicio.nombre}: HTTP ${estado.codigoHttp} en ${estado.duracionMs} ms`;
}
return `${estado.servicio.nombre}: falla (${estado.detalle})`;
}
// fig03_11/src/main.ts
import { lineaReporte, type Estado } from "./modelo.js";
const catalogo: Estado = {
servicio: {
nombre: "catálogo",
url: "https://catalogo.example",
timeoutMs: 1500,
},
tipo: "disponible",
codigoHttp: 200,
duracionMs: 42,
};
const pagos: Estado = {
servicio: {
nombre: "pagos",
url: "https://pagos.example",
timeoutMs: 3000,
},
tipo: "falla",
detalle: "tiempo agotado",
};
console.log(lineaReporte(catalogo));
console.log(lineaReporte(pagos));
$ cd fig03_11
$ npm run compilar
> compilar
> tsc
$ npm run arrancar
> arrancar
> node dist/main.js
catálogo: HTTP 200 en 42 ms
pagos: falla (tiempo agotado)
Импортът използва type Estado, защото импортира само информация за компилатора. TypeScript премахва този импорт на тип от издадения JavaScript; lineaReporte, напротив, е истинска функция и се импортира, за да може Node да я изпълни. Разширението .js в относителния път продължава да е задължително, защото Node ще разреши издадения файл.
Не пиши условия, основани на наличието на свойство, когато вече имаш ясен дискриминант. Да попиташ if ("codigoHttp" in estado) може да сработи, но описва случайна подробност на представянето. Да попиташ if (estado.tipo === "disponible") изразява правилото на домейна: обработваш наличния случай. Кодът се чете по-лесно, а TypeScript може да стесни типа директно.
Когато добавиш трета алтернатива, например "cancelado", функциите, които обработват състоянията, трябва да решат какво да правят с нея. Защитата за изчерпателност (exhaustiveness check) прави това задължение проверимо: в default на switch присвояваш оставащото състояние на променлива never. never е типът, който представлява невъзможна стойност; ако всички алтернативи вече са обработени, TypeScript приема това присвояване. Това триене е предимство. Ново състояние не бива да се появява тихомълком в таблото, сякаш е позната грешка; защитата позволява на компилатора да посочи всяка функция, която трябва да добави клон.
Следващата програма добавя EstadoCancelado, но оставя switch непокътнат. С TypeScript 7.0.2 tsc отпечатва TS2322, защото в default още остава EstadoCancelado, което не може да се присвои на never.
// fig03_14.ts
type EstadoDisponible = {
tipo: "disponible";
codigoHttp: number;
};
type EstadoFalla = {
tipo: "falla";
detalle: string;
};
type EstadoCancelado = {
tipo: "cancelado";
motivo: string;
};
type Estado = EstadoDisponible | EstadoFalla | EstadoCancelado;
function descripcion(estado: Estado): string {
switch (estado.tipo) {
case "disponible":
return `HTTP ${estado.codigoHttp}`;
case "falla":
return `falla: ${estado.detalle}`;
default: {
const sinAtender: never = estado;
return sinAtender;
}
}
}
$ npx tsc --strict --target ES2022 --module nodenext fig03_14.ts
fig03_14.ts(26,13): error TS2322: Type 'EstadoCancelado' is not assignable to type 'never'.
Грешката, която ще видиш
TS2540 се появява, когато се опиташ да присвоиш наново свойство readonly, както стана във fig03_06.ts. Компилаторът не казва, че обектът е невъзможен за използване; сочи конкретна операция, която противоречи на договора. Правилната поправка зависи от намерението: ако идентификаторът наистина не бива да се променя, създай нов обект с новата стойност; ако е трябвало да може да се променя по време на етап на нормализиране, използвай променлив тип само в този етап и после построй окончателната стойност.
Друга честа диагностика при дискриминирани обединения е TS2339. Появява се, когато се опиташ да прочетеш свойство, което не съществува във всички алтернативи, без преди това да провериш дискриминанта.
// fig03_12.ts
type EstadoDisponible = {
tipo: "disponible";
codigoHttp: number;
};
type EstadoFalla = {
tipo: "falla";
detalle: string;
};
type Estado = EstadoDisponible | EstadoFalla;
function codigo(estado: Estado): number {
return estado.codigoHttp;
}
$ npx tsc --strict --target ES2022 --module nodenext fig03_12.ts
fig03_12.ts(15,17): error TS2339: Property 'codigoHttp' does not exist on type 'Estado'.
Property 'codigoHttp' does not exist on type 'EstadoFalla'.
TS2339 означава, че свойството не е гарантирано от сегашния тип. codigoHttp съществува за EstadoDisponible, но не и за EstadoFalla. Не използвай твърдение като estado as EstadoDisponible, за да скриеш диагностиката: ако резултатът наистина е грешка, това обещание би било невярно. Първо провери estado.tipo === "disponible"; само вътре в този клон HTTP кодът е достъпен.
TS2741 се появява при построяване на обект, който пропуска задължително свойство. Особено полезна е при промяна на модела, защото сочи всички конструкции, които вече не изпълняват договора.
// fig03_13.ts
interface Servicio {
nombre: string;
url: string;
timeoutMs: number;
}
const catalogo: Servicio = {
nombre: "catálogo",
url: "https://catalogo.example",
};
$ npx tsc --strict --target ES2022 --module nodenext fig03_13.ts
fig03_13.ts(8,7): error TS2741: Property 'timeoutMs' is missing in type '{ nombre: string; url: string; }' but required in type 'Servicio'.
Не поправяй TS2741, като добавяш измислени стойности като timeoutMs: 0, без да решиш какво значи нула. Може да е валидна стойност, може да значи „без таймаут“ или може да е опасна конфигурация. Диагностиката не просто иска свойство; иска от теб да разрешиш решение от домейна. Ако таймаутът трябва да е задължителен, предостави стойност, избрана съзнателно. Ако наистина може да липсва, моделирай това отсъствие и го обработи, преди да започнеш запитване.
Какво се прави погрешно
Да пазиш услуга в отделни променливи. Докато има една услуга, изглежда по-кратко. При няколко стойностите се смесват, а сигнатурите на функциите стават дълги и крехки. Обектът
Servicioпази заедно данните, които описват една и съща конфигурация.Да избираш
typeилиinterfaceкато абсолютно правило. И двата вършат работа за много обекти. Да използвашinterfaceза обектни същности иtypeза обединения е полезна конвенция; спорът кой е универсално по-добър отвлича от важния въпрос: каква форма трябва да допуска програмата.Да използваш
readonly, сякаш е сигурност при изпълнение. Модификаторът съществува само при проверката на типове и е повърхностен. Не валидира външни входове, не замразява вложени обекти и не пречи на JavaScript без типове да промени споделена референция.Да декларираш всички свойства като незадължителни в едно-единствено състояние. Тип с
codigoHttp?: numberиdetalle?: stringдопуска противоречиви комбинации. Дискриминираното обединение изразява кои свойства съществуват във всяка алтернатива и те принуждава да провериш случая, преди да го използваш.Да проверяваш случайни свойства вместо дискриминанта.
if ("detalle" in estado)зависи от начина, по който обектът е представен днес.if (estado.tipo === "falla")изразява решението на домейна и прави стесняването на типа по-ясно.Да използваш
as EstadoDisponible, за да махнеш TS2339. Твърдението не превръща грешка в наличен резултат. Ако стойността идва от обединение, безопасният път е да се стесни с дискриминанта. Ако идва отвън на програмата, първо трябва да се валидира.Да моделираш
ServicioиEstado, сякаш са една и съща същност. Услугата представлява намерението да се пита един URL; състоянието представлява това, което е станало при опита. Ако са разделени, наблюдаван отговор не променя случайно конфигурацията, която го е породила.
Упражнения
Упражнение 1 — Пълна услуга
Дефинирай интерфейс Servicio с nombre, url и timeoutMs, всички задължителни. Създай две услуги, catálogo и pagos, и функция etiqueta, която получава Servicio и отпечатва името и URL. Компилирай със strict; после премахни timeoutMs от един от обектите и обясни диагностиката, която се появява.
Упражнение 2 — Конфигурация, която не се променя
Промени Servicio, така че nombre, url и timeoutMs да са readonly. Опитай да присвоиш наново url след създаването на услуга и потвърди TS2540. После напиши функция conTimeout(servicio, timeoutMs), която връща нов обект Servicio с променен лимит, без да променя оригинала.
Упражнение 3 — Отчет с резултати
Дефинирай EstadoDisponible със servicio, tipo: "disponible", codigoHttp и duracionMs. Дефинирай EstadoFalla със servicio, tipo: "falla" и detalle. Създай type Estado като обединение на двете алтернативи и функция lineaReporte, която произвежда различен ред за всеки случай. Тествай поне един резултат от всеки вид.
Упражнение 4 — Ново състояние те принуждава да решиш
Добави EstadoCancelado с tipo: "cancelado" и motivo. Включи го в Estado. Пренапиши lineaReporte като switch с default, който присвоява състоянието на променлива never. Първо остави извън случая "cancelado" и потвърди TS2322; после добави клона му, за да го показва и него. Определи функциите, които имат тази защита за изчерпателност, и обясни защо това е за предпочитане пред отмяната да се появява като обща грешка.
Решения
Решение 1
Интерфейсът трябва да групира трите данни, необходими за започване на запитване. При изтриването на timeoutMs TypeScript произвежда TS2741, защото конфигурацията вече не изпълнява договора. Диагностиката е правилна: програмата още трябва да реши колко време може да чака, преди да обяви едно запитване за неуспешно.
interface Servicio {
nombre: string;
url: string;
timeoutMs: number;
}
function etiqueta(servicio: Servicio): string {
return `${servicio.nombre}: ${servicio.url}`;
}
Решение 2
readonly пречи да се присвоява на съществуващото свойство, но създаването на нова стойност е валидно. Функцията връща копие с новия лимит; операторът за разпростиране (spread) запазва останалите свойства.
function conTimeout(servicio: Servicio, timeoutMs: number): Servicio {
return {
...servicio,
timeoutMs,
};
}
Оригиналният обект не се променя. Това свойство е ценно, когато една и съща Servicio се споделя между кода, който сглобява отчета, и кода, който изпълнява запитването.
Решение 3
Решението трябва да попита за дискриминанта, преди да достъпи специфични данни на дадена алтернатива. Във всеки клон TypeScript стеснява типа автоматично.
function lineaReporte(estado: Estado): string {
if (estado.tipo === "disponible") {
return `${estado.servicio.nombre}: HTTP ${estado.codigoHttp} en ${estado.duracionMs} ms`;
}
return `${estado.servicio.nombre}: falla (${estado.detalle})`;
}
Не е нужно да се пита дали codigoHttp съществува: сравнението с tipo вече доказва, че стойността е EstadoDisponible.
Решение 4
Новата алтернатива трябва да се добави изрично към обединението и към функцията, която я представя.
type EstadoCancelado = {
servicio: Servicio;
tipo: "cancelado";
motivo: string;
};
След това lineaReporte се нуждае от клон за "cancelado" и от защита за изчерпателност в края на switch.
function lineaReporte(estado: Estado): string {
switch (estado.tipo) {
case "disponible":
return `${estado.servicio.nombre}: HTTP ${estado.codigoHttp} en ${estado.duracionMs} ms`;
case "falla":
return `${estado.servicio.nombre}: falla (${estado.detalle})`;
case "cancelado":
return `${estado.servicio.nombre}: cancelado (${estado.motivo})`;
default: {
const sinAtender: never = estado;
return sinAtender;
}
}
}
С трите клона estado вече е невъзможно в default, затова присвояването на never се компилира. Ако добавиш още една алтернатива към Estado и забравиш нейния case, TS2322 ще посочи тази функция. Това позволява да се различи умишлена отмяна от мрежова грешка и те принуждава да обновиш функциите, които са избрали защита за изчерпателност; функция без такава защита не може да обещае, че компилаторът ще я посочи.
Как разбирам, че съм успял
-
npx tsc --versionотпечатваVersion 7.0.2. -
node --versionзапочва сv24. - След като създадеш структурата на
fig03_11,cd fig03_11 && npm run compilar && npm run arrancarотпечатва точно:
catálogo: HTTP 200 en 42 ms
pagos: falla (tiempo agotado)
- Когато компилираш
fig03_06.tsсnpx tsc --strict --target ES2022 --module nodenext fig03_06.ts, получаваш TS2540 и не се опитваш да го изпълниш, сякаш се е компилирал. - Когато компилираш
fig03_12.tsсnpx tsc --strict --target ES2022 --module nodenext fig03_12.ts, получаваш TS2339 и можеш да обясниш защоcodigoHttpможе да се чете само след проверка наtipo. - Когато компилираш
fig03_14.tsсnpx tsc --strict --target ES2022 --module nodenext fig03_14.ts, получаваш TS2322; добавяшcase "cancelado"и защитатаneverотново се компилира безanyи без твърдения. - Можеш да обясниш, че
readonlyзащитава от ново присвояване по време на проверката на TypeScript, но сам по себе си не замразява обект в Node.
За допълнително четене
TypeScript Handbook: Object Types — официална документация за обекти, интерфейси, свойства
readonlyи структурна съвместимост; консултирано на 2 октомври 2026 г.TypeScript Handbook: Everyday Types — официална документация за интерфейси, псевдоними и практическите им разлики; консултирано на 2 октомври 2026 г.
TypeScript Handbook: Narrowing — официална документация за стесняване на типове и дискриминирани обединения; консултирано на 2 октомври 2026 г.
MDN: Object.freeze() — справочник за разликата между статично ограничение и замразяване на обекти по време на изпълнение; консултирано на 2 октомври 2026 г.
Предпочитате имейл? Пишете ни на hola@habil.mx