Всем привет!

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

Комментарии (11)


  1. sumdy-c
    22.03.2024 10:08

    Предложение зачтено, но, что первое в голову пришло, не проще для этих целей использовать enum ? И никаких функций не нужно.

    enum Data {
      foo = "foo",
      bar = "bar",
      none = null,
    }
    
    const check = (data) => {
      switch (data) {
        case Data.foo:
          alert("foo");
          break;
        case Data.bar:
          alert("bar");
          break;
        case Data.none:
          alert("none");
          break;
        default:
          alert("default");
          break;
      }
    };
    
    const inputKey = "sdfsdf";
    check(inputKey);

    В рамках задачи, конкретно этой, такой подход проще вроде ) В любом случае ваш кому-то понравится)

    Спасибо за работу!


    1. makaevS Автор
      22.03.2024 10:08
      +1

      В рамках примера да, проще использовать enum. Но пример очень упрощён.

      Если рассматривать вопрос шире, то enum не поможет, если потребуется, например дженерик или объектный тип.

      В случае с объектным типом, для сбора утилит в одном месте подошёл бы и класс со статичными методами. Но он же не подошёл бы для примера с union type в статье.

      Мне тем и нравится подход с воплощёнными типами, что он позволяет эффективно и единообразно решить проблему для всех возможных видов типов.


  1. Nipheris
    22.03.2024 10:08

    А зачем вам такой объект? Модуль - уже и есть готовый объект с таким же предназначением. Разве так не проще?
    strbool.mts:

    export const foo = 'foo';
    export type Foo = typeof foo;
    
    export const bar = 'bar';
    export type Bar = typeof bar;
    
    type StrBool = Foo | Bar | null;
    export { type StrBool as default };
    
    export function is(value: unknown): value is StrBool {
      return isFoo(value) || isBar(value) || isNull(value);
    }
    
    export function isFoo(value: unknown): value is Foo {
      return value === foo;
    }
    
    export function isBar(value: unknown): value is Bar {
      return value === bar;
    }
    
    export function isNull(value: unknown): value is null {
      return value === null;
    }
    
    export function toBoolean(value: StrBool): boolean {
      return isFoo(value) || isNull(value);
    }
    

    Использование:

    import type StrBool from './strbool.mjs';
    import { is, isBar, isFoo } from './strbool.mjs';
    


    1. makaevS Автор
      22.03.2024 10:08

      1) Так "жирнее" импорты, может быть ощутимо в файле где используется много типов.

      2) Если у вас будет много типов, то получится та самая проблема, которую я предлагаю решить. Либо у вас везде type guard-ы называются is и тогда intellisense вам не поможет, либо каждый type guard в имени будет содержать имя типа, тогда опять же вспоминать названия и ещё "жирнее" импорты.

      Это все может не быть проблемами в небольших проектах, но с увеличением кодовой базы начнет сказываться.


  1. Onni
    22.03.2024 10:08

    export type StrBool = 'foo'& {_strbooltag?: 'StrBool'} | 'bar' & {_strbooltag?: 'StrBool'} | null;
    export type Response = { decision: Exclude<string, 'foo'|'bar'> | StrBool };

    Можно как-то так сделать


    1. makaevS Автор
      22.03.2024 10:08
      +1

      Можно, но это:

      1) не решает проблему с разрозненными утилитами;

      2) намного ближе к теме номинальных типов, её я не планировал затрагивать в данной статье.

      Кстати, номинальный тип можно определить как воплощённый (в соответствии с терминологией из статьи) и так с ним будет работать намного удобнее :)


      1. Onni
        22.03.2024 10:08

        Вообще когда я только начал изучать TS я ожидал что type guards позволяют вот так делать. Все еще мечтаю что добавят. С другой стороны typeof так умеет

        function getType(x: unknown): 
          x is { name: string } ? 'user' :
          x is { size: number } ? 'box' :
          x is number ? 'number' :
          x is string ? 'string' :
          never {
          ???
        }
        
        switch(getTupe(x)) {
          case 'user': log(x.name);
          case 'box': log(x.size);
          case 'number': log(x * x);
          case 'string': log(x);
        }


  1. meonsou
    22.03.2024 10:08
    +1

    Для такого в ТС уже есть неймспейсы

    type Foo = Something
    
    namespace Foo {
      export function util() {}
    
      export type NestedType = Something
    
      export const value = something
    }

    В них удобно прятать утилиты и детали реализации подобных вещей.

    Кстати, помимо "воплощённых" типов ещё можно городить воплощённые функции:

    function f() {}
    
    namespace f {
      export type Util = Something
      export const value = something
    }

    И даже воплощённые классы:

    class User { }
    
    namespace User {
      export type NestedType = Something
    }

    Касательно примера в статье:

    export type StrBool = 'foo' | 'bar' | null;
    export type Response = { decision: string | null };
    // Можно написать и decision: string | StrBool,
    // но typescript все равно сведет это к типу string | null

    Чтобы юнион не схлопывался можно записать так: StrBool | (string & {})


    1. makaevS Автор
      22.03.2024 10:08

      Согласен, с namespace-ами получится +- то же самое. Тут уже дело вкуса, как мне кажется. Статья в целом про подход, я материалов и примеров про такое их использование не нашёл.

      Юнион не схлопнется, но и смысла в нём не прибавится, т.к. все равно string уже включает в себя значения 'foo' и 'bar'.


      1. meonsou
        22.03.2024 10:08

        Юнион не схлопнется, но и смысла в нём не прибавится, т.к. все равно string уже включает в себя значения 'foo' и 'bar'.

        Смысл в том что эти литералы будут отображаться в подсказках


  1. MiyuHogosha
    22.03.2024 10:08

    А это не является реализацией шаблона "стратегия" или чего-то похожего?