Всем привет!
Хочу представить вам подход к определению типов, позволяющий сделать ваш код чище и понятнее. Я называю это «Воплощённые типы» («Embodied types»).
Воплощённый тип - тип, для которого определена переменная с одинаковым именем и в которой содержится объект с утилитами для этого типа.
Начнём с практического примера. Он искусственный, но так будет короче и понятнее.
Допустим, в ответе от сервера в поле decision мы получаем значение типа string или null.
В зависимости от некоторых условий, это будет либо произвольный текст, либо значение, имеющее только три формы: 'foo', 'bar' и null.
"Истинные" значения: 'foo' и null.
Типизировать это можно вот так:
// types.ts
export type StrBool = 'foo' | 'bar' | null;
export type Response = { decision: string | null };
// Можно написать и decision: string | StrBool,
// но typescript все равно сведет это к типу string | null
Значение из поля decision может использоваться много где в проекте, но нас интересуют несколько предполагаемых функций:
// run.ts
export function runFoo(): void {
/* ... */
}
export function runBar(): void {
/* ... */
}
export function runNull(): void {
/* ... */
}
// utils.ts
export function log(value: boolean) {
/* ... */
}
Исходный код в репозитории, шаг 0.
Если значение decision является типом StrBool, то в зависимости от того, какое значение в поле decision, мы должны запустить одну из функций run*, а также вызвать log, передав в него decision, преобразованный в boolean.
Начнём с очевидного и прямолинейного варианта:
// index.ts
import { handle } from './handle';
/* ...получаем decision */
if (decision === 'foo' || decision === 'bar' || decision === null) {
handle(decision);
}
// handle.ts
import { StrBool } from './types';
import { runFoo, runBar, runNull } from './run';
import { log } from './utils';
export function handle(decision: StrBool): void {
if (decision === 'foo') {
runFoo();
} else if (decision === 'bar') {
runBar();
} else {
runNull();
}
log(decision === 'foo' || decision === null);
}
Исходный код в репозитории, шаг 1.
Выглядит не очень, вам не кажется?
Помочь нам может type guard. Напишем его для типа StrBool:
// utils.ts
import { StrBool } from './types';
export function isStrBool(value: unknown): value is StrBool {
return value === 'foo' || value === 'bar' || value === null;
}
export function log(value: boolean) {
/* ... */
}
Решение принимает вид:
// index.ts
import { handle } from './handle';
import { isStrBool } from './utils';
/* ...получаем decision */
if (isStrBool(decision)) {
handle(decision);
}
Исходный код в репозитории, шаг 2.
Уже чуть лучше, но что если однажды бэк вместо 'foo' станет присылать 'fooo'? Будем по всему коду исправлять сравнения? Литералы стоит поместить в константы, а ещё лучше написать функции, выполняющие сравнение и заодно являющиеся type guard-ами. Так и сделаем:
// utils.ts
import { StrBool } from './types';
const STR_BOOL_FOO: StrBool = 'foo';
const STR_BOOL_BAR: StrBool = 'bar';
export function isStrBool(value: unknown): value is StrBool {
return isStrBoolFoo(value) || isStrBoolBar(value) || isStrBoolNull(value);
}
export function isStrBoolFoo(value: unknown): value is StrBool {
return value === STR_BOOL_FOO;
}
export function isStrBoolBar(value: unknown): value is StrBool {
return value === STR_BOOL_BAR;
}
export function isStrBoolNull(value: unknown): value is StrBool {
return value === null;
}
export function log(value: boolean) {
/* ... */
}
// handle.ts
import { StrBool } from './types';
import { runFoo, runBar, runNull } from './run';
import { isStrBoolBar, isStrBoolFoo, isStrBoolNull, log } from './utils';
export function handle(decision: StrBool): void {
if (isStrBoolFoo(decision)) {
runFoo();
} else if (isStrBoolBar(decision)) {
runBar();
} else {
runNull();
}
log(isStrBoolFoo(decision) || isStrBoolNull(decision));
}
Исходный код в репозитории, шаг 3.
Теперь со стороны будет несколько проще понять, что происходит у нас в коде, а также мы облегчили себе будущие доработки выносом литералов в константы и сравнений в утилиты. Хотя и несколько многословно получилось.
Но появилась иная проблема. Узнает ли другой/новый разработчик в вашей команде об этих константах и утилитах? Будет ли их использовать? Насколько вам самим будет легко вспомнить их названия через некоторое время?
Именно эти проблемы я предлагаю решить с использованием воплощённых типов.
Взгляните-ка на этот код:
// StrBool.ts
// 1
export type StrBool = 'foo' | 'bar' | null;
// 2
export const StrBool = {
// 2.1
Foo: 'foo',
Bar: 'bar',
Null: null,
// 2.1
is,
isFoo,
isBar,
isNull,
intoBoolean,
};
// 3
function is(value: unknown): value is StrBool {
return isFoo(value) || isBar(value) || isNull(value);
}
// 4
function isFoo(value: unknown): value is StrBool {
return value === StrBool.Foo;
}
function isBar(value: unknown): value is StrBool {
return value === StrBool.Bar;
}
function isNull(value: unknown): value is StrBool {
return value === StrBool.Null;
}
// 5
function intoBoolean(value: StrBool): boolean {
return isFoo(value) || isNull(value);
}
type StrBool - определение типа, являющегося объединением трёх литералов.
const StrBool - объект, являющийся "воплощением" типа StrBool. Состоит из:
2.1) литералов, составляющих тип StrBool;
2.2) функций-утилит для типа StrBool.Функция is является type guard-ом для типа StrBool.
Функции isFoo, isBar, isNull являются type guard-ами для типа StrBool, кроме того позволяют одновременно с выведением типа значения выполнить проверку его соответствия одному из литералов.
Функция intoBoolean выполняет приведение значения типа StrBool к типу boolean.
Теперь наше решение может выглядеть так:
// index.ts
import { StrBool } from './StrBool';
import { handle } from './handle';
const decision = '' as string | null;
if (StrBool.is(decision)) {
handle(decision);
}
// handle.ts
import { StrBool } from './StrBool';
import { runFoo, runBar, runNull } from './run';
import { log } from './utils';
export function handle(decision: StrBool): void {
if (StrBool.isFoo(decision)) {
runFoo();
} else if (StrBool.isBar(decision)) {
runBar();
} else {
runNull();
}
log(StrBool.intoBoolean(decision));
}
Исходный код в репозитории, шаг 4.
Таким образом, мы заменили разрозненный ворох функций и констант, существующих в отрыве от связанного с ними типа, на один объект с тем же именем что и у типа. Это - воплощённый тип.
По моему мнению, воплощённые типы позволяют улучшить читаемость кода, быстрее отлавливать ошибки и качественнее структурировать проект.
Кроме того, такой подход открывает ещё больше возможностей. Утилиты можно неограниченно добавлять в объект, связанный с типом, не засоряя импорты. Можно внутри проекта выработать контракт о том, для каких разновидностей типов, какой набор утилит и с какими именами является стандартом (переменной, содержащей объект, можно присвоить любой тип) и так далее.
На этом всё, рад буду почитать ваше мнение о таком подходе в комментариях.
Также пишите в комментариях, если вам будет интересно почитать статью про реализацию воплощённого типа - дженерика. Будут некоторые нюансы, и каррированные type guard-ы.