/** * 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(); }