У меня есть несколько объектов 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 безопасным для типа способом?
Я не осознавал, что можно таким образом использовать this в охране типа. Это круто.
@jcalz: да, то, что вы предлагаете, сработает, спасибо! Я не рассматривал идею использования параметра this для «включения» методов.
Если подумать, эти типы в программе, над которой я работаю, не являются двоичными: если бы я мог перепроектировать все это в TS, у меня было бы объединение примерно шести разных типов, и вместо логического значения isInt я понадобится дискриминант id: "A"|"B"|"C".... Охранники нестандартных типов позволяли мне различать только один тип из всего объединения (в то время как дискриминанты позволяли мне различать все типы одновременно). Есть ли подобный хитрый трюк, позволяющий различать множество типов, из которых будет состоять мой союз?
Я сейчас пишу ответ, и этот дополнительный вопрос выходит за рамки заданного вопроса. Если вы хотите, чтобы на этот вопрос был дан ответ, вам следует подумать об открытии для него нового поста.
Да конечно. Возможно, я сделаю это после того, как ты ответишь.






Я понимаю, что вы здесь пытаетесь сделать, и у меня есть несколько предложений, но я не уверен на 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.
@BlueNebula, неудовлетворительный ответ... Я бы, вероятно, отказался от безопасности типов компиляции и хорошего интеллекта и просто вставил проверки во время выполнения (имейте в виду, что я не знаю вашего фактического варианта использования). Решение jcalz, по крайней мере, возвращает вам intellisense, а определяемые пользователем средства защиты типов — довольно надежный способ выполнять проверки во время выполнения.
Но на самом деле я придерживаюсь того, что сказал (и несмотря на решение jcalz) в своем ответе: наличие типа данных (даже концептуально, речь идет не только о статическом анализе), при котором некоторые операции недопустимы в зависимости от внутреннего состояния, является антипаттерном. Если вы моделируете, например, конечный автомат, и разные состояния — это разные вещи, тогда смоделируйте это таким образом в коде, а не изменяйте язык в попытке быть умным. Другими словами, на моем месте я бы изменил реализацию JS с Typescript или без него. У вас есть две разные вещи, а не одна вещь с двумя состояниями.
Я понимаю, что вы имеете в виду, и во многом согласен с вами. Проблема в том, что эти типы JS уже существуют и используются несколькими проектами в моей компании. Я пытаюсь перенести некоторые проекты моей команды в TS, но у меня нет возможности вносить в этот код изменения, нарушающие API. Я мог бы бороться за это, но поскольку в основном существуют работающие решения, было бы проще использовать их и сохранить свою боевую мощь для вещей, которые действительно нужно изменить. Я уверен, что смогу улучшить этот материал в будущем, но сейчас я хотел бы добавить типы TS в JS, не нарушая последнего.
@BlueNebula Я полностью понимаю, ты сам выбираешь сражения. Я думаю, что решение jcalz примерно настолько хорошее, насколько вы сможете получить.
На самом деле сделать это нелегко, особенно если 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 использовать их. Это означает, что я смогу реализовать ваше решение.
Вы можете возиться с использованием этих параметров и метода защиты пользовательского типа , но вам нужно
isInt()быть методом, а не геттером (без ms/TS#43368 ). См. ссылку на эту игровую площадку. Это полностью решает вопрос? Если да, то я напишу ответ с объяснением; если нет, то что мне не хватает?