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 class
es 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!