Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Using special decorators for assertions and constraints #60093

Closed
6 tasks done
AdamSobieski opened this issue Sep 29, 2024 · 9 comments
Closed
6 tasks done

Using special decorators for assertions and constraints #60093

AdamSobieski opened this issue Sep 29, 2024 · 9 comments
Labels
Out of Scope This idea sits outside of the TypeScript language design constraints Suggestion An idea for TypeScript

Comments

@AdamSobieski
Copy link

AdamSobieski commented Sep 29, 2024

πŸ” 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((obj: Foo) => { assert(obj.x > obj.y) })
@constraint((obj: Foo) => { assert(obj.y >= 0) })
@constraint((obj: Foo) => { assert(obj.x >= 0) })
class Foo
{
    x: number;
    y: number;
}

@constraint((obj: ExtendsFoo) => { assert(obj.z >= 0) })
class ExtendsFoo extends Foo
{
    z: number;
}

πŸ’» 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
Copy link

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

@AdamSobieski
Copy link
Author

AdamSobieski commented Sep 29, 2024

@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
Copy link

@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
Copy link
Author

AdamSobieski commented Sep 29, 2024

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();

@bgenia
Copy link

bgenia commented Sep 30, 2024

@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>;
}

It's not clear when these constraints are supposed to be executed. If they behave like class decorators by injecting constraint applications into expressions of type T this actually violates the "no emitting different JS based on the types of the expressions" rule.

Also what kind of type information do they provide? Are they supposed to be statically analyzable / executed by the type checker? If so, I suggest you reading through #52088 on why arbitrary programming in types is not a good idea.

@AdamSobieski
Copy link
Author

Thank you. I'm still reading and thinking about #52088. Initially, a self keyword reminded me of a proposal that I saw elsewhere for a this keyword to signify a special generic type-parameter for interfaces. As I remember, that proposal and discussion involved:

interface Cloneable<this T>
{
    clone(): T;
}
interface Tree<this T>
{
    readonly root: T;
    readonly parent: T;
    readonly children: Iterable<T>;
}

As an aside, I am recently thinking about the assured validity of objects, whether some objects can be always valid, and how to go about updating objects which are to be always valid using programming-language features such as setting the values of single properties, one at a time. Perhaps such always-valid objects could provide a method update() which would receive a sequence of instructions, or a script, to perform on the object. This update() method would return a boolean to indicate whether the update script, comprised of granular operations, was altogether successfully processed or whether it was rolled back. In either case, the object would always be in a valid state.

interface Updateable<this T>
{
    update(script: (arg: T) => void): boolean;
}

or

interface Updateable<this T>
{
    update(script: (arg: T) => void): UpdateResult;
}

To your question, yes, I was thinking that the contents of the lambda expressions in special decorators, e.g., @assert or @constraint, could be statically analyzable. One possibility with respect to narrowing the expressiveness in those lambdas in the decorators would involve that IDE's could provide visual "green checkmarks" or "red x's" to indicate which decorators' lambda expressions could be processed by a static analyzer and which could not. The expressiveness supported in such lambda expressions need not be arbitrary.

@AdamSobieski
Copy link
Author

AdamSobieski commented Oct 2, 2024

I am excited to share that I revised the syntax and implementation sketch for this proposal. The rationale for the new decorator syntax is to support developers being able to use template strings for custom assert() error messages involving the lambdas' arguments.

Now the proposed decorators add static members to constrained types, storing constraints in a static array on types. The validate() functions are static methods on constrained types. The implementation sketch works with inheritance.

No interfaces are required to be implemented by any constrained types.

Also, Errors thrown by constraints' calls to assert() are aggregated using AggregateError.

import assert = require("assert");

function* getConstrainedTypes(type: { new(...args: any[]): any }): Iterable<{ new(...args: any[]): any }>
{
    if (type !== undefined && type !== null)
    {
        const base = Object.getPrototypeOf(type);

        for (const t of getConstrainedTypes(base))
        {
            yield t;
        }

        if (Object.hasOwn(type, 'constraints') === true)
        {
            yield type;
        }
    }
}

function constraint<TConstructor extends { new(...args: any[]): TType }, TType>(constraint: (arg: TType) => void): (type: any) => any
{
    return function (type: any): any
    {
        if (Object.hasOwn(type, 'constraints') === false)
        {
            Object.defineProperty(type, 'constraints',
                {
                    value: new Array<Function>(),
                    writable: false,
                    enumerable: true,
                    configurable: false
                });

            Object.defineProperty(type, 'validate',
                {
                    value: function (obj: object)
                    {
                        let errors = new Array<Error>();

                        for (const t of getConstrainedTypes(this))
                        {
                            // @ts-ignore
                            for (const c of t.constraints)
                            {
                                try
                                {
                                    c(obj);
                                }
                                catch (e)
                                {
                                    errors.push(e);
                                }
                            }
                        }

                        if (errors.length == 1)
                        {
                            throw errors[0];
                        }
                        if (errors.length > 1)
                        {
                            throw new AggregateError(errors);
                        }
                    },
                    writable: false,
                    enumerable: true,
                    configurable: false
                });
        }

        type.constraints.push(constraint);

        return type;
    }
}

function freeze(type: any): any
{
    if (Object.hasOwn(type, 'constraints') === true)
    {
        Object.freeze(type.constraints);
    }
    return type;
}

@freeze
@constraint((obj: Foo) => { assert(obj.s >= 0) })
@constraint((obj: Foo) => { assert(obj.r >= 0) })
class Foo
{
    r: number;
    s: number;
}

@freeze
@constraint((obj: Foo2) => { assert(obj.u >= 0) })
@constraint((obj: Foo2) => { assert(obj.t >= 0) })
class Foo2 extends Foo
{
    t: number;
    u: number;
}

class Foo3 extends Foo2
{
    v: number;
}

@freeze
@constraint((obj: Foo4) => { assert(obj.w >= 0) })
@constraint((obj: Foo4) => { assert(obj.v >= 0) })
class Foo4 extends Foo3
{
    w: number
}

let obj = new Foo4();

obj.r = 6;
obj.s = 5;
obj.t = 4;
obj.u = 3;
obj.v = 2;
obj.w = 1;

// @ts-ignore
Foo4.validate(obj);

Hopefully, this version is a bit more enjoyable to look at, to consider, and to explore. I have also created a repository for these ideas. Thank you.

@RyanCavanaugh RyanCavanaugh added Suggestion An idea for TypeScript Out of Scope This idea sits outside of the TypeScript language design constraints labels Oct 16, 2024
@RyanCavanaugh
Copy link
Member

RyanCavanaugh commented Oct 16, 2024

We don't want to run usercode as part of typechecking, and we can't subtype types created by predicates ("does this program return true for a subset of inputs of this other program" is in general not decidable).

Doing this sort of thing at runtime is out of scope for TS; I recommend libraries like zod or its many variants if you want to do runtime schema validation with associated static typechecking.

@AdamSobieski
Copy link
Author

Understood. Thank you.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Out of Scope This idea sits outside of the TypeScript language design constraints Suggestion An idea for TypeScript
Projects
None yet
Development

No branches or pull requests

4 participants