Last active
March 27, 2022 13:37
-
-
Save TotallyNotChase/ce14e04c0e29902de27b9fbb41d3e429 to your computer and use it in GitHub Desktop.
Scoped, yet too unscoped - extensible records API in typescript, through expressive abstraction
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
import { stretch, stretchP } from './implementation'; | |
interface IApplication { | |
wow: boolean; | |
great: string; | |
} | |
function stretchExample0(cond: boolean): IApplication { | |
// Reach for an `IApplication`, but start with `{}`. | |
const app = stretch<IApplication>() | |
.extend('wow', cond) | |
.extendWith('great', _ => { | |
if (cond) { | |
return 'yay'; | |
} else { | |
return 'nay'; | |
} | |
}) | |
.pure(); | |
return app; | |
} | |
function stretchExample1(cond: boolean): IApplication { | |
// Reach for an `IApplication`, but start with `{}`. | |
const app = stretch<IApplication>() | |
.extend('wow', cond) | |
// @ts-expect-error | |
.extendWith('great', _ => { | |
if (cond) { | |
return 1; | |
} else { | |
return 2; | |
} | |
// ^ Invalid return type for 'great' assignment ('great' expects string). | |
// And finally, the error is here: "Type 'number' is not assignable to type 'string'." | |
}) | |
.pure(); | |
return app; | |
// ^ No errors here, take care of the error above. | |
} | |
function stretchExample2(cond: boolean): IApplication { | |
// Reach for an `IApplication`, but start with `{}`. | |
const app0 = stretch<IApplication>().extend('wow', cond); | |
// You can also separate out the methods, but this is silly. | |
const app1 = app0.extendWith('great', _ => { | |
if (cond) { | |
return 'yay'; | |
} else { | |
return 'nay'; | |
} | |
}); | |
return app1.pure(); | |
} | |
function stretchExample3(cond: boolean): IApplication { | |
// You can also extend with several keys at once. | |
const app = stretch<IApplication>() | |
.extendMany((kv, _) => { | |
const init = kv('wow', cond); | |
if (cond) { | |
return [init, kv('great', 'yay')]; | |
} else { | |
return [init, kv('great', 'nay')]; | |
} | |
}) | |
.pure(); | |
return app; | |
} | |
async function stretchPExample0(cond: boolean): Promise<IApplication> { | |
// Reach for an `IApplication`, but start with `{}`. | |
const app = await stretchP<IApplication>() | |
.extend('wow', cond) | |
.extendWith('great', async _ => { | |
if (cond) { | |
return 'yay'; | |
} else { | |
return 'nay'; | |
} | |
}) | |
.pure(); | |
return app; | |
} | |
async function stretchPExample1(cond: boolean): Promise<IApplication> { | |
// Reach for an `IApplication`, but start with `{}`. | |
const app = stretchP<IApplication>() | |
.extend('wow', cond) | |
// @ts-expect-error | |
.extendWith('great', async _ => { | |
if (cond) { | |
return 1; | |
} else { | |
return 2; | |
} | |
// ^ Invalid return type for 'great' assignment ('great' expects string). | |
// And finally, the error is here: "Type 'number' is not assignable to type 'string'." | |
}); | |
return app.pure(); | |
// ^ No errors here, take care of the error above. | |
} | |
async function stretchPExample2(cond: boolean): Promise<IApplication> { | |
// Reach for an `IApplication`, but start with `{}`. | |
const app0 = stretchP<IApplication>().extend('wow', cond); | |
// You can also separate out the methods, but this is silly. | |
const app1 = app0.extendWith('great', async _ => { | |
if (cond) { | |
return 'yay'; | |
} else { | |
return 'nay'; | |
} | |
}); | |
return app1.pure(); | |
} | |
async function stretchPExample3(cond: boolean): Promise<IApplication> { | |
// You can also extend with several keys at once. | |
const app = stretchP<IApplication>().extendMany(async (kv, _) => { | |
const init = kv('wow', cond); | |
if (cond) { | |
return [init, kv('great', 'yay')]; | |
} else { | |
return [init, kv('great', 'nay')]; | |
} | |
}); | |
return app.pure(); | |
} |
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
class TypedKV<Target, K extends keyof Target> { | |
constructor(private readonly k: K, private readonly v: Target[K]) {} | |
pure(): [K, Target[K]] { | |
return [this.k, this.v]; | |
} | |
} | |
// Extend to a target. Reach for the skies. | |
export class Stretch<Target, T = {}> { | |
private typedKV: <K extends keyof Target>(k: K, v: Target[K]) => TypedKV<Target, K>; | |
constructor(private readonly x: T) { | |
this.typedKV = (k, v) => new TypedKV(k, v); | |
} | |
// Extend with a key and a value, where the value to be assigned must follow from `Target[K]` | |
// where `K` is indeed a key of `Target`. The user may not choose the type of the value freely. | |
extend<K extends keyof Target>(k: K, v: Target[K]): Stretch<Target, T & { [k in K]: Target[K] }> { | |
const extension = { [k]: v } as { [k in K]: Target[K] }; | |
const res = { ...this.x, ...extension }; | |
return new Stretch(res); | |
} | |
// extend as above, but this time with a function to handle stateful computation perhaps. | |
extendWith<K extends keyof Target>(k: K, vc: (x: T) => Target[K]): Stretch<Target, T & { [k in K]: Target[K] }> { | |
return this.extend(k, vc(this.x)); | |
} | |
// Extend with several keys, in the form of key value pairs. | |
extendMany<K extends keyof Target, L extends readonly TypedKV<Target, keyof Target>[]>( | |
vc: (typedKV: typeof this.typedKV, x: T) => [TypedKV<Target, K>, ...L] | |
): Stretch<Target, AssignMany<T & { [k in K]: Target[K] }, L>> { | |
const [kv0, ...adds] = vc(this.typedKV, this.x); | |
const [k, v] = kv0.pure(); | |
const extension = { [k]: v } as { [k in K]: Target[K] }; | |
const initial: T & typeof extension = { ...this.x, ...extension }; | |
const additions = Object.fromEntries(adds.map(x => x.pure())) as AssignMany<{}, typeof adds>; | |
const combied = { ...initial, ...additions } as AssignMany<typeof initial, typeof adds>; | |
return new Stretch(combied); | |
} | |
// Extract essence. Unwrap the Stretch layer. | |
// N.B: You can call `pure` whenever you want - not necessarily once you have reached `Target` construction fully. | |
// N.B: It's possible to make `pure` callable only once `T = Target`, but why? | |
pure(): T { | |
return this.x; | |
} | |
} | |
// Good ol' `Stretch`, promisified. | |
export class StretchP<Target, T = {}> { | |
private typedKV: <K extends keyof Target>(k: K, v: Target[K]) => TypedKV<Target, K>; | |
constructor(private readonly xprom: Promise<T>) { | |
this.typedKV = (k, v) => new TypedKV(k, v); | |
} | |
// Extend with a key and a value, where the value to be assigned must follow from `Target[K]` | |
// where `K` is indeed a key of `Target`. The user may not choose the type of the value freely. | |
extend<K extends keyof Target>(k: K, v: Target[K]): StretchP<Target, T & { [k in K]: Target[K] }> { | |
const extension = { [k]: v } as { [k in K]: Target[K] }; | |
const res = this.xprom.then(x => ({ ...x, ...extension })); | |
return new StretchP(res); | |
} | |
// extend as above, but this time with a function to handle stateful computation perhaps. | |
extendWith<K extends keyof Target>( | |
k: K, | |
vc: (x: T) => Promise<Target[K]> | |
): StretchP<Target, T & { [k in K]: Target[K] }> { | |
const res = this.xprom.then(x => | |
vc(x).then(v => { | |
const newStretch = this.extend(k, v); | |
return newStretch.pure(); | |
}) | |
); | |
return new StretchP(res); | |
} | |
// Extend with several keys, in the form of key value pairs. | |
extendMany<K extends keyof Target, L extends readonly TypedKV<Target, keyof Target>[]>( | |
vc: (typedKV: typeof this.typedKV, x: T) => Promise<[TypedKV<Target, K>, ...L]> | |
): StretchP<Target, AssignMany<T & { [k in K]: Target[K] }, L>> { | |
const res: Promise<AssignMany<T & { [k in K]: Target[K] }, L>> = this.xprom.then(x => | |
vc(this.typedKV, x).then(([kv0, ...adds]) => { | |
const [k, v] = kv0.pure(); | |
const extension = { [k]: v } as { [k in K]: Target[K] }; | |
const initial: T & typeof extension = { ...x, ...extension }; | |
const additions = Object.fromEntries(adds.map(x => x.pure())) as AssignMany<{}, typeof adds>; | |
return { ...initial, ...additions } as AssignMany<typeof initial, typeof adds>; | |
}) | |
); | |
return new StretchP(res); | |
} | |
// Extract essence. Unwrap the Stretch layer. | |
// N.B: You can call `pure` whenever you want - not necessarily once you have reached `Target` construction fully. | |
// N.B: It's possible to make `pure` callable only once `T = Target`, but why? | |
pure(): Promise<T> { | |
return this.xprom; | |
} | |
} | |
type Head<L> = L extends [infer H, ...any[]] ? H : never; | |
type Tail<L> = L extends [any, ...infer T] ? T : never; | |
type AssignMany<C, L> = L extends [] | |
? C | |
: Head<L> extends TypedKV<infer Target, infer K> | |
? K extends keyof Target | |
? AssignMany<C & { [k in K]: Target[K] }, Tail<L>> | |
: never | |
: never; | |
export function stretch<Target>(): Stretch<Target, {}> { | |
return new Stretch({}); | |
} | |
/** Like 'stretch' but start with some concrete value. | |
Example: `stretchWith<IApplication, Pick<IApplication, 'wow'>>({ wow: cond })` | |
*/ | |
export function stretchWith<Target, T>(x: T): Stretch<Target, T> { | |
return new Stretch(x); | |
} | |
export function stretchP<Target>(): StretchP<Target, {}> { | |
return new StretchP(Promise.resolve({})); | |
} | |
/** Like 'stretchP' but start with some concrete value. | |
Example: `stretchWithP<IApplication, Pick<IApplication, 'wow'>>({ wow: cond })` | |
*/ | |
export function stretchWithP<Target, T>(x: T): StretchP<Target, T> { | |
return new StretchP(Promise.resolve(x)); | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
References, tangentially related:-