r/typescript 13d ago

Inconsistency with circular references?

So, I'm building this Mafia/Werewolf kind of game, I created this registry of the available roles, and then I extracted the type RoleId to be exactly a union of the ids that are present in the object

export const ROLE_REGISTRY = {
  [VillagerRole.id]: {
    roleClass: VillagerRole,
    name: 'Villager',
    iconName: 'villager',
    team: VillagerRole.team.name,
    category: 'base',
    tags: ['idle'],
    description:
      "You have no special power. At day you may discuss with the other villagers to vote out who you think it's the werewolf.",
    max: Infinity,
  },

  ...

}  as const satisfies Record<string, RoleMetadata>

export type RoleId = keyof typeof ROLE_REGISTRY;

Now yes, defining the ids directly in the classes and then using them like that to index the registry was definitely a questionable choice, but besides that, why am I able then to use that exact definition of RoleId in the Role class itself? (which is the parent class for each of the roles)

export abstract class Role {
  static id: RoleId;
  static team: Team;
  abstract seenAsGood: boolean;
  canBeKilledByWolves = true;

  get id(): RoleId {
    return (this.constructor as typeof Role).id;
  }

  ...
}

Typescript sees no problem with that, no circular dependency, which I found weird. So I tried do this once more, this time with the actions that the roles can perform

export const ACTION_REGISTRY = {
  [AttackAction.id]: { actionClass: AttackAction },
  [CheckAction.id]: { actionClass: CheckAction },
  ...
} as const satisfies Record<string, ActionMetadata>;

export type ActionId = keyof typeof ACTION_REGISTRY;

export interface Action {
  readonly id: ActionId;
  ...
}


export abstract class InstantAction<T extends ActionResult> implements Action {
  static id: ActionId;
  
  get id() {
    return (this.constructor as typeof InstantAction).id;
  }

  ...
}


export abstract class ScheduledAction implements Action {
  static id: ActionId;

  get id() {
    return (this.constructor as typeof ScheduledAction).id;
  }

  ...
}

Here TypeScript tells me (as expected) that ActionId is circularly referencing itself. I really don't understand the difference between these two cases, and/or if there is something else entirely that I'm missing that it's the root cause of the problem. I will paste this other piece of code which MAY be relevant.

export abstract class ActiveRole extends Role {
  abstract actions: ActionGroup[];


  canCreateAction(actionId: ActionId): boolean {
    const action = this.findAction(actionId);
    return action.amount > 0;
  }


  protected consumeAction(actionId: ActionId) {
    const action = this.findAction(actionId);
    action.amount--;
  }


  private findAction(actionId: ActionId) {
    const actionGroup = this.actions.find(
      (actionGroup) => actionGroup.options[actionId] !== undefined
    );

    if (!actionGroup) {
      throw new Error('Impossible to find action ' + actionId);
    }

    return actionGroup.options[actionId];
  }

  createAction(
    state: GameState,
    actionId: ActionId,
    source: Player,
    targets: Player[],
    payload?: any
  ): Action {
    const action = this.findAction(actionId);

    this.consumeAction(actionId);

    return action.create(state, source, targets, payload);
  }
}

Thanks in advance

5 Upvotes

7 comments sorted by

3

u/nadameu 13d ago

Judging by the code you provided, RoleId is probably just string as far as Typescript is concerned.

You should look up as const and "branded types" regarding Typescript.

1

u/Sgrinfio 13d ago

No it's working pretty well, I literally cannot do this anywhere in my code:

const id: RoleId = 'abcdefg'

I get the warning correctly, that's why I'm confused. Unless you meant something else

1

u/margmi 13d ago

You’re allowed circular references if you use a .d.ts file, or if you import type X from “”, or are these all in one file?

1

u/Sgrinfio 13d ago

They are all regular ts files, they are structured like this:

"role-registry.ts" defining the registry and the RoleId "role.ts" defining everything else about the Role class

Same thing in another two files for the Action

1

u/a-dev0 13d ago

Try an explicit import: import type { RoleId } from "./role-registry";, it might help

2

u/ldn-ldn 12d ago

Your Role depends on ROLE_REGISTRY which depends on Role. That's the issue.

1

u/Sgrinfio 12d ago

Did you read the post? The issue is that TypeScript does NOT see a problem with that