Last active
February 13, 2022 23:35
-
-
Save LiamGoodacre/d3931312914d045a3f5c02aec36350a8 to your computer and use it in GitHub Desktop.
TypeScript: flat domain modelling with applicability & invariants
This file contains 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
const absurd = (v: never): never => v | |
const typed = <T,>(v: T): T => v | |
type Kinded<A extends B, B> = A | |
type Norm<T> = T extends T ? { [k in keyof T]: T[k] } : never | |
// | |
interface Condition { present: unknown; absent: unknown } | |
type Cond<P, A> = Condition & { present: P, absent: A } | |
type Not<C extends Condition> = Cond<C['absent'], C['present']> | |
type Always = Cond<unknown, never> | |
type Never = Not<Always> | |
type And<L extends Condition, R extends Condition> = | |
Cond<L['present'] & R['present'], L['absent'] | R['absent']> | |
type Or<L extends Condition, R extends Condition> = | |
Cond<L['present'] | R['present'], L['absent'] & R['absent']> | |
type Match<NS, Name extends keyof NS, T extends NS[Name] | null> = | |
Cond<Record<Name, T>, Record<Name, Exclude<NS[Name] | null, T>>> | |
interface SomeRule { | |
// under what condition is a field applicable | |
applicable: Condition | |
// when applicable, what condition should hold true | |
invariant: Condition | |
} | |
type Rule<NS, K extends keyof NS, R extends SomeRule> = Record<K, R> | |
type Field<NS, Name extends keyof NS, R extends SomeRule> = | |
| (R['applicable']['present'] & R['invariant']['present'] & Record<Name, NS[Name]>) | |
| (R['applicable']['present'] & R['invariant']['absent'] & Record<Name, never>) | |
| (R['applicable']['absent'] & Record<Name, null>) | |
// Interprets Rules to into an intersection of Fields, for a subset of field names | |
type Build<NS, KS extends keyof NS, RS> = | |
RS extends [infer H, ...infer T] ? | |
( | |
& (keyof H extends KS ? ( | |
Field<NS, keyof H, (H[keyof H] & SomeRule)> | |
) : unknown) | |
& Build<NS, KS, T> | |
) : | |
unknown | |
type Property = null | |
interface SomeDomain { | |
names: {} | |
rules: Array<Record<string, SomeRule>> | |
} | |
type Compile<D extends SomeDomain, KS extends keyof D['names']> = | |
Norm<Build<D['names'], KS, D['rules']>> | |
type Inc<D extends SomeDomain, P, K extends keyof D['names']> = | |
Compile<D, (keyof P | K) & keyof D['names']> | |
type Valid<D extends SomeDomain> = Compile<D, keyof D['names']> | |
type Raw<D extends SomeDomain> = Partial<D['names']> | |
// | |
type Names = { | |
hasCats: "Yes" | "No" | |
favCat: "odin" | "thor" | "loki" | |
wellformed: Property | |
} | |
type Rules = [ | |
Rule<Names, "hasCats", { applicable: Always, invariant: Always }>, | |
Rule<Names, "favCat", { applicable: Match<Names, "hasCats", "Yes">, invariant: Always }>, | |
Rule<Names, "wellformed", { applicable: Always, invariant: Not<Match<Names, "favCat", "odin">> }>, | |
] | |
type Domain = Kinded<{ names: Names, rules: Rules }, SomeDomain> | |
const eg0 = typed<Valid<Domain>>({ hasCats: "Yes", favCat: "loki", wellformed: null }) | |
const eg1 = typed<Valid<Domain>>({ hasCats: "No", favCat: null, wellformed: null }) | |
// @ts-expect-error | |
const eg2 = typed<Valid<Domain>>({ hasCats: true, favCat: "odin", wellformed: null }) | |
// @ts-expect-error | |
const eg3 = typed<Valid<Domain>>({ hasCats: false, favCat: "thor", wellformed: null }) | |
type Inc0 = Inc<Domain, {}, "hasCats"> | |
type Inc1 = Inc<Domain, Inc0, "favCat"> | |
const valid = (p: Raw<Domain>): null | Valid<Domain> => { | |
const inc0 = ((): null | Inc<Domain, {}, 'hasCats'> => { | |
if (p.hasCats === undefined) return null | |
return { hasCats: p.hasCats } | |
})() | |
if (!inc0) return null | |
const inc1 = ((): null | Inc<Domain, typeof inc0, 'favCat'> => { | |
switch (inc0.hasCats) { | |
case "Yes": | |
if (p.favCat === undefined) return null | |
if (p.favCat === null) return null | |
return { favCat: p.favCat, hasCats: inc0.hasCats } | |
case "No": | |
return { favCat: null, hasCats: inc0.hasCats } | |
default: return absurd(inc0.hasCats) | |
} | |
})() | |
if (!inc1) return null | |
const inc2 = ((): null | Inc<Domain, typeof inc1, 'wellformed'> => { | |
switch (inc1.favCat) { | |
case null: return { hasCats: inc1.hasCats, favCat: inc1.favCat, wellformed: null } | |
case "odin": return null | |
case "thor": return { hasCats: inc1.hasCats, favCat: inc1.favCat, wellformed: null } | |
case "loki": return { hasCats: inc1.hasCats, favCat: inc1.favCat, wellformed: null } | |
default: return absurd(inc1) | |
} | |
})() | |
if (!inc2) return null | |
return inc2 | |
} | |
const k0 = (dom: Valid<Domain>): string => { | |
switch (dom.hasCats) { | |
case "Yes": | |
switch (dom.favCat) { | |
case "odin": return absurd(dom.wellformed) | |
case "thor": return "" | |
case "loki": return "" | |
default: return absurd(dom) | |
} | |
case "No": | |
switch (dom.favCat) { | |
case null: return "" | |
default: return absurd(dom) | |
} | |
default: return absurd(dom) | |
} | |
} | |
const k1 = (dom: Valid<Domain>): string => { | |
switch (dom.favCat) { | |
case "odin": return absurd(dom.wellformed) | |
case "thor": | |
switch (dom.hasCats) { | |
case "Yes": | |
switch (dom.wellformed) { | |
case null: return "" | |
default: return absurd(dom) | |
} | |
default: return absurd(dom) | |
} | |
case "loki": | |
switch (dom.hasCats) { | |
case "Yes": return "" | |
default: return absurd(dom) | |
} | |
case null: | |
switch (dom.hasCats) { | |
case "No": return "" | |
default: return absurd(dom) | |
} | |
default: return absurd(dom) | |
} | |
} | |
/* | |
@enum([ | |
{ value: "Yes" }, | |
{ value: "No" } | |
]) | |
string HasCats | |
@enum([ | |
{ value: "odin" }, | |
{ value: "thor" }, | |
{ value: "loki" } | |
]) | |
string FavCat | |
unit Property | |
structure Domain { | |
@applicable(Always) | |
@invariant(Always) | |
hasCats: HasCats | |
@applicable(Match(hasCats, "Yes")) | |
@invariant(Always) | |
favCat: FavCat | |
@applicable(Always) | |
@invariant(Not(Match(favCat, "odin"))) | |
wellformed: Property | |
} | |
*/ | |
namespace Code { | |
type Shape = | |
| { type: "Enum", options: string[] } | |
| { type: "Property" } | |
type Expression = | |
| { type: "Always" } | |
| { type: "Never" } | |
| { type: "And", lhs: Expression, rhs: Expression } | |
| { type: "Or", lhs: Expression, rhs: Expression } | |
| { type: "Not", exp: Expression } | |
| { type: "Match", name: string, value: string } | |
type Entry = { | |
name: string | |
shape: Shape | |
applicable: Expression | |
invariant: Expression | |
} | |
type Data = Array<Entry> | |
const eg: Data = [ | |
{ | |
name: "hasCats", | |
shape: { type: "Enum", options: ["Yes", "No"] }, | |
applicable: { type: "Always" }, | |
invariant: { type: "Always" } | |
}, | |
{ | |
name: "favCat", | |
shape: { type: "Enum", options: ["odin", "thor", "loki"] }, | |
applicable: { type: "Match", name: "hasCats", value: "Yes" }, | |
invariant: { type: "Always" } | |
}, | |
{ | |
name: "wellformed", | |
shape: { type: "Property" }, | |
applicable: { type: "Always" }, | |
invariant: { type: "Not", exp: { type: "Match", name: "favCat", value: "odin" } } | |
}, | |
] | |
const expressionToCode = (exp: Expression): string => { | |
switch (exp.type) { | |
case "Always": return `Always` | |
case "Never": return `Never` | |
case "And": return `And<${expressionToCode(exp.lhs)}, ${expressionToCode(exp.rhs)}>` | |
case "Or": return `Or<${expressionToCode(exp.lhs)}, ${expressionToCode(exp.rhs)}>` | |
case "Not": return `Not<${expressionToCode(exp.exp)}>` | |
case "Match": return `Match<Names, ${JSON.stringify(exp.name)}, ${JSON.stringify(exp.value)}>` | |
default: return absurd(exp) | |
} | |
} | |
const shapeToCode = (shape: Shape): string => { | |
switch (shape.type) { | |
case "Enum": return ( | |
shape.options.length === 0 | |
? "never" | |
: shape.options.map(x => JSON.stringify(x)).join(" | ") | |
) | |
case "Property": return `Property` | |
default: return absurd(shape) | |
} | |
} | |
const dataToCode = (data: Data): string => { | |
const names: string[] = [] | |
const rules: string[] = [] | |
for (const field of data) { | |
names.push(`${field.name}: ${shapeToCode(field.shape)}`) | |
rules.push(`Rule<Names, ${JSON.stringify(field.name) | |
}, { applicable: ${expressionToCode(field.applicable) | |
}, invariant: ${expressionToCode(field.invariant) | |
} }>,`) | |
} | |
return ` | |
type Names = { | |
${names.join("\n ")} | |
} | |
type Rules = [ | |
${rules.join("\n ")} | |
] | |
type Domain = Kinded<{ names: Names, rules: Rules }, SomeDomain>` | |
} | |
console.log(dataToCode(eg)) | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment