microsoft / TypeScript

TypeScript is a superset of JavaScript that compiles to clean JavaScript output.
https://www.typescriptlang.org
Apache License 2.0
100.4k stars 12.41k forks source link

Using special decorators for assertions and constraints #60093

Open AdamSobieski opened 20 hours ago

AdamSobieski commented 20 hours ago

🔍 Search Terms

typescript, inference, decorators, assertions, constraints

✅ Viability Checklist

⭐ Suggestion

I would like to request a feature involving the use of one or more special decorators for providing even more information about types, functions, and properties.

📃 Motivating Example

@constraint((t: T) => { return t.x > 0 && t.x <= 100 })
@constraint((t: T) => { return t.y.length > 0 && t.y.length <= 4 })
class T 
{
   x: number;
   y: Array<any>;
}
@constraint((t: T) => { return t.x > 0 && t.x <= 100 }, "A T's x is between 1 and 100.")
@constraint((t: T) => { return t.y.length > 0 && t.y.length <= 4 }, "A T only has between 1 and 4 y's.")
class T 
{
   x: number;
   y: Array<any>;
}

and, perhaps:

@constraint((t: T) => { return t.x > 0 && t.x <= 100 })
@constraint((t: T) => { return t.y.length > 0 && t.y.length <= 4 })
type T = 
{
   x: number;
   y: Array<any>;
}
@constraint((t: T) => { return t.x > 0 && t.x <= 100 }, "A T's x is between 1 and 100.")
@constraint((t: T) => { return t.y.length > 0 && t.y.length <= 4 }, "A T only has between 1 and 4 y's.")
type T = 
{
   x: number;
   y: Array<any>;
}

💻 Use Cases

  1. What do you want to use this for? Even more granular expressiveness about types. functions, and properties would be useful.

  2. What shortcomings exist with current approaches? With one or more special decorators, developers could express many more useful assertions and constraints.

  3. What workarounds are you using in the meantime? Workarounds are situation-dependent.

guillaumebrunerie commented 14 hours ago

Duplicate of #54925 (the functionality, not the syntax).

AdamSobieski commented 6 hours ago

@guillaumebrunerie, the expressiveness of the proposed feature would include, but not be limited to, that for numeric ranges. I think that the expressiveness usefully possible in the lambda expressions would resemble that for assert expressions.

class Widget
{
    public w: number;
}

@constraint((obj: Item) => { return obj.z.length === 2 }, 'there must be 2 elements in the z array.')
@constraint((obj: Item) => { return obj.z[1] instanceof Widget }, 'the second element of z must be a Widget')
@constraint((obj: Item) => { return typeof obj.z[0] === 'number'}, 'the first element of z must be a number')
@constraint((obj: Item) => { return obj.z !== null }, 'the z array must not be null.')
@constraint((obj: Item) => { return obj.x > obj.y }, 'x must be greater than y')
@constraint((obj: Item) => { return obj.x <= 100 }, 'x must be less than or equal to 100.')
@constraint((obj: Item) => { return obj.x > 0 }, 'x must be greater than 0.')
class Item
{
    public x: number;
    public y: number;
    public z: Array<any>;
}

One scenario which interests me, in particular, is being able to utilize the fact that $P \rightarrow Q$ = $\lnot P \vee Q$ in assertions and constraints. That is, it would be convenient to be able to express something like:

@constraint((obj: Example) => { return !obj.p || obj.q }, 'p implies q.')
class Example
{
    public p: boolean;
    public q: boolean;
}

Something like that would be intended to mean that after an assertion or in a conditional branch involving p being true, the reasoning engines would determine and the Intellisense would report to developers that q were also true.

A second scenario of interest is being able to constrain elements in an array or iterable, either specifying that there exists one element which satisfies a constraint or specifying that all elements must satisfy a constraint.

@constraint((obj: Item2) => { return obj.ws.some((value: Widget) => value.w === 1) }, "one Widget in ws must have w equal to 1.")
@constraint((obj: Item2) => { return obj.ws.every((value: Widget) => value.w > 0) }, "every Widget in ws must have w greater than 0.")
@constraint((obj: Item2) => { return obj.ws.every((value: Widget) => value instanceof Widget) }, "every element in ws must be a Widget.")
@constraint((obj: Item2) => { return obj.ws.length > 0 }, "ws must have at least one element.")
@constraint((obj: Item2) => { return obj.ws !== null }, "ws must not be null.")
@constraint((obj: Item2) => { return obj.ws !== undefined }, "ws must be defined.")
class Item2
{
    public ws: Array<Widget>;
}
guillaumebrunerie commented 6 hours ago

@AdamSobieski This seems way too vague and wide as a proposal, given that even range types seem very far from being implemented in Typescript. Your last use case can be implemented using a type with three different values instead of having two booleans:

declare const pAndQ: "bothPAndQ" | "onlyQ" | "neitherPNorQ";

Then p is pAndQ === "bothPAndQ", and q is pAndQ !== "neitherPNorQ".

AdamSobieski commented 4 hours ago

Thank you. I see your point about the timing of the proposal as numeric range types are still being discussed.

I updated the previous comment to broach constraints on the elements of arrays and iterables. This proposal appears to be getting wider as I attempt to make it less vague.

Also, for anyone interested in playing with the syntax ideas today, here is a prototype (ideas to improve it are welcomed):

import assert = require("assert");

interface Validatable
{
    validate(): void;
}

function constraint<TConstructor extends { new(...args: any[]): TType }, TType extends Validatable>(constraint: (arg: TType) => boolean, message: string): (ctor: TConstructor) => TConstructor
{
    return function (constructor: TConstructor): TConstructor
    {
        if ('validate' in constructor.prototype)
        {
            const original = constructor.prototype.validate;

            constructor.prototype.validate = function ()
            {
                original.call(this);
                assert(constraint(this), new Error('[' + constructor.name + '] ' + message));
            }
        }
        else
        {
            constructor.prototype.validate = function ()
            {
                assert(constraint(this), new Error('[' + constructor.name + '] ' + message));
            }
        }

        return constructor;
    };
}

class Widget
{
    public w: number;
}

@constraint((obj: Item2) => { return obj.ws.some((value: Widget) => value.w === 1) }, "at least one Widget in ws must have w equal to 1.")
@constraint((obj: Item2) => { return obj.ws.every((value: Widget) => value.w > 0) }, "every Widget in ws must have w greater than 0.")
@constraint((obj: Item2) => { return obj.ws.every((value: Widget) => value instanceof Widget) }, "every element in ws must be a Widget.")
@constraint((obj: Item2) => { return obj.ws.length > 0 }, "ws must have at least one element.")
@constraint((obj: Item2) => { return obj.ws !== null }, "ws must not be null.")
@constraint((obj: Item2) => { return obj.ws !== undefined }, "ws must be defined.")
class Item2 implements Validatable
{
    public ws: Array<Widget>;

    public validate() { }
}

let item = new Item2();

let w0: Widget = new Widget();
w0.w = 1;

item.ws = new Array<Widget>();
item.ws[0] = w0;

item.validate();