Skip to content

Instantly share code, notes, and snippets.

@kriskowal
Created October 8, 2022 05:17
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save kriskowal/0442aa0c63b77c42739437b36c599d74 to your computer and use it in GitHub Desktop.
Save kriskowal/0442aa0c63b77c42739437b36c599d74 to your computer and use it in GitHub Desktop.
/**
* @template T
* @typedef {object} Schema
* @prop {() => T} number
* @prop {() => T} boolean
* @prop {() => T} string
* @prop {(t: T) => T} optional
* @prop {(t: T) => T} list
* @prop {(t: T) => T} dict
* @prop {(shape: Record<string, T>) => T} struct
* @prop {(tagName: string, shape: Record<string, Record<string, T>>) => T} choice
*/
/**
* @type {Schema<(allegedValue: unknown, errors: Array<string>, path?: Array<string>) => void>}
*/
export const toValidator = {
string:
() =>
(allegedValue, errors, path = []) => {
if (typeof allegedValue !== 'string') {
errors.push(`expected a string at ${path.join('.')}`);
}
},
number:
() =>
(allegedValue, errors, path = []) => {
if (typeof allegedValue !== 'number') {
errors.push(`expected a number at ${path.join('.')}`);
}
},
boolean:
() =>
(allegedValue, errors, path = []) => {
if (typeof allegedValue !== 'boolean') {
errors.push(`expected a boolean at ${path.join('.')}`);
}
},
struct:
shape =>
(allegedValue, errors, path = []) => {
if (typeof allegedValue !== 'object') {
errors.push(
`expected an object at ${path.join(
'.',
)} but got ${typeof allegedValue}`,
);
} else if (allegedValue === null) {
errors.push(`expected an object at ${path.join('.')} but got null`);
} else if (Array.isArray(allegedValue)) {
errors.push(`expected an object at ${path.join('.')} but got an array`);
} else {
const allegedObject = /** @type {{[name: string]: unknown}} */ (
allegedValue
);
for (const [name, schema] of Object.entries(shape)) {
schema(allegedObject[name], [...path, name], errors);
}
}
},
choice:
(tagName, shapes) =>
(allegedValue, errors, path = []) => {
if (typeof allegedValue !== 'object') {
errors.push(
`expected an object at ${path.join(
'.',
)} but got ${typeof allegedValue}`,
);
} else if (allegedValue === null) {
errors.push(`expected an object at ${path.join('.')} but got null`);
} else if (Array.isArray(allegedValue)) {
errors.push(`expected an object at ${path.join('.')} but got an array`);
} else {
const allegedObject = /** @type {{[name: string]: unknown}} */ (
allegedValue
);
const { [tagName]: tagValue, ...rest } = allegedObject;
if (typeof tagValue !== 'string') {
errors.push(
`expected distinguishing property named ${tagName} with value of type string on object at ${path.join(
'.',
)} but got ${typeof tagValue}`,
);
} else if (!Object.prototype.hasOwnProperty.call(shapes, tagValue)) {
errors.push(
`expected distinguishing property named ${tagName} with a value that is one of ${Object.keys(
shapes,
)} at ${path.join('.')}`,
);
} else {
const shape = shapes[tagValue];
const seen = new Set(Object.keys(shape));
for (const [name, schema] of Object.entries(shape)) {
seen.delete(name);
schema(rest[name], [...path, name], errors);
}
if (seen.size) {
errors.push(
`unexpected properties on object with distinguishing property named ${tagName} with value ${tagValue}: ${[
...seen,
].join(', ')}`,
);
}
}
}
},
dict:
schema =>
(allegedValue, errors, path = []) => {
if (typeof allegedValue !== 'object') {
errors.push(
`expected an object at ${path.join(
'.',
)} but got ${typeof allegedValue}`,
);
} else if (allegedValue === null) {
errors.push(`expected an object at ${path.join('.')} but got null`);
} else if (Array.isArray(allegedValue)) {
errors.push(`expected an object at ${path.join('.')} but got an array`);
} else {
const allegedObject = /** @type {{[name: string]: unknown}} */ (
allegedValue
);
for (const [name, value] of Object.entries(allegedObject)) {
schema(value, [...path, name], errors);
}
}
},
list:
schema =>
(allegedValue, errors, path = []) => {
if (typeof allegedValue !== 'object') {
errors.push(
`expected an array at ${path.join(
'.',
)} but got ${typeof allegedValue}`,
);
} else if (allegedValue === null) {
errors.push(`expected an array at ${path.join('.')} but got null`);
} else if (!Array.isArray(allegedValue)) {
errors.push(`expected an array at ${path.join('.')} but got an object`);
} else {
let index = 0;
for (const value of allegedValue) {
schema(value, [...path, `${index}`], errors);
index += 1;
}
}
},
optional:
schema =>
(allegedValue, errors, path = []) => {
if (allegedValue !== null && allegedValue !== undefined) {
schema(allegedValue, errors, path);
}
},
};
/**
* @type {Schema<string>}
*/
export const toTypeScriptNotation = {
string: () => 'string',
number: () => 'number',
boolean: () => 'boolean',
struct: shape =>
`{${Object.entries(shape)
.map(([name, schema]) => `${name}: ${schema}`)
.join(', ')}}`,
choice: (tagName, shapes) =>
Object.entries(shapes)
.map(
([tagValue, shape]) =>
`{${tagName}: ${JSON.stringify(tagValue)}, ${Object.entries(shape)
.map(([name, schema]) => `${name}: ${schema}`)
.join(', ')}}`,
)
.join(' | '),
dict: schema => `Map<string, ${schema}>`,
list: schema => `Array<${schema}>`,
optional: schema => `${schema} | undefined`,
};
/**
* @type {Schema<(value: unknown) => unknown>}
*/
export const toComplicator = {
string: () => value => value,
number: () => value => value,
boolean: () => value => value,
struct: shape => value =>
Object.fromEntries(
Object.getOwnPropertyNames(shape).map(name => [
name,
shape[name](/** @type {Record<string, unknown>} */ (value)[name]),
]),
),
choice: (tagName, shapes) => value => {
const { [tagName]: tagValue, ...rest } =
/** @type {Record<string, unknown>} */ (value);
const shape = shapes[/** @type {string} */ (tagValue)];
return Object.fromEntries([
...Object.getOwnPropertyNames(shape).map(name => [
name,
shape[name](/** @type {Record<string, unknown>} */ (rest)[name]),
]),
[tagName, tagValue],
]);
},
dict: liftValue => value =>
new Map(
Object.getOwnPropertyNames(value).map(name => [
name,
liftValue(/** @type {Record<string, unknown>} */ (value)[name]),
]),
),
list: liftValue => value =>
/** @type {Array<unknown>} */ (value).map(liftValue),
optional: liftValue => value => value == null ? null : liftValue(value),
};
/** @template T
* @typedef {import('./lib/schema.js').Schema<T>} Schema
*/
import { rect } from './topology/rect/schema.js';
import { torus } from './topology/torus/schema.js';
import { daia } from './topology/daia/schema.js';
/**
* @template T
* @param {Schema<T>} $
*/
export const point = $ =>
$.struct({
x: $.number(),
y: $.number(),
});
/**
* @template T
* @param {Schema<T>} $
*/
export const colors = $ =>
$.struct({
base: $.string(),
lava: $.string(),
water: $.string(),
earth: $.string(),
});
/**
* @template T
* @param {Schema<T>} $
*/
export const Mechanics = $ =>
$.struct({
agentTypes: $.list(
$.struct({
name: $.string(),
tile: $.optional($.string()),
wanders: $.optional($.string()),
dialog: $.optional($.list($.string())),
health: $.optional($.number()),
stamina: $.optional($.number()),
modes: $.optional(
$.list(
$.struct({
tile: $.string(),
holds: $.optional($.string()),
has: $.optional($.string()),
hot: $.optional($.boolean()),
cold: $.optional($.boolean()),
sick: $.optional($.boolean()),
health: $.optional($.number()),
stamina: $.optional($.number()),
immersed: $.optional($.boolean()),
}),
),
),
slots: $.optional(
$.list(
$.struct({
tile: $.string(),
held: $.optional($.boolean()),
pack: $.optional($.boolean()),
}),
),
),
}),
),
recipes: $.list(
$.struct({
agent: $.string(),
reagent: $.string(),
product: $.string(),
byproduct: $.optional($.string()),
price: $.optional($.number()),
dialog: $.optional($.string()),
}),
),
actions: $.list(
$.struct({
agent: $.optional($.string()),
patient: $.string(),
left: $.optional($.string()),
right: $.optional($.string()),
effect: $.optional($.string()),
verb: $.string(),
items: $.list($.string()),
dialog: $.optional($.string()),
}),
),
tileTypes: $.list(
$.struct({
name: $.string(),
text: $.string(),
turn: $.optional($.number()),
}),
),
itemTypes: $.list(
$.struct({
name: $.string(),
tile: $.optional($.string()),
comestible: $.optional($.boolean()),
health: $.optional($.number()),
stamina: $.optional($.number()),
heat: $.optional($.number()),
boat: $.optional($.boolean()),
swimGear: $.optional($.boolean()),
tip: $.optional($.string()),
slot: $.optional($.string()),
}),
),
effectTypes: $.list(
$.struct({
name: $.string(),
tile: $.optional($.string()),
}),
),
});
/**
* @template T
* @param {Schema<T>} $
*/
export const world = $ =>
$.struct({
colors: $.dict($.string()),
levels: $.list(
$.choice('topology', {
rect: rect($),
torus: torus($),
daia: daia($),
}),
),
player: $.optional($.number()),
locations: $.list($.number()),
types: $.list($.number()),
inventories: $.list(
$.struct({
entity: $.number(),
inventory: $.list($.number()),
}),
),
terrain: $.list($.number()),
healths: $.list(
$.struct({
entity: $.number(),
health: $.number(),
}),
),
staminas: $.list(
$.struct({
entity: $.number(),
stamina: $.number(),
}),
),
entityTargetLocations: $.list(
$.struct({
entity: $.number(),
location: $.number(),
}),
),
mechanics: Mechanics($),
});
import fs from 'node:fs/promises';
import { toTypeScriptNotation, toValidator, toComplicator } from './lib/schema.js';
const worldData = JSON.parse(
await fs.readFile('emojiquest/emojiquest.json', 'utf8'),
);
const type = world(toTypeScriptNotation);
console.log(type);
const validate = world(toValidator);
/** @type {Array<string>} */
const errors = [];
validate(worldData, errors);
console.log(errors);
// Turns dictonary-like objects into Maps so we need not worry about the prototype
console.log(world(toComplicator)(worldData));
import { colors } from '../../schema.js';
/**
* @template T
* @param {import('../../lib/schema.js').Schema<T>} $
*/
export const daia = $ => ({
facetsPerFace: $.number(),
tilesPerFacet: $.number(),
colors: $.list(colors($)),
});
import { point, colors } from '../../schema.js';
/**
* @template T
* @param {import('../../lib/schema.js').Schema<T>} $
*/
export const rect = $ => ({
size: point($),
colors: colors($),
});
import { point, colors } from '../../schema.js';
/**
* @template T
* @param {import('../../lib/schema.js').Schema<T>} $
*/
export const torus = $ => ({
tilesPerChunk: point($),
chunksPerLevel: point($),
colors: colors($),
});
@kriskowal
Copy link
Author

The script generates a TypeScript type:

{colors: Map<string, string>, levels: Array<{topology: "rect", size: {x: number, y: number}, colors: {base: string, lava: string, water: string, earth: string}} | {topology: "torus", tilesPerChunk: {x: number, y: number}, chunksPerLevel: {x: number, y: number}, colors: {base: string, lava: string, water: string, earth: string}} | {topology: "daia", facetsPerFace: number, tilesPerFacet: number, colors: Array<{base: string, lava: string, water: string, earth: string}>}>, player: number | undefined, locations: Array<number>, types: Array<number>, inventories: Array<{entity: number, inventory: Array<number>}>, terrain: Array<number>, healths: Array<{entity: number, health: number}>, staminas: Array<{entity: number, stamina: number}>, entityTargetLocations: Array<{entity: number, location: number}>, mechanics: {agentTypes: Array<{name: string, tile: string | undefined, wanders: string | undefined, dialog: Array<string> | undefined, health: number | undefined, stamina: number | undefined, modes: Array<{tile: string, holds: string | undefined, has: string | undefined, hot: boolean | undefined, cold: boolean | undefined, sick: boolean | undefined, health: number | undefined, stamina: number | undefined, immersed: boolean | undefined}> | undefined, slots: Array<{tile: string, held: boolean | undefined, pack: boolean | undefined}> | undefined}>, recipes: Array<{agent: string, reagent: string, product: string, byproduct: string | undefined, price: number | undefined, dialog: string | undefined}>, actions: Array<{agent: string | undefined, patient: string, left: string | undefined, right: string | undefined, effect: string | undefined, verb: string, items: Array<string>, dialog: string | undefined}>, tileTypes: Array<{name: string, text: string, turn: number | undefined}>, itemTypes: Array<{name: string, tile: string | undefined, comestible: boolean | undefined, health: number | undefined, stamina: number | undefined, heat: number | undefined, boat: boolean | undefined, swimGear: boolean | undefined, tip: string | undefined, slot: string | undefined}>, effectTypes: Array<{name: string, tile: string | undefined}>}}

Which means I can validate JSON and then narrow the any type to the above with confidence that it’s been verified at runtime. The type above is of course sloppy, but then I can assign the resulting value into a hand-written type and TSC will verify that they’re structurally compatible.

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