Воплощённые типы

от автора

Всем привет!

Хочу представить вам подход к определению типов, позволяющий сделать ваш код чище и понятнее. Я называю это «Воплощённые типы» («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); }
  1. type StrBool — определение типа, являющегося объединением трёх литералов.

  2. const StrBool — объект, являющийся «воплощением» типа StrBool. Состоит из:

    2.1) литералов, составляющих тип StrBool;
    2.2) функций-утилит для типа StrBool.

  3. Функция is является type guard-ом для типа StrBool.

  4. Функции isFoo, isBar, isNull являются type guard-ами для типа StrBool, кроме того позволяют одновременно с выведением типа значения выполнить проверку его соответствия одному из литералов.

  5. Функция 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-ы.


ссылка на оригинал статьи https://habr.com/ru/articles/802213/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *