Last active
May 4, 2022 02:54
-
-
Save JSuder-xx/e16483bed426e398acacb6b4a732609a to your computer and use it in GitHub Desktop.
Fluent Builder Type API which transforms any arbitrary fluent builder type into a fluent builder type enforcing call count dependencies between methods (where dependencies are represented as a type). Employs Mapped, Conditional, and Literal Types to demonstrate both TypeScript's proximity to dependent typing and the practical value.
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
/** | |
* Imagine a Fluent Builder where some subset of the fluent methods are valid to call | |
* * after one or more fluent methods have been called | |
* * before one or more fluent methods have been called | |
* * limited number of times. | |
* | |
* There is no way to enforce such constraints in statically typed languages such as C++, C# or Java when using a single builder | |
* class/interface. Developers would need to author many interfaces to represent the different shapes and would likely need to | |
* author many versions of the builder itself (proxies with a specific signature delegating to an underlying source builder). | |
* | |
* By contrast, applying TypeScript Literal Types, Mapped Types, and Conditional Types this gist demonstrates the creation of | |
* very specific type signatures from a dependency specification. | |
* | |
* The best way to consume this gist is to jump to ExampleUsage module at the bottom to see how the API can be used. | |
* Read the ReadMe for ideas of things to try. Once you understand what the API has to offer then review the type machinery | |
* in the FluentBuilderConstraintAPI. | |
* | |
* This Gist can be pasted into the TypeScript playground. | |
* | |
* **NOTE** Requires TypeScript >= 3.6.3!!! This code throws errors in earlier versions of TypeScript. | |
* | |
* **Fun Observation** | |
* None of the FluentBuilderConstraintAPI module (roughly 240 lines) is materialized in JavaScript. | |
*/ | |
module FluentBuilderConstraintAPI { | |
//----------------------------------------------------------------------- | |
// General and Object | |
//----------------------------------------------------------------------- | |
/** Return the first if assignable to the second else never. */ | |
type Filter<typeToTest, predicateType> = | |
typeToTest extends predicateType | |
? typeToTest | |
: never; | |
type PropertyNamesOf<ofObject> = { | |
[p in keyof ofObject]: p | |
}[keyof ofObject]; | |
/** Given an object and a type return an object type that has the same member names of the source object but with all the members having the given type. */ | |
type ObjectWithMembersOfType<objectTemplate, typeOfMembers> = { | |
[p in keyof objectTemplate]: typeOfMembers | |
} | |
//----------------------------------------------------------------------- | |
// Function Types | |
//----------------------------------------------------------------------- | |
/** Return the input argument types of a function type. */ | |
type ArgumentTypesOfFunction<functionType extends (...args: any[]) => any> = | |
functionType extends (...args: infer A) => any | |
? A | |
: never; | |
/** Change the return type of the given function type while preserving the argument types. */ | |
type ChangeFunctionReturn<functionType extends (...args: any[]) => any, returnType> = | |
(...args: ArgumentTypesOfFunction<functionType>) => returnType; | |
//----------------------------------------------------------------------- | |
// Boolean Types | |
//----------------------------------------------------------------------- | |
type And<left extends boolean, right extends boolean> = | |
left extends true | |
? right extends true | |
? true | |
: false | |
: false; | |
type Or<left extends boolean, right extends boolean> = | |
left extends true | |
? true | |
: right; | |
type Not<val extends boolean> = | |
val extends true ? false : true; | |
//-------------------------------------------------------------------- | |
// Unions | |
//-------------------------------------------------------------------- | |
/** Returns true if two unions contain the same members. This is good for checking a narrowing. */ | |
type UnionsEquivalent<leftUnion, rightUnion> = | |
And< | |
leftUnion extends rightUnion ? true : false, | |
rightUnion extends leftUnion ? true : false | |
> | |
//----------------------------------------------------------------------- | |
type Increment<num extends number> = | |
num extends 0 ? 1 | |
: num extends 1 ? 2 | |
: num extends 2 ? 3 | |
: num extends 3 ? 4 | |
: num extends 4 ? 5 | |
: num extends 5 ? 6 | |
: num extends 6 ? 7 | |
: num extends 7 ? 8 | |
: num extends 8 ? 9 | |
: num extends 9 ? 10 | |
: num extends 10 ? 11 | |
: num extends 11 ? 12 | |
: num extends 12 ? 13 | |
: never; | |
//----------------------------------------------------------------------- | |
// Number Inequalities | |
//----------------------------------------------------------------------- | |
/** True if left equals right */ | |
type IsNumberEqualTo<left extends number, right extends number> = | |
left extends right | |
? right extends left | |
? true | |
: false | |
: false; | |
type ZeroToOne = 0 | 1; | |
type ZeroToThree = ZeroToOne | 2 | 3; | |
type ZeroToFive = ZeroToThree | 4 | 5; | |
type ZeroToSeven = ZeroToFive | 6 | 7; | |
type ZeroToNine = ZeroToSeven | 8 | 9; | |
type ZeroToEleven = ZeroToNine | 10 | 11; | |
/** True if left is greater than right. Only handles left from [0, 12] but since this is static code analysis that is enough. */ | |
type IsNumberGreaterThan<left extends number, right extends number> = | |
left extends 0 ? false | |
: left extends 1 ? right extends 0 ? true : false | |
: left extends 2 ? right extends ZeroToOne ? true : false | |
: left extends 3 ? right extends ZeroToOne | 2 ? true : false | |
: left extends 4 ? right extends ZeroToThree ? true : false | |
: left extends 5 ? right extends ZeroToThree | 4 ? true : false | |
: left extends 6 ? right extends ZeroToFive ? true : false | |
: left extends 7 ? right extends ZeroToFive | 6 ? true : false | |
: left extends 8 ? right extends ZeroToSeven ? true : false | |
: left extends 9 ? right extends ZeroToSeven | 8 ? true : false | |
: left extends 10 ? right extends ZeroToNine ? true : false | |
: left extends 11 ? right extends ZeroToNine | 10 ? true : false | |
: left extends 12 ? right extends ZeroToEleven ? true : false | |
: left extends 13 ? right extends ZeroToEleven | 12 ? true : false | |
: never; | |
type IsNumberGreaterThanOrEqualTo<left extends number, right extends number> = | |
Or< | |
IsNumberEqualTo<left, right>, | |
IsNumberGreaterThan<left, right> | |
>; | |
type IsNumberLessThanOrEqualTo<left extends number, right extends number> = | |
Not<IsNumberGreaterThan<left, right>>; | |
//------------------------------------------------------------------------------------------------ | |
// Numeric Range | |
//------------------------------------------------------------------------------------------------ | |
type NumericRange = { min?: number; max?: number; } | |
type IsNumberInRange<num extends number, range extends NumericRange> = | |
And< | |
range["min"] extends number | |
? IsNumberGreaterThanOrEqualTo<num, range["min"]> | |
: true, | |
range["max"] extends number | |
? IsNumberLessThanOrEqualTo<num, range["max"]> | |
: true | |
>; | |
//------------------------------------------------------------------------------------------------ | |
// Fluent Builder: Requirements for each Member | |
//------------------------------------------------------------------------------------------------ | |
/** Number of times each method has been called */ | |
type MembersCalledCountMap = { [memberName: string]: number }; | |
type IncrementIfNumber<num> = num extends number ? Increment<num> : never; | |
type IncrementedCallCount<memberToIncrement, membersCalledCountMap> = | |
{ | |
[memberName in keyof membersCalledCountMap]: memberName extends memberToIncrement | |
? IncrementIfNumber<membersCalledCountMap[memberName]> | |
: membersCalledCountMap[memberName]; | |
} | |
type MemberCallCountRequirementsOfOtherMembers = { [memberName: string]: NumericRange }; | |
type _AreMemberCallCountRequirementsMet<requirementsOnOtherMembers extends MemberCallCountRequirementsOfOtherMembers, membersCalledCountMap extends MembersCalledCountMap> = | |
{ | |
[ | |
member | |
in | |
keyof requirementsOnOtherMembers | |
]: member extends string | |
? IsNumberInRange< | |
membersCalledCountMap[member], | |
requirementsOnOtherMembers[member] | |
> | |
: false | |
}[keyof requirementsOnOtherMembers] | |
type AreMemberCallCountRequirementsMet<requirementsOnOtherMembers extends MemberCallCountRequirementsOfOtherMembers, membersCalledCountMap extends MembersCalledCountMap> = | |
PropertyNamesOf<requirementsOnOtherMembers> extends never | |
// when there are no requirements we pass | |
? true | |
// otherwise determine if all of the requirements are met - UnionsEquivalent required because the return type is the union of results. | |
: UnionsEquivalent< | |
true, | |
_AreMemberCallCountRequirementsMet<requirementsOnOtherMembers, membersCalledCountMap> | |
>; | |
//------------------------------------------------------------------------------------------------ | |
// Fluent Builder: Top Level Builder | |
//------------------------------------------------------------------------------------------------ | |
/** For all members, what is each of their requirements on other members. */ | |
type ObjectMemberCallCountRequirements = { [memberName: string]: MemberCallCountRequirementsOfOtherMembers }; | |
type NamesOfMembersWithSatisfiedCallCounts<membersCalledCountMap extends MembersCalledCountMap, objectMemberCallCountRequirements extends ObjectMemberCallCountRequirements> = | |
{ | |
[ | |
memberName | |
in keyof membersCalledCountMap | |
]: | |
memberName extends string | |
? UnionsEquivalent< | |
true, | |
AreMemberCallCountRequirementsMet<objectMemberCallCountRequirements[memberName], membersCalledCountMap> | |
> extends true | |
? memberName | |
: never | |
: never | |
}[keyof membersCalledCountMap]; | |
type _FluentBuilderWithRequirementCountSpecification< | |
BuilderClass, | |
ResultMethodName extends keyof BuilderClass, | |
membersCalledCountMap extends MembersCalledCountMap, | |
objectMemberCallCountRequirements extends ObjectMemberCallCountRequirements | |
> = | |
{ | |
readonly [ | |
memberName | |
in | |
( | |
// always include the result method | |
ResultMethodName | |
// only the fluent methods whose requirements have been satisfied | |
| Filter< | |
keyof BuilderClass, | |
NamesOfMembersWithSatisfiedCallCounts<membersCalledCountMap, objectMemberCallCountRequirements> | |
> | |
) | |
]: | |
memberName extends ResultMethodName | |
// straight pass-thru of the result method | |
? BuilderClass[memberName] | |
: BuilderClass[memberName] extends (...args: any[]) => any | |
// for other functions/methods, assume fluent builder methods | |
? ChangeFunctionReturn< | |
BuilderClass[memberName], | |
_FluentBuilderWithRequirementCountSpecification< | |
BuilderClass, | |
ResultMethodName, | |
IncrementedCallCount<memberName, membersCalledCountMap>, | |
objectMemberCallCountRequirements | |
> | |
> | |
// non-methods are pass-thru | |
: BuilderClass[memberName]; | |
} | |
export type FluentBuilderWithRequirementCountSpecification< | |
BuilderClass, | |
ResultMethodName extends keyof BuilderClass, | |
objectMemberCallCountRequirements extends ObjectMemberCallCountRequirements | |
> = _FluentBuilderWithRequirementCountSpecification<BuilderClass, ResultMethodName, ObjectWithMembersOfType<BuilderClass, 0>, objectMemberCallCountRequirements>; | |
} | |
module ExampleUsage { | |
/** Silly example fluent builder for demonstration purposes. This would be your builder. */ | |
class SillyFluentBuilder { | |
private _messages: string[] = []; | |
withWidget(x: number): SillyFluentBuilder { | |
this._messages.push(`Widget of ${x}`); | |
return this; | |
} | |
withGadget(x: string): SillyFluentBuilder { | |
this._messages.push(`Gadget of ${x}`); | |
return this; | |
} | |
withGidget(x: boolean): SillyFluentBuilder { | |
this._messages.push(`Gidget is ${x}`); | |
return this; | |
} | |
withCake(x: number): SillyFluentBuilder { | |
this._messages.push(`Cake of ${x}`); | |
return this; | |
} | |
getResult(): string { | |
return this._messages.join("\n"); | |
} | |
} | |
/** | |
* This type specifies the call count dependencies of every property of the fluent builder | |
* against every other method (and even potentially itself) of the fluent builder. | |
* | |
* This specification is fed to the constrained fluent builder API to generate the very specific | |
* types which enforce these call count requirements. | |
*/ | |
type SillyFluentBuilderMemberRequirements = { | |
// withWidget will be available for use once the following constraints are met | |
withWidget: { | |
// withWidget is available to be called up to when it has been called 2 times before and no more | |
// ...which means it can be called 3 times in total. | |
// If it should only called once then set max to 0. | |
// If you do not care then simpl exclude. | |
withWidget: { max: 2 }; | |
// withWidget is available to be called when withCake has NOT been called i.e. as soon as withCake is called it is no longer available. | |
withCake: { max: 0 }; | |
// withWidget is available once withGadget has been called once i.e. a dependency and only | |
// while withGadget has been called 2 or fewer times. | |
withGadget: { min: 1, max: 2 }; | |
}; | |
withGidget: { | |
// requires withWidget to have been called twice | |
withWidget: { min: 2 } | |
}; | |
} | |
/** | |
* A factory function that constrains the original fluent builder class. | |
* This is where the magic happens: All of the work is in the mapped type application of FluentBuilderWithRequirementCountSpecification | |
* which constructs a complex type based upon the SillyFluentBuilderMemberRequirements. | |
**/ | |
function createConstrainedSillyFluentBuilder(): FluentBuilderConstraintAPI.FluentBuilderWithRequirementCountSpecification< | |
SillyFluentBuilder, | |
"getResult", | |
SillyFluentBuilderMemberRequirements | |
> { | |
return new SillyFluentBuilder(); | |
} | |
/** | |
* ## First Try | |
* 1. Move withWidget after a call to withCake call to observe a violation of withWidget's dependendency { withCake: { max: 0 } }. | |
* 2. Adding another call to withWidget to observe a violation of the withWidget's own max count { withWidget: { max: 2 } } | |
* 3. Move withWidget to before the first call to withGadget to observe a violation of the dependency of withWidget on withGadget { withGadget: { min: 1 } } | |
* | |
* ## Next Try | |
* 1. Change the counts or specifications of an existing member specification in SillyFluentBuilderMemberRequirements (above). | |
* 2. Add a new specification for another method such as withGadget. | |
*/ | |
module ReadMe {} | |
createConstrainedSillyFluentBuilder() | |
.withGadget("Bonjour") | |
.withWidget(20) | |
.withWidget(20) | |
.withGadget("Bonjour") | |
.withWidget(20) | |
.withGidget(false) | |
.withCake(1) | |
.withCake(2) | |
.withGadget("Bonsoir") | |
.withGidget(false) | |
.withGadget("Bon matin") | |
.withCake(4) | |
.getResult(); | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment