Skip to content

Instantly share code, notes, and snippets.

@JSuder-xx
Last active February 11, 2021 01:04
Show Gist options
  • Save JSuder-xx/27ddd7873e11d6ee5515eb1e65d7112a to your computer and use it in GitHub Desktop.
Save JSuder-xx/27ddd7873e11d6ee5515eb1e65d7112a to your computer and use it in GitHub Desktop.
Example of building safe APIs with type driven development in TypeScript.
/**
* 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