Recently, I published schema-dts (npm, GitHub), an open source library that models JSON-LD Schema.org in TypeScript. A big reason I wanted to do this project is because I knew some TypeScript type system features, such as discriminated type unions, powerful type inference, nullability checking, and type intersections, present an opportunity to both model what Schema.org-conformant JSON-LD looks like, while also providing ergonomic completions to the developer.

In a series of posts, I’ll go over some of the Structured Data concepts that lent themselves well to TypeScript’s type system, and those concepts that didn’t. First up: the type hierarchy of JSON-LD Schema.org Schema, and how can be represented in TypeScript.

Note: I’ll be describing JSON-LD in general in very broad strokes and will spend more time discussing how Schema.org JSON-LD looks like in particular. For those who are familiar with the JSON-LD spec, you’ll see I took a few liberties. This is because schema-dts makes a few assumptions, such as the @context being a known constant, etc. schema-dts also foregoes some features, such as specifying multiple types of a node object, etc.

Modeling the Schema.org class structure with the TypeScript Type System

Schema.org JSON-LD node objects are always typed (that is, they have a @type property that points to some IRI–a string–describing it). Given a @type you know all the properties that are defined on a particular object. Object types inherit from each other. For example, Thing in Schema.org has a property called name , and Person is a subclass of Thing that defines additional properties such as birthDate , and inherits all the properties of Thing such as name. Thing has other sub-classes, like Organization , with it’s own properties, like logo .

Let’s use this minimal example to try a few approaches:

1. Modeling each with inheritance

interface Thing { name : string ; } interface Person extends Thing { "@type" : "Person" ; birthDate : string ; } interface Organization extends Thing { "@type" : "Organization" ; logo : string ; }

If we had a const something: Thing , then we could assign it to a Thing , Person , or Organization . So that’s a start! But there are a few problems:

Using type Thing on it’s own isn’t quite right, as it is missing a @type annotation. More broadly, non-leaf types (types that a super-class of another) are not representible this way.

on it’s own isn’t quite right, as it is missing a annotation. More broadly, non-leaf types (types that a super-class of another) are not representible this way. Writing object literals inline will cause TypeScript’s excess property checks to complain that "@type" , "birthDate" , and "logo" are not a known property of Thing .

, , and are not a known property of . Lacking completions for "@type" . If I was filling in a complex nested object whose property had some type, it would be great if I can look for helpful completions on "@type" and see what allowed types exist for a certain property.

2. Modeling each object individually

interface Thing { "@type" : "Thing" ; name : string ; } interface Person { "@type" : "Person" ; name : string ; birthDate : string ; } interface Organization { "@type" : "Organization" ; name : string ; logo : string ; }

Another approach altogether is to fully roll all parent types of each object separately. This solves the first problem, where types that are a superclass of other types can still be represented, but introduces (and exacerbates) the existing problems.

“Sub-classes” are not assignable to their parent types. For example, an object of type Person cannot be assigned to a variable of type Thing . It also cannot be used as the value of a property of a super-class type.

3. Modeling super-classes as discriminated unions

type Thing = Person | Organization ; interface Person { "@type" : "Person" ; name : string ; birthDate : string ; } interface Organization { "@type" : "Organization" ; name : string ; logo : string ; }

By defining Thing (or, generally, a parent class) as a union of it’s sub-classes, it’ll behave like a discriminated union. It’s discriminated because each possible type within the union has a property ( @type ) that is sufficient to tell the compiler which type of the union that object is.

Discriminated unions allow us to achieve assignability, completions (typing ’ "@type": " ’ inside of a Thing will suggest "Person" or "Organization" ), and proper type checking without tripping up excess property checking when writing properties of a sub-class.

The problem? We’re back to not being able to individually express a type with sub-classes (e.g. Thing ) individually. Back to the drawing board.

4. Hybrid Approach: Modeling parent classes within unions

When we think of the Thing Schema.org class, three separable concepts come to mind. It could be a node

with the actual "@type": "Thing" that has all the properties of Thing that has a "@type" equal to that of any of the (direct or indirect) sub-classes of Thing .

(1) can be represented as a specific object literal; (2) can be represented as some type to be extended (or intersected); and (3) can be represented as a discriminated union.

Representing the Type Structure of our set of classes

interface ThingBase { name : string ; }

interface ThingLeaf extends ThingBase { "@type" : "Thing" ; } interface Person extends ThingBase { "@type" : "Person" ; birthDate : string ; } interface Organization extends ThingBase { "@type" : "Organization" ; logo : string ; }

type Thing = ThingLeaf | Person | Organization ;

Here, Thing , Person , and Organization can all be used independently. And we have the properties we want:

A Person or Organization is assignable to a Thing .

or is assignable to a . A Thing can exist with "@type": "Thing" .

can exist with . Thing is a discriminated union and can suggest "Thing" , "Person" , or "Organization" when typing a "@type" value.

TypeScript will also type check our deeply nested properties according to their types.

Best thing about this approach, it’s recursive. Consider, for example, two sub-types of Organization : Airline and Corporation . Those can be modeled recursively as the graph shows below.

Examples of the recursive nature of the type model

Getting to a Working System

When schema-dts generates types, it uses a very similar model as described above. Notably different, is that I use type intersections ( A & B ) instead of interfaces ( interface B extends A ) to simplify some of the nested expressions that show up.

In schema-dts, every “node” type is represented like Thing in that it has a “base” type, and represents the “real” type as a union of a leaf and other sub-classes.

In future articles, I’ll discuss how to represent “enums”, the limitations of the TypeScript type system with data types, and how to represent properties.