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
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 istype Boo = {[k: string]: boolean}. Butkeyof Boowill bestring, which you don't want.Since we can't really write
AllowedObjectas a specific object type, we can try writing it as a generic constraint. That is,VerifyAllowed<T>checks a candidate typeTfor whether it is allowed or not. IfT extends VerifyAllowed<T>, then it is allowed, otherwise it is not.Here's one possible implementation of that:
If
TisAllowed, thenVerifyAllowed<T>will resolve to justT(and thusT extends VerifyAllowed<T>will be true). Otherwise, ifTis anobjecttype, we map each propertyT[K]toVerifyAllowed<T[K]>unless the keyKis one of theForbiddenKeysin which case we map it tonever. So if none of the keys are forbidden, thenT extends VerifyAllowed<T>succeeds if all the properties are allowable, and fails otherwise. If even one key is forbidden, then that property is mapped toneverand thenT extends VerifyAllowed<T>will be false. And finally, ifTis neitherAllowed, nor anobject, then it's some primitive we don't want (likesymbol) and so we just returnneverso thatT extends VerifyAllowed<T>will be false.Okay, so how can we use that? One way if you're using
classdefinitions is to put it in animplementsclause to catch any non-compliantclasses 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:Oops, we made a mistake and put
yin there. Let's remove that:Whether or not we use
implements VerifyAllowed<>in ourclassdeclarations, we can still catch mistakes by making any function that accepts "allowed" things generic. For example:Now that we have put the constraint in there we can define
Props<T>as the same thing as thePartial<T>utility type, because there's no string index signature messing you up:And the same thing goes for
AddProps<T>: you can recursively turnTintoAddProps<T>without worrying about string index signatures:Looks good!
Playground link to code