Skip to content

prevent Role from forming malformed intersections#205

Merged
Eyas merged 1 commit intogoogle:mainfrom
HunterLarco:hunter/fix-malformed-role-types
Aug 21, 2025
Merged

prevent Role from forming malformed intersections#205
Eyas merged 1 commit intogoogle:mainfrom
HunterLarco:hunter/fix-malformed-role-types

Conversation

@HunterLarco
Copy link
Copy Markdown
Contributor

@HunterLarco HunterLarco commented Aug 19, 2025

Problem

The Role type (introduced here) is designed to be an intermediary type for all fields and takes the form:

type RoleLeaf<TContent, TProperty extends string> = RoleBase & {
  "@type": "Role";
} & {
  [key in TProperty]: TContent;
};

The idea being that any field such as member: SchemaValue<Person, "member"> can be expressed either as its inherent type (Person) or the intermediate type Role<Person> such as

{
  "@context": "https://schema.org",
  "@type": "Organization",
  "name": "Cryptography Users",
  "member": {
    "@type": "OrganizationRole",
    "member": {
      "@type": "Person",
      "name": "Alice"
    },
    "startDate": "1977"
  }
}

The issue with this (which is an issue in the schema.org definition, not isolated to just this typescript repository) is that there's no guidance on how to resolve conflicts between RoleBase and the property field. This is seen clearly in the CreativeWorkSeason type.

To simplify the type graph:

type SchemaValue<T, TProperty extends string> = T | Role<T, TProperty>;

interface RoleBase {
  "startDate"?: SchemaValue<Date, "startDate">;
}

type Role<TContent, TProperty extends string> = RoleBase & {
  "@type": "Role";
} & {
  [key in TProperty]: TContent;
};

interface CreativeWorkSeason {
  "startDate"?: SchemaValue<Date, "startDate">;
}

If we expand CreativeWorkSeason.startDate it resolves as follows:

SchemaValue<Date, "startDate">
Date | Role<T, "startDate">
Date | (RoleBase & { startDate: Date })
Date | ({ startDate: SchemaValue<Date, "startDate"> } & { startDate: Date })
Date | ({ startDate: Date | Role<T, "startDate"> } & { startDate: Date })
Date | ({ startDate: Date | (RoleBase & { startDate: Date }) } & { startDate: Date })

At nested level two this creates the malformed type Date & { startDate: Date } and as a result nested roles will fail to compile. For example:

import * as schemaDts from 'schema-dts';

export const season: schemaDts.CreativeWorkSeason = {
  "@type": "CreativeWorkSeason",
  startDate: {
    "@type": "Role",
    startDate: {
      "@type": "Role",
      startDate: "2025-08-19T14:07:00.834Z",
    },
  },
}

this results in the compiler error

example.ts:5:3 - error TS2322: Type '{ "@type": "Role"; startDate: { "@type": "Role"; startDate: "2025-08-19T14:07:00.834Z"; }; }' is not assignable to type 'SchemaValue<string, "startDate">'.
  Types of property 'startDate' are incompatible.
    Type '{ "@type": "Role"; startDate: "2025-08-19T14:07:00.834Z"; }' is not assignable to type 'SchemaValue<string, "startDate"> & string'.
      Type '{ "@type": "Role"; startDate: "2025-08-19T14:07:00.834Z"; }' is not assignable to type '(RoleBase & { "@type": "Role"; } & { startDate: string; } & string) | (readonly (string | Role<string, "startDate">)[] & string)'.
        Type '{ "@type": "Role"; startDate: "2025-08-19T14:07:00.834Z"; }' is not assignable to type 'RoleBase & { "@type": "Role"; } & { startDate: string; } & string'.
          Type '{ "@type": "Role"; startDate: "2025-08-19T14:07:00.834Z"; }' is not assignable to type 'string'.

5   startDate: {
    ~~~~~~~~~

  node_modules/schema-dts/dist/schema.d.ts:2314:5
    2314     "startDate"?: SchemaValue<Date | DateTime, "startDate">;
             ~~~~~~~~~~~
    The expected type comes from property 'startDate' which is declared here on type 'CreativeWorkSeason'


Found 1 error in example.ts:5

Fortunately, this example probably doesn't exist in the wild. Based on the definition of Role, I don't think it's semantically meaningful to nest roles like this. However, the current implementation breaks tooling. For example, typia a project which statically analyzes the type tree to create validators breaks when it encounters the nonsensical type Date & { startDate: Date }. I would imagine other tools also may break.

Solution

In this fix, we resolve conflicts between RoleBase and the modeled field by preferring the modeled field's type (using Omit). For example:

SchemaValue<Date, "startDate">
Date | Role<T, "startDate">
Date | (Omit<RoleBase, "startDate"> & { startDate: Date })
Date | ({ } & { startDate: Date })
Date | ({ @type: "Role" } & { startDate: Date })
Date | ({ @type: "Role", startDate: Date })

Not only does this improve the type tree by removing infinite Role depth, but it prevents nonsensical types which can break language servers and static analysis tools.

Risks

Breaking Change

This is a breaking change. However, it only impacts projects using at least two levels of Role recursion specifically for fields which conflict with RoleBase. This includes:

  • CreativeWorkSeason (endDate + startDate)
  • CreativeWorkSeries (endDate + startDate)
  • DatedMoneySpecification (endDate + startDate)
  • EducationalOccupationalProgram (endDate + startDate)
  • Event (endDate + startDate)
  • MerchantReturnPolicy (endDate + startDate)
  • Schedule (endDate + startDate)

and all fields on all types inherited from ThingBase (because Role extends ThingBase).

However, none of those fields were able to build anyway. So there should be no new breakages as a result of this change.

Divergence from schema.org

schema.org provides no guidance on how to address the conflicting definition within Role. However, based on the examples listed on Role it seems to me that the intent is clearly that Role is not meant to be nested and that the parameterized property should represent the parent type, NOT a malformed intersection.

Testing

Tests pass and lint passes.

@HunterLarco HunterLarco marked this pull request as ready for review August 19, 2025 14:32
@Eyas Eyas merged commit 0b7c19a into google:main Aug 21, 2025
5 checks passed
@HunterLarco HunterLarco deleted the hunter/fix-malformed-role-types branch August 22, 2025 04:05
@Eyas
Copy link
Copy Markdown
Collaborator

Eyas commented Mar 23, 2026

This is in https://github.com/google/schema-dts/releases/tag/v2.0.0

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants