ts-schema-implementation
Step-by-step procedure for implementing a new schema.org type in the TypeScript version of this library, including class structure, enum creation, test writing, and code style.
SKILL.md.claude/skills/ts-schema-implementation/SKILL.md
GitHubImplement a Schema.org Type (TypeScript)
Step-by-step procedure for implementing a new schema.org type in the TypeScript version of this library. Read AGENTS-ts.md for full conventions before starting.
Step 1: Understand the type
- Read the Google Rich Results docs URL from the issue
- Read the schema.org spec URL from the issue
- Identify required, recommended, and optional properties
- Identify property types: string, number, boolean, array, enum, Date/string, or nested schema
- Check the PHP implementation in
php/src/v1/Schema/{TypeName}.phpfor reference — the TS version should mirror it
Step 2: Check for dependencies
- Does this type reference other TypedSchema classes?
- Are they already implemented in
ts/src/schema/? - If not, they need to be created too (or the issue should specify using a simpler type like
stringtemporarily) - Check the PHP version's dependencies in
php/src/v1/Schema/for the full dependency list
Step 3: Write failing tests first (TDD)
File: ts/test/schema/{TypeName}.test.ts
Write tests before the implementation. These tests will fail initially because the class doesn't exist yet.
Template:
import { describe, it, expect } from "vitest";
import { JsonLdGenerator } from "../../src/JsonLdGenerator";
import { TypeName } from "../../src/schema/TypeName";
describe('TypeName', () => {
it('produces minimal JSON-LD output with required fields only', () => {
const schema = new TypeName({ requiredProp: 'required-value' });
const json = JsonLdGenerator.schemaToJson(schema);
const obj = JSON.parse(json);
expect(obj['@context']).toBe('https://schema.org/');
expect(obj['@type']).toBe('TypeName');
expect(obj.requiredProp).toBe('required-value');
});
it('omits optional fields when null', () => {
const schema = new TypeName({ requiredProp: 'required-value' });
const json = JsonLdGenerator.schemaToJson(schema);
const obj = JSON.parse(json);
expect(obj).not.toHaveProperty('optionalField');
});
it('includes all fields when set', () => {
const schema = new TypeName({
requiredProp: 'required-value',
optionalProp: 'optional-value',
});
const json = JsonLdGenerator.schemaToJson(schema);
const obj = JSON.parse(json);
expect(obj.optionalField).toBe('optional-value');
});
it('serializes nested schemas correctly', () => {
const nested = new NestedType({ name: 'nested-value' });
const schema = new TypeName({
requiredProp: 'required-value',
nestedProp: nested,
});
const json = JsonLdGenerator.schemaToJson(schema);
const obj = JSON.parse(json);
expect(obj.nestedProp['@type']).toBe('NestedType');
expect(obj.nestedProp.name).toBe('nested-value');
});
it('serializes enums to schema.org URLs', () => {
const schema = new TypeName({
requiredProp: 'required-value',
enumProp: SomeEnum.Value,
});
const json = JsonLdGenerator.schemaToJson(schema);
const obj = JSON.parse(json);
expect(obj.enumProp).toBe('https://schema.org/Value');
});
});
Must test:
- Minimal construction: Only required properties, verify JSON has
@context,@type, and required fields - Null omission: Optional properties when
nullare NOT present in output - Full construction: All properties set, verify all appear in output
- Nested schemas: Verify nested TypedSchema instances serialize correctly
- Enums: Verify enums serialize to schema.org URLs
- PHP parity: JSON-LD output should match the PHP version for the same inputs
Step 4: Create the schema class
File: ts/src/schema/{TypeName}.ts
ALL schema classes use the options-object constructor pattern. This is a uniform convention — no exceptions, regardless of how many properties the class has. This matches PHP's named parameter ergonomics and eliminates positional argument errors.
import { TypedSchema } from '../TypedSchema.js';
export interface TypeNameOptions {
requiredProp: string;
optionalProp?: string | null;
nestedProp?: NestedType | null;
enumProp?: SomeEnum | null;
}
export class TypeName extends TypedSchema {
static readonly schemaType = 'TypeName';
public readonly requiredProp: string;
public readonly optionalProp: string | null;
public readonly nestedProp: NestedType | null;
public readonly enumProp: SomeEnum | null;
constructor(options: TypeNameOptions) {
super();
this.requiredProp = options.requiredProp;
this.optionalProp = options.optionalProp ?? null;
this.nestedProp = options.nestedProp ?? null;
this.enumProp = options.enumProp ?? null;
}
}
The options interface:
- Is exported alongside the class (named
{TypeName}Options) - Uses
?:for optional fields (this is the interface contract) - Required fields have no
?:— they are mandatory in the options object - Optional fields default to
nullvia?? nullin the constructor body
Rules:
- Extend
TypedSchema(or a parent schema class if the type inherits — see below) - Set
static readonly schemaTypeto the exact schema.org type name - Use
public readonlyfor all properties - Required properties have no default value, optional properties default to
null - Use
Type | nullunion syntax for class properties (NOT?:on the class) - For array properties, use
readonly Type[]— optional arrays default tonull(not[]) - Import paths must include
.jsextension (ESM convention) - Do NOT add serialization methods —
JsonLdGeneratorhandles everything - Do NOT modify
JsonLdGenerator.tsorTypedSchema.tsunless the issue specifically asks
Class inheritance variant
If the schema type extends another schema type (not TypedSchema directly), use the inheritance pattern:
import { ParentType } from "./ParentType.js";
import type { ParentTypeOptions } from "./ParentType.js";
export interface ChildTypeOptions extends ParentTypeOptions {
childSpecificProp?: string | null;
}
export class ChildType extends ParentType {
static readonly schemaType: string = "ChildType";
public readonly childSpecificProp: string | null;
constructor(options: ChildTypeOptions) {
super(options);
this.childSpecificProp = options.childSpecificProp ?? null;
}
}
Check the PHP implementation to see if the type uses inheritance. Existing chains:
LocalBusiness → FoodEstablishment → RestaurantLocalBusiness → StoreArticle → BlogPosting,Article → NewsArticleSoftwareApplication → MobileApplication,SoftwareApplication → WebApplication
Step 5: Create enum types (if needed)
File: ts/src/enum/{EnumName}.ts
Template:
export enum EnumName {
CaseName = 'https://schema.org/CaseName',
AnotherCase = 'https://schema.org/AnotherCase',
}
Rules:
- String enum with full schema.org URLs as values
- PascalCase for enum name and member names matching schema.org names
- One enum per file
- Export from barrel (
ts/src/index.ts)
Step 6: Update barrel exports
File: ts/src/index.ts
Add exports for all new classes and enums:
export { TypeName } from './schema/TypeName';
export { EnumName } from './enum/EnumName';
Keep exports sorted alphabetically within each section (schemas, then enums).
Step 7: Make tests pass
Run tests and iterate until all pass:
npm run test
All existing tests plus new tests must pass. Fix any failures before proceeding.
Step 8: Run linter and build
npx biome check
npm run build
Both must pass cleanly. Biome handles formatting and linting. Do NOT manually fix style issues that Biome can auto-fix — run npx biome check --write instead.
Step 9: Verify PHP parity
Compare your TypeScript JSON-LD output against the PHP version:
- Same
@contextand@type - Same property names (camelCase, matching schema.org)
- Same serialization of nested objects, arrays, and enums
- Null/undefined properties omitted in both
Common mistakes to avoid
- Adding serialization methods — don't; JsonLdGenerator uses property enumeration
- Using
anytype — be explicit with all types - Forgetting
schemaType— this determines the@typein JSON-LD output - Using default exports — this project uses named exports only
- Using
require()— this is an ESM-first project - Using
?:for optional class properties — useType | null = nullfor class properties (matches PHP convention, ensures property exists with explicit null). Note:?:IS correct in options interfaces. - Modifying JsonLdGenerator.ts or TypedSchema.ts — never modify these unless specifically instructed
- Forgetting to update index.ts — all new public types must be exported from the barrel
- Writing tests after implementation — write tests FIRST (TDD), then implement to make them pass
- Mismatched property names vs PHP — check the PHP implementation to ensure property names are identical
- Using positional constructor parameters — ALL classes use options-object constructors, no exceptions. Even simple one-property types like
Brandusenew Brand({ name: 'ACME' }), notnew Brand('ACME'). - Omitting
.jsextension in imports — ESM requires.jsextensions:import { Foo } from './Foo.js'