Skip to content

Instantly share code, notes, and snippets.

@JSuder-xx
Last active May 4, 2022 02:54
Show Gist options
  • Save JSuder-xx/e16483bed426e398acacb6b4a732609a to your computer and use it in GitHub Desktop.
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.
/**
* 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