Как я могу ограничить набор операций, разрешенных для значения JavaScript, с помощью TypeScript?

У меня есть несколько объектов JavaScript, которые предоставляют множество операций, некоторые из которых недействительны в зависимости от состояния объекта во время выполнения (но постоянного). Я хочу использовать TypeScript, чтобы ограничить набор операций, доступных для этих объектов, чтобы нельзя было вызывать недопустимые операции.

Мне удалось добиться этого, определив дискриминируемое объединение и утвердив его как тип моих объектов.

Этот подход работает, однако у него есть проблема: я не знаю, как использовать TypeScript, чтобы убедиться, что мои объекты и дискриминируемое объединение совместимы, поскольку я принудительно задаю тип через утверждение.

Чтобы еще больше усложнить ситуацию, я хотел бы использовать этот подход для ввода определенных типов объектов, не обращая внимания на их состояние, поскольку они основаны на лени.

Минимальный пример

Вот очень минимальный и глупый пример, показывающий, что я пытаюсь сделать. Поиграться с этим в интерактивном режиме (есть небольшие несущественные отличия) можно здесь: https://tsplay.dev/Wk18pN

У меня есть класс MyNum, который оборачивает число. Некоторые его операции доступны только для некоторых видов чисел. Например, round() можно вызывать только для нецелых чисел, а incr() можно вызывать только для целых чисел:

class MyNum {
  n: number;
  get isInt() { return this.n%1 === 0; }
  
  constructor(n: number) { this.n = n; }

  incr() {
    if (!this.isInt) throw new Error(`incr() can only be called on integers`);
    return this.n+1;
  }
  round() {
    if (this.isInt) throw new Error(`round() can only be called on integers`);
    return Math.round(this.n);
  }
}

Проблема

Я хочу использовать TypeScript, чтобы статически гарантировать невозможность вызова несовместимых операций. Я хочу получить статическую ошибку, если попытаюсь сделать следующее:

function f(n: MyNum) {
  // the following line should fail at compile time,
  // since it can fail at runtime, if `!n.isInt`
  n.incr();
}

Мое решение

Мне удалось добиться желаемого, используя дискриминируемое объединение, где некоторые операции доступны только для некоторых подтипов. Тогда мне просто нужно добавить MyNum к такому союзу:

type NumBase = {
  n: number;
  isInt: boolean;
}
interface Int extends NumBase {
  isInt: true; // discriminant
  incr(): number;
}
interface Real extends NumBase {
  isInt: false; // discriminant
  round(): number;
}
type Num = Int | Real;

// with this I can create a `Num` (by casting a `MyNum`)
function myNum(n: number): Num {
  return new MyNum(n) as unknown as Num;
}

И это позволяет мне написать следующий типобезопасный код:

// this fails statically (as expected)
function f(n: Num) {
  n.incr(); // Error: Property 'incr' does not exist on type 'Num'
}

// this works (as expected)
function g(n: Num) {
  if (n.isInt) {
    n.incr() // OK, since `n` is `Int` in this branch
  } else {
    n.round() // OK, since `n` is `Real` in this branch
  }
}

Проблема с моим решением

Проблема в том, что TypeScript не гарантирует совместимость Num и MyNum. Если у меня есть опечатка в одном из полей Num, я не получу ошибки. Например, если бы я написал...

interface Int extends NumBase {
  isInt: true; // discriminant
  inxr(): Int; // notice the typo: `inxr` vs `incr`
}

...TypeScript все равно примет программу. Он не осознает, что MyNum не имеет inxr поля.

Дальнейшее осложнение: лень.

Я хотел бы использовать этот подход и для ленивых объектов. Это означает, что мне не разрешено просматривать состояние объектов для их ввода, поскольку это потребовало бы строгости:

class LazyNum implements NumBase {
  protected readonly compute: () => number;
  protected _n: number | undefined;
  get n() {
    if ( this._n === undefined ) this._n = this.compute();
    return this._n;
  }
  get isInt() { return this.n%1 === 0; }

  constructor(compute: () => number) {
    this.compute = compute;
  }

  incr() { return this.n+1; }
  round() { return Math.round(this.n); }
}
function lazyNum(compute: ()=>number): Num {
  return new LazyNum(compute) as unknown as Num;
}

Опять же, это по-прежнему работает нормально, но имеет те же проблемы, что и выше. Возможно, из-за этого будет немного сложнее определить lazyNum типобезопасным способом.

Вопрос

Как я могу ограничить набор операций, доступных для объекта JavaScript, используя TypeScript безопасным для типа способом?

Вы можете возиться с использованием этих параметров и метода защиты пользовательского типа , но вам нужно isInt() быть методом, а не геттером (без ms/TS#43368 ). См. ссылку на эту игровую площадку. Это полностью решает вопрос? Если да, то я напишу ответ с объяснением; если нет, то что мне не хватает?

jcalz 11.05.2024 15:21

Я не осознавал, что можно таким образом использовать this в охране типа. Это круто.

Jared Smith 11.05.2024 15:34

@jcalz: да, то, что вы предлагаете, сработает, спасибо! Я не рассматривал идею использования параметра this для «включения» методов.

Blue Nebula 11.05.2024 17:11

Если подумать, эти типы в программе, над которой я работаю, не являются двоичными: если бы я мог перепроектировать все это в TS, у меня было бы объединение примерно шести разных типов, и вместо логического значения isInt я понадобится дискриминант id: "A"|"B"|"C".... Охранники нестандартных типов позволяли мне различать только один тип из всего объединения (в то время как дискриминанты позволяли мне различать все типы одновременно). Есть ли подобный хитрый трюк, позволяющий различать множество типов, из которых будет состоять мой союз?

Blue Nebula 11.05.2024 19:06

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

jcalz 11.05.2024 19:07

Да конечно. Возможно, я сделаю это после того, как ты ответишь.

Blue Nebula 11.05.2024 19:08
Зод: сила проверки и преобразования данных
Зод: сила проверки и преобразования данных
Сегодня я хочу познакомить вас с библиотекой Zod и раскрыть некоторые ее особенности, например, возможности валидации и трансформации данных, а также...
Как заставить Remix работать с Mantine и Cloudflare Pages/Workers
Как заставить Remix работать с Mantine и Cloudflare Pages/Workers
Мне нравится библиотека Mantine Component , но заставить ее работать без проблем с Remix бывает непросто.
Угловой продивер
Угловой продивер
Оригинал этой статьи на турецком языке. ChatGPT используется только для перевода на английский язык.
TypeScript против JavaScript
TypeScript против JavaScript
TypeScript vs JavaScript - в чем различия и какой из них выбрать?
Синхронизация localStorage в масштабах всего приложения с помощью пользовательского реактивного хука useLocalStorage
Синхронизация localStorage в масштабах всего приложения с помощью пользовательского реактивного хука useLocalStorage
Не все нужно хранить на стороне сервера. Иногда все, что вам нужно, это постоянное хранилище на стороне клиента для хранения уникальных для клиента...
Что такое ленивая загрузка в Angular и как ее применять
Что такое ленивая загрузка в Angular и как ее применять
Ленивая загрузка - это техника, используемая в Angular для повышения производительности приложения путем загрузки модулей только тогда, когда они...
4
6
61
2
Перейти к ответу Данный вопрос помечен как решенный

Ответы 2

Я понимаю, что вы здесь пытаетесь сделать, и у меня есть несколько предложений, но я не уверен на 100%, что вы сможете полностью достичь того, чего хотите. Посмотрим.

Во-первых, я бы сказал, что наличие класса, в котором у вас есть операции, которые могут быть действительными, а могут и не быть, является чем-то вроде антишаблона, вам нужна фабрика, которая возвращает правильный тип на основе своего аргумента:

class Int implements NumBase {
  isInt = true

  constructor(public n: number) { }

  incr(): number {
    return this.n + 1;
  }
}

class Real implements NumBase {
  isInt = false

  constructor(public n: number) { }

  round(): number {
    return Math.round(this.n);
  }
}

type Num = Int | Real

function numFactory(n: number): Num {
  if (Math.round(n) === n) {
    return new Int(n);
  } else {
    return new Real(n);
  }
}

Теперь вы не можете вызвать неправильный метод для неправильного типа, компилятор не позволит вам предварительно распознать объединение, и вы можете вызвать фабрику со статически известной константой или динамическим значением из сетевого вызова и получить обратно правильный тип. А в вашем примере inxr вы довольно быстро получите ошибку компиляции, как только попытаетесь ее использовать (например, в модульном тесте).

Я также не уверен, что полностью понимаю суть класса LazyNum, придерживаясь этой темы, если у вас еще нет числа для передачи, вместо того, чтобы свойство n было потенциально неопределенным и «смешивало» два несовместимых типа, которые я просто отложил бы создание экземпляра класса и вызвал бы фабрику, когда все будет готово.

Я понимаю, что вы хотите скрыть лень за фасадом, но если вы это сделаете, у вас не будет возможности напечатать ее статически. Вы можете сделать так, чтобы защищенный _n был Num, и просто выбрасывать его во время выполнения, если вызывающий объект обращается к «неправильному» методу, по сути заставляя вызывающий объект проверять isInt. Для корректного вызова метода LazyNum можно просто делегировать его защищенному Num экземпляру, но вы не сможете заставить этот LazyNum класс работать каким-либо статически проверяемым способом, а объектно-ориентированный шаблон для обработки этого состоит в том, чтобы просто использовать фабрику. .

Полагаю, вопрос в том, какую часть вашего проекта вы готовы переработать, чтобы получить эти улучшения в типобезопасности? Вопрос, на который можете ответить только вы, но я изложил здесь подход, который вы можете использовать, если решите, что оно того стоит.

Редактировать

Учитывая комментарий jcalz: это позволяет вам в некоторой степени получить свой торт и съесть его, и это может быть лучшим подходом к тому, что вы хотите. Просто убедитесь, что логика в вашей защите пользовательского типа правильна, поскольку функционально это все еще является утверждением для компилятора (что, честно говоря, не делает его плохим).

Как бы вы справились с вашим предложением LazyNum? Я спрашивал конкретно о том, как типизировать существующий объект JS, а не о его повторной реализации в TS.

Blue Nebula 11.05.2024 16:50

@BlueNebula, неудовлетворительный ответ... Я бы, вероятно, отказался от безопасности типов компиляции и хорошего интеллекта и просто вставил проверки во время выполнения (имейте в виду, что я не знаю вашего фактического варианта использования). Решение jcalz, по крайней мере, возвращает вам intellisense, а определяемые пользователем средства защиты типов — довольно надежный способ выполнять проверки во время выполнения.

Jared Smith 11.05.2024 17:20

Но на самом деле я придерживаюсь того, что сказал (и несмотря на решение jcalz) в своем ответе: наличие типа данных (даже концептуально, речь идет не только о статическом анализе), при котором некоторые операции недопустимы в зависимости от внутреннего состояния, является антипаттерном. Если вы моделируете, например, конечный автомат, и разные состояния — это разные вещи, тогда смоделируйте это таким образом в коде, а не изменяйте язык в попытке быть умным. Другими словами, на моем месте я бы изменил реализацию JS с Typescript или без него. У вас есть две разные вещи, а не одна вещь с двумя состояниями.

Jared Smith 11.05.2024 17:22

Я понимаю, что вы имеете в виду, и во многом согласен с вами. Проблема в том, что эти типы JS уже существуют и используются несколькими проектами в моей компании. Я пытаюсь перенести некоторые проекты моей команды в TS, но у меня нет возможности вносить в этот код изменения, нарушающие API. Я мог бы бороться за это, но поскольку в основном существуют работающие решения, было бы проще использовать их и сохранить свою боевую мощь для вещей, которые действительно нужно изменить. Я уверен, что смогу улучшить этот материал в будущем, но сейчас я хотел бы добавить типы TS в JS, не нарушая последнего.

Blue Nebula 11.05.2024 19:00

@BlueNebula Я полностью понимаю, ты сам выбираешь сражения. Я думаю, что решение jcalz примерно настолько хорошее, насколько вы сможете получить.

Jared Smith 11.05.2024 19:54
Ответ принят как подходящий

На самом деле сделать это нелегко, особенно если isInt быть добытчиком. Если вы позволите isInt быть методом, вы можете получить то поведение, о котором говорите, но это довольно беспорядочно. Так:

class MyNum {

    n: number;
    isInt(): this is { __isInt: true } {
        return this.n % 1 === 0;
    }

    constructor(n: number) { this.n = n; }

    incr(this: { __isInt: true } & MyNum) {
        if (!this.isInt()) throw new Error(`incr() can only be called on integers`);
        return this.n + 1;
    }
    round(this: this extends { __isInt: true } ? never : MyNum) {
        if (this.isInt()) throw new Error(`round() can only be called on integers`);
        return Math.round(this.n);
    }
}

const n = new MyNum(123);
if (n.isInt()) {
    n.incr(); // okay
    n.round(); // error
} else {
    n.incr(); // error
    n.round(); // okay
}

Идея состоит в том, что isInt() — это метод защиты пользовательского типа, который делает вид, что isInt() возврат true добавляет к экземпляру фантомное __isInt свойство типа true.

Это не может работать, когда isInt() является геттером, поскольку геттеры не могут возвращать предикаты типа. По адресу microsoft/TypeScript#43368 есть открытый запрос на поддержку этой функции, но на данный момент это невозможно.


В любом случае, методы incr и round используют параметр this , чтобы проверить, есть ли в экземпляре свойство __isInt типа true. Если да, то incr() разрешено (поскольку this можно присвоить {__isInt: true}), а round() запрещено (поскольку this нельзя присвоить условному типу this extends { __isInt: true } ? never : MyNum). Если нет, то происходит обратный процесс.

Тип для round() более сложен, поскольку если isInt() возвращает false, то MyNum не сужается, и round() необходимо проверить наличие несуженного случая. Условный тип по сути выполняет эквивалент Exclude, так что если this было сужено, вы получаете never (что не удается), в противном случае вы получаете MyNum (что удается).


Это работает, но я не уверен, что рекомендую такой подход. Я бы рассмотрел это только в том случае, если бы вы не могли провести рефакторинг своего кода, исключив из одного класса с запрещенным поведением, основанным на состоянии... но если вы не можете провести рефакторинг, то isInt(), вероятно, нельзя будет превратить из геттера в метод. Но это, по крайней мере, близко к тому, о чем вы просите.

Детская площадка, ссылка на код

Спасибо, это лучшее решение, которое я нашел на данный момент, и ваше объяснение очень ясное. Я создам новый вопрос о том, как обрабатывать объединение нескольких типов. > но если вы не можете выполнить рефакторинг, то isInt(), вероятно, нельзя будет изменить с геттера на метод. Я не могу изменить isInt с геттера на метод, так как это нарушит существующий код. Но мне разрешено добавлять новые методы и новые геттеры и указывать пользователям TypeScript использовать их. Это означает, что я смогу реализовать ваше решение.

Blue Nebula 12.05.2024 12:26

Другие вопросы по теме