Last active
February 11, 2021 01:04
-
-
Save JSuder-xx/27ddd7873e11d6ee5515eb1e65d7112a to your computer and use it in GitHub Desktop.
Example of building safe APIs with type driven development in TypeScript.
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
/** | |
* Yet another demonstration of TypeScript awesomeness | |
* - NonEmptyString demonstrates a simple tagged type (similar to single-case-union in FP). | |
* - DBConfiguration shows off both union and boolean literal types. | |
* - EnvironmentDBConfigurationMap finishes with a smart fluent builder utilizing Conditional and Mapped types | |
* along with type intersection to create a Fluent Builder with stronger verification than is possible | |
* in C#/Java/C++. | |
* | |
* See the TRY comments for things to... well... try. | |
* | |
* Open this in the TypeScript Playground (https://www.typescriptlang.org/play). | |
*/ | |
module ReadMe {} | |
//-------------------------------------------------------------------------- | |
// APIs | |
//-------------------------------------------------------------------------- | |
/** A string verified to be non-empty. */ | |
module NonEmptyString { | |
type NonEmptyTag = { __tag: "NonEmptyString" } | |
type NonEmptyString = string & NonEmptyTag | |
/** | |
* A non-empty string. | |
* - Assignable to string | |
* - **BUT** string is not assignable to this. | |
**/ | |
export type Type = NonEmptyString; | |
type FromLiteral<s extends string> = | |
s extends "" ? unknown // if s is known to be empty then fail | |
: string extends s ? unknown // if s is general (unrefined string) then fail | |
: s & NonEmptyTag; // otherwise we can return the specific non-empty string | |
/** Refines the type of the given string to NonEmpty if it is a non-empty literal; others the type becomes unknown. */ | |
export const literal = <str extends string>(str: str): FromLiteral<str> => str as any; | |
/** If you have a string, test and refine whether it is non-empty. */ | |
export const test = (str: string): str is Type => (str || "").length > 0; | |
} | |
/** | |
* A database configuration is either with integrated security or not. Observe that when not integrated security that | |
* additonal fields are required. If not familiar the | is an OR operation on types just like || is an OR operation | |
* on boolean values in C-syntax languages. | |
*/ | |
type DBConfiguration = | |
| { database: NonEmptyString.Type; integratedSecurity: true} | |
| { database: NonEmptyString.Type; integratedSecurity: false; userName: string; password: string } | |
/** A map from environment name to DBConfiguration */ | |
module EnvironmentDBConfigurationMap { | |
class Builder<availableEnvironments extends string, mapSoFar extends {} = {}> { | |
constructor(private readonly _map: mapSoFar) {} | |
/** Add a mapping for an environment. */ | |
forEnvironment<environmentName extends availableEnvironments, configuration extends DBConfiguration>( | |
environmentName: environmentName, | |
configuration: configuration | |
): Builder< | |
// remove the environmentName from this call from the list of remaining environmentNames in the subsequent builder | |
Exclude<availableEnvironments, environmentName>, | |
// Add the environmentName: configuration to the type of the returned map. | |
mapSoFar & { [prop in environmentName]: configuration } | |
> { | |
return new Builder({ | |
...this._map, | |
[environmentName]: configuration | |
} as any); | |
} | |
/** Get the final environment map. */ | |
get result(): mapSoFar { | |
return this._map; | |
} | |
} | |
type DefaultEnvironments = "production" | "functionalTesting" | "loadTesting" | "securityTesting" | |
export const builder = <environments extends string = DefaultEnvironments>() => new Builder<environments>({}); | |
} | |
//-------------------------------------------------------------------------- | |
// Usage | |
//-------------------------------------------------------------------------- | |
const environmentConfigurationMap = EnvironmentDBConfigurationMap.builder() | |
// TRY: Change integratedSecurity to false and observe the type system complain about the absence of userName and password | |
.forEnvironment("loadTesting", { database: NonEmptyString.literal("TiredTrisha"), integratedSecurity: true }) | |
// TRY: Replacing the database name with an empty string to watch the type system complain that an empty string is not allowed. | |
// TRY: Commenting out the production configuration and watch the call of _exampleUsage_ below fail. | |
.forEnvironment("production", { database: NonEmptyString.literal("ProductionPatty"), integratedSecurity: true }) | |
.forEnvironment("functionalTesting", { database: NonEmptyString.literal("FunctionalFrita"), integratedSecurity: false, userName: "jsuder", password: "password1" }) | |
// TRY: Remove the text 'securityTesting' and press ctrl+space. Observe that 'securityTesting' is the only allowed option. | |
.forEnvironment("securityTesting", {database: NonEmptyString.literal("SececureSally"), integratedSecurity: true}) | |
// TRY: Adding a fifth forEnvironment. Observe that there are no more valid options remaining. | |
.result | |
// TRY: Hover over environmentConfigurationMap to see the descriptive inferred type. | |
exampleUsage(environmentConfigurationMap); | |
// TRY: Uncommenting the line below and inspect the contents of environmentConfigurationMap with intellisense. | |
// environmentConfigurationMap.functionalTesting | |
function exampleUsage(config: { production: DBConfiguration; functionalTesting: DBConfiguration }) { | |
console.log(`Production Database`, config.production.database); | |
console.log(`Functional Database`, config.functionalTesting.database); | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment