Home > OS >  How to restrict the allowed properties of a class in typescript
How to restrict the allowed properties of a class in typescript

Time:08-27

I need to restrict the properties names and the types a class can have. The only way I have found to do this is the following

type ForbiddenKeys = "x"|"y"|"z"

type Allowed = string|number|boolean|Set<string|number>|Array<string|number>|null|undefined
type AllowedObject = { [s:string]: Allowed | AllowedObject } & { [F in ForbiddenKeys]?: never }

class A {
    [s:string]: Allowed | AllowedObject 
    private x?: never
    private y?: never
    private z?: never
    static scan(): string {
        return "DUMMY static method return value"
    }
    save(): void {
        // DUMMY empty method
    }
}

this class will be used as an abstract class to make the compiler aware of hidden methods and forbidden property names that extending classes will have. The extending classes, will in fact have a decorator applied to them where the real logic of the methods resided

function addMethods<T extends { new (...args: any[]): {} }>(constructor: T) {
    return class extends constructor {
        static scan() {
            // Real logic goes here
            return "scan() static method got executed."
        }
        save() {
            console.log(`${JSON.stringify(this)} has been saved`)
            // REAL method logic goes here
        }
    }
}

@addMethods
class B extends A { // <-- Only properties definitions go here while methods are added by the decorator.
    x?: string // <-- Error as needed. We don't want "x" here
    a?: string
    b?: number
    c?: {
        d?: boolean
        //y?: string // <-- Error as needed. We don't want "y" here
    }
}

Follows an example usage

const b = new B()
b.a = "A"
b.b = 0
b.save() // <-- return value: void. Compiler must be aware of this. Decorator logic gets executed.
const scan = B.scan() // <-- const scan: string. Compiler must be aware of this.
console.log(scan) // <-- Prints: "scan() static method got executed."

This works until I need to work with the property names of the child class. Even a simple type which iterates over the properties of B, will not behave as desired because keyof T includes [s:string]

type Props<T> = {
    [K in keyof T]?: T[K]
}

const props: Props<B> = {
    a: "abcd",
    b: 0,
    anyProperty: "anything" // <-- No error. I need an error here.
}

The following type is a closer (simplified) example of what I do really need. It is a type which adds the forbidden properties to each key of the class and so does with its nested objects

type AddProps<T> = {
    [K in keyof T]?: T[K] extends Allowed ? { 
        [F in ForbiddenKeys]?: T[K] 
    } : T[K] extends (AllowedObject|undefined) ? AddProps<T[K]> : never
}

function addProps<T>(propsToAdd: AddProps<T>) {
    return propsToAdd
}

addProps<B>({  // <-- We don't want errors here.
    a: { x: "some string" }, 
    b: { y: 0 },
    c: { 
        d: {
            z: true
        }
    }
})

This cannot be done, because keyof T includes [s:string] and not only the properties I declared in class B

Question

Is there a way to achieve what I am after? Playground link

CodePudding user response:

The main issue here is that there is no specific object type in TypeScript which constrains value types without adding a string index signature. If I want to say that an object can only have, say, boolean-valued properties, then the only specific object type available to me is type Boo = {[k: string]: boolean}. But keyof Boo will be string, which you don't want.

Since we can't really write AllowedObject as a specific object type, we can try writing it as a generic constraint. That is, VerifyAllowed<T> checks a candidate type T for whether it is allowed or not. If T extends VerifyAllowed<T>, then it is allowed, otherwise it is not.

Here's one possible implementation of that:

type VerifyAllowed<T> = T extends Allowed ? T :
    T extends object ? {
        [K in keyof T]: K extends ForbiddenKeys ? never : VerifyAllowed<T[K]>
    } : never

If T is Allowed, then VerifyAllowed<T> will resolve to just T (and thus T extends VerifyAllowed<T> will be true). Otherwise, if T is an object type, we map each property T[K] to VerifyAllowed<T[K]> unless the key K is one of the ForbiddenKeys in which case we map it to never. So if none of the keys are forbidden, then T extends VerifyAllowed<T> succeeds if all the properties are allowable, and fails otherwise. If even one key is forbidden, then that property is mapped to never and then T extends VerifyAllowed<T> will be false. And finally, if T is neither Allowed, nor an object, then it's some primitive we don't want (like symbol) and so we just return never so that T extends VerifyAllowed<T> will be false.


Okay, so how can we use that? One way if you're using class definitions is to put it in an implements clause to catch any non-compliant classes right way. This isn't necessary, but without it you'd only catch the error the first time you tried to pass a class instance into something. Anyway, it looks like this:

class A implements VerifyAllowed<A> {
    static scan(): string {
        return "DUMMY static method return value"
    }
    save(): void {
    }
}    

@addMethods
class BadB extends A implements VerifyAllowed<BadB> {
    a?: string
    b?: number
    c?: { // error! // Types of property 'y' are incompatible
        d: boolean
        y: string
    }
}

Oops, we made a mistake and put y in there. Let's remove that:

@addMethods
class B extends A implements VerifyAllowed<B> { // okay
    a?: string
    b?: number
    c?: {
        d: boolean
    }
}

Whether or not we use implements VerifyAllowed<> in our class declarations, we can still catch mistakes by making any function that accepts "allowed" things generic. For example:

function acceptOnlyAllowedThings<T>(t: VerifyAllowed<T>) {

}

const badB = new BadB();
const b = new B();

acceptOnlyAllowedThings(badB); // error! c.y is bad
acceptOnlyAllowedThings(b); // okay

Now that we have put the constraint in there we can define Props<T> as the same thing as the Partial<T> utility type, because there's no string index signature messing you up:

type Props<T> = Partial<T>; // <-- this is just Partial

const props: Props<B> = {
    a: "abcd",
    b: 0,
    anyProperty: "anything" // error!
}

And the same thing goes for AddProps<T>: you can recursively turn T into AddProps<T> without worrying about string index signatures:

type AddProps<T> = T extends VerifyAllowed<T> ? {
    [K in keyof T]?: T[K] extends Allowed ? {
        [F in ForbiddenKeys]?: T[K]
    } : AddProps<T[K]>
} : never;

const test: AddProps<B> = {
    a: { x: "some string" },
    b: { y: 0 },
    c: {
        d: { z: true }
    }
}

Looks good!

Playground link to code

  • Related