1

I have the following type construct:

class StartClass {
  [propertyName: string]: NestedClass; // We can have multiple NestedClasses here with any names

  ...
}

class NestedClass {
  active: boolean; // error: not assignable ...
  blacklist?: string[]; // error: not assignable ...
  [propertyName: string]: NestedClass; // NestedClass itself can contain multiple NestedClass fields with any names

  ...
}

I get the compiler errors like: Property 'blacklist' of type 'string[] | undefined' is not assignable to string index type 'NestedClass'.. My first idea (which might be totally wrong) is to create an intersection like:

type Intersected = NestedClass & {[propertyName: string]: NestedClass}

Despite me not being sure that's the correct approach anyway, it leads to the compiler error Type alias 'Intersected' circularly references itself.

So, is there any way to declare such a construct as sketched with NestedClass in TypeScript?

NotX
  • 1,516
  • 1
  • 14
  • 28
  • I'm confused about what you're trying to achieve. This might be an [XY problem](//en.wikipedia.org/wiki/XY_problem): The way you thought to do X is with Y, but it's an incompatible [index signature](//www.typescriptlang.org/docs/handbook/interfaces.html#indexable-types). And then the way to do Y is with Z, a circular intersection, which isn't "allowed". Well, Z is possible: `type Intersected = NestedClass & { [propertyName: string]: Intersected }` works fine. But I don't follow the logic from Y to Z and I don't know what X is, so I don't know if Z helps or not. What is the goal here? – jcalz Mar 20 '21 at 01:24
  • @jcalz Thanks for your response! Declaring `Intersected` as above leads to the compiler error `Type alias 'Intersected' circularly references itself.` That said, I'm not convinced `Intersected` is actually the right approach - tbh I have a hard time wrapping my head around it. What I'm really aiming at is the `NestedClass` structure. I could try to explain the real world application if that helps to shed light on things. But spoken in the "XY" terminology, `NestedClass` is my "X" and the `type Intersected` declaration might be the "Y", so we cam drop that part if it's misleading. ;) – NotX Mar 21 '21 at 13:49
  • I can't edit my further comment anymore, but I've modified my initial question a bit, hoping it's a tad clearer now. – NotX Mar 21 '21 at 13:58
  • You must be using an older release of TypeScript if you’re getting a circularity error for that intersection. Can you explain what you mean by “any names”? `"active"` is a name, but you’ve typed it as `boolean` and that isn’t assignable to `NestedClass` so the typings aren’t consistent. I think you might be aiming for “any names except for the other ones mentioned in the class”, which isn’t a concept TypeScript has. If that’s the issue I can point you to other answers which describe the existing workarounds and approaches. – jcalz Mar 21 '21 at 14:20
  • Demo of `Intersected` working fine: [link](https://tsplay.dev/m3A6yw); this has been supported since TypeScript 4.0, so you should probably think of updating at some point. – jcalz Mar 21 '21 at 14:30
  • Mixing named properties with indexed properties is a bad idea - what if one of your indices just so happens to be the string `'active'`? Store the key/value pairs in a separate object using composition. – kaya3 Mar 21 '21 at 15:52
  • @jcalz you're right, `type Intersected = { active: boolean; blacklist?: string[] } & { [propertyName: string]: Intersected }` works and solves my issue with TS 4. It's a bit odd I can't construct it via an literal like `{active: true, "some prop": {active: true}}`, but only via a helper method parsing `any`, but it's a start! I understand that the theoretical conflict between `active` and other fields of index type `string` is troublesome for TypeScript to solve and I might have to rework this approach later on. – NotX Mar 21 '21 at 16:53
  • @kaya3 I don't see that happen for my case, but ofc those assumptions are the root of many problems which arise later. I'll think I'll issue that as technical debt since multiple processes are relying on that structure for now. – NotX Mar 21 '21 at 16:55

1 Answers1

1

I suppose the answer to this question as asked is that you can indeed write a recursive intersection in TypeScript like this since TypeScript 4.0:

type Foo = { a: string } & { [k: string]: Foo }; // works

declare const foo: Foo;
foo.a.toUpperCase();
foo.bar.a.toUpperCase();

Playground link to code

I am not going to go into detail here about why such a type is problematic, as OP already knows it and it is somewhat out of scope here; for those interested, you can read about in the Stack Overflow question "How to define Typescript type as a dictionary of strings but with one numeric “id” property".


This should have worked since TypeScript 3.7 introduced support for more recursive type aliases, but there was a bug that prevented this case from working. It was reported in microsoft/TypeScript#38672 and fixed in microsoft/TypeScript#38673, which made it into TypeScript 4.0.

That means if you are getting errors for this code, you are using a version of TypeScript before 4.0, and might want to think about upgrading when you get an opportunity, as the language changes fairly rapidly.


If you must get such a thing working in an earlier version of TypeScript, you could do the following ugly thing:

type _Foo = { a: string, [k: string]: unknown };
type Foo = { [K in keyof _Foo]: string extends K ? Foo : _Foo[K] };
/* type Foo = {
    [x: string]: Foo;
    a: string;
} */

declare const foo: Foo;
foo.a.toUpperCase();
foo.bar.a.toUpperCase();

Playground link to code

You can trick the compiler into allowing an inconsistent index signature by mapping over a type with a consistent index signature and doing an inconsistent conditional type for the properties. So I take {a: string, [k: string]: unknown}, a perfectly valid type, and map over it to produce Foo, whose type looks is {[x: string]: Foo, a: string} despite the fact that you couldn't write this type directly.


I don't really know if I'd recommend a solution that depends on tricking the compiler though, so you should still consider upgrading to TS4.0+ if possible, or maybe even abandoning this type altogether in favor of something TypeScript can handle.

jcalz
  • 264,269
  • 27
  • 359
  • 360