Created
October 8, 2022 05:17
-
-
Save kriskowal/0442aa0c63b77c42739437b36c599d74 to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/** | |
* @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), | |
}; |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/** @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($), | |
}); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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)); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import { colors } from '../../schema.js'; | |
/** | |
* @template T | |
* @param {import('../../lib/schema.js').Schema<T>} $ | |
*/ | |
export const daia = $ => ({ | |
facetsPerFace: $.number(), | |
tilesPerFacet: $.number(), | |
colors: $.list(colors($)), | |
}); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import { point, colors } from '../../schema.js'; | |
/** | |
* @template T | |
* @param {import('../../lib/schema.js').Schema<T>} $ | |
*/ | |
export const rect = $ => ({ | |
size: point($), | |
colors: colors($), | |
}); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import { point, colors } from '../../schema.js'; | |
/** | |
* @template T | |
* @param {import('../../lib/schema.js').Schema<T>} $ | |
*/ | |
export const torus = $ => ({ | |
tilesPerChunk: point($), | |
chunksPerLevel: point($), | |
colors: colors($), | |
}); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
The script generates a TypeScript type:
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.