Reputation: 6060
I'd like to define a type that is recursive on itself like this, essentially:
interface CSSProperties {
marginLeft?: string | number
[key: string]?: CSSProperties
}
Unfortunately, the typescript docs say:
While string index signatures are a powerful way to describe the “dictionary” pattern, they also enforce that all properties match their return type. This is because a string index declares that obj.property is also available as obj[“property”]. In the following example, name’s type does not match the string index’s type, and the type-checker gives an error:
Which seems to say this is impossible to express in typescript, which seems a severe limitation. Flow does what I consider the right thing here and assumes that marginLeft
does not fall into the index specification.
Is this possible at all in TypeScript? Alternatively, is there a way to specify that a string is any string but a set of strings? That way, I could do something roughly like:
interface NestedCSSProperties: CSSProperties {
[P not in keyof CSSProperties]?: CSSProperties
}
Upvotes: 9
Views: 2195
Reputation: 13651
Have you tried using an intersection type Like this?
interface CssPropertyValues {
marginLeft? :string |number;
}
interface RecursiveCssProperties {
[key: string]: CssProperties;
}
type CssProperties = CssPropertyValues & RecursiveCssProperties;
let foo: CssProperties = {};
let myMargin = foo.bar.really.foobar.marginLeft; //should work... myMargin is typed as string|number|undefined
Intersection types are an often over-looked and slightly more powerful alternative to interface inheritance
Upvotes: 1
Reputation: 10659
The problem here is not really with recursion (which is allowed) but with the conflicting signatures as you pointed out.
I'm not sure the opposite of the current behavior would be correct. It seems like a subjective decision to me with cases that can be made for both ways. Accepting the example as-is means you're merging the definitions implicitly; you may look at one of the lines and assume an effect of the interface while another line changes the outcome. It does feel more natural to write but I'm not sure it's as safe as you don't normally expect fall throughs on type definitions.
Regardless. TypeScript does allow a similar behavior as to what you're expecting, but you have to be explicit in that string keys can also be of type string
or number
. This will work:
interface CSSProperties {
marginLeft?: string | number,
[key: string]: CSSProperties|string|number,
}
For example, with the above interface, this is valid:
let a: CSSProperties = {
marginLeft: 10,
name: {
marginLeft: 20,
}
};
This is not:
let a: CSSProperties = {
marginLeft: 10,
something: false, // Type 'boolean' is not assignable to type 'string | number | CSSProperties'.
something: new RegExp(/a/g), // Type 'RegExp' is not assignable to type 'CSSProperties'.
name: {
marginLeft: 20,
},
car: ["blue"], // Type 'string[]' is not assignable to type 'CSSProperties'.
};
It'll know the named members correctly:
let name1: string | number = a.marginLeft; // OK, return type is string | number
a.marginLeft = false; // Blocked, Type 'false' is not assignable to type 'string | number'.
a["whatever"] = false; // Blocked, Type 'false' is not assignable to type 'string | number | CSSProperties'.
a["marginLeft"] = false; // Blocked, Type 'false' is not assignable to type 'string | number'.
The problem here, however, is that you need to cast other dynamic members when reading - it won't know it's a CSSProperties
.
This won't get blocked:
a["whatever"] = 100;
And it will complain about this:
let name3: CSSProperties = a["name"]; // Type is CSSProperties | string | number
But this will work if you typecast explicitly:
let name3: CSSProperties = a["name"] as CSSProperties;
Upvotes: 3
Reputation: 89
In this case you have to be inclusive of string and number. Then you can define your overrides. Using this example, if you try to assign a map to 'marginLeft', TS will complain, but will be permissive of everything else.
Any more constraining and you'll probably need to write a more in-depth .d.ts file which may use mapped types.
You could simplify your exclusive properties using the preset mapped types like Pick but I admit, I have a hard time wrapping my brain around them sometimes.
interface IMap<T> {
[key: string]: T
}
type CSSPropertyValue = string | number | CSSPropertiesBase;
interface CSSPropertiesBase extends IMap<CSSPropertyValue>
{
}
interface CSSProperties extends CSSPropertiesBase
{
marginLeft?: string|number;
}
Upvotes: 1