@ganbarodigital/ts-lib-value-objects
v2.1.0
Published
Helps you create value objects and refined types for safer software
Downloads
6
Maintainers
Readme
Value Objects for Typescript
Introduction
This TypeScript library will help you create value objects and refined types. They will help you write safer software that also performs better.
NOTE: This README documents the v2
API. The older V1
API has been dropped.
- Introduction
- Quick Start
- General Concepts
- Which Approach Is Best?
- Foundation Types
- Value Objects
- Entities and EntityObjects
- Refined Types
- Refined Types As Value Objects
- Converting From
V1
API Tov2
API - NPM Scripts
Quick Start
# run this from your Terminal
npm install @ganbarodigital/ts-lib-value-objects
// add this import to your Typescript code
import { RefinedString } from "@ganbarodigital/ts-lib-value-objects/lib/v2"
VS Code users: once you've added a single import anywhere in your project, you'll then be able to auto-import anything else that this library exports.
General Concepts
The basic idea behind value objects and refined types is to use TypeScript's type system to reduce the amount of runtime checks we need (improve robustness) and reduce the amount of logic defects in our code (improve correctness).
How do we do this?
- We define new types that can only contain valid values.
- We take all of the
isXXX()
kind of checks that are scattered around our code, and put them into smart constructors for these new types. - That way, the checks run once (when we create the value), and don't have to run again when we pass the value into functions and methods (because the TypeScript compiler prevents us passing the wrong type / returning the wrong type).
- Our functions and methods can then be made simplier, because they know they're receiving a value that is always valid. In many cases, functions and methods can be made to work 100% of the time.
Or, to put it another way, we consolidate most of our defensive programming checks into smart constructors, and we write our business logic to always work for our new types.
None of this removes the need to write good unit tests. You'll end up writing less code, with less complexity. This will reduce the number of branches in your code. Less branches means less test cases to cover with your unit tests. You still need to write unit tests for every branch of code that remains.
An Example: UUIDs
For example, a universally-unique ID (or UUID for short) is a string that contains a mixture of hexadecimal values and some '-' separator characters. The string must be 36 characters long, and the separators must be in specific places in the string. And there's some rules about the values of some of the hexadecimal characters too.
We can say that "all UUIDs are strings", but we cannot say that "all strings are UUIDs". What do we mean?
- We can use a UUID's value as a parameter to any function or method that expects a
string
, and that function / method will work as expected. - But if we pass any old string into a function / method that expects a UUID, the function / method probably won't work as expected.
In pure JavaScript (and many other languages!), each function / method would have to call an isUUID(input)
function first, just to protect itself from a bad input. This is a robustness check, and an example of defensive programming. You may have also heard it called programming by contract.
Each call to isUUID()
is a runtime check: it happens every time the function / method calls. There's a performance cost to runtime checks, and that cost adds up very quickly.
We want to use TypeScript to replace these runtime checks with compile-time checks instead. And there's two ways to do that:
- we can build a
Uuid
class, and create value objects, or - we can create a
Uuid
"branded" string, known as a refined type
Both options give you a Uuid
type, that you can then use across your TypeScript code:
function doSomething(uuid: Uuid) { /* ... */ }
// uuid1 is a value object
const uuid1 = Uuid.from("123e4567-e89b-12d3-a456-426655440000");
// this works
doSomething(uuid1);
// this produces a TypeScript compile error
doSomething("123e4567-e89b-12d3-a456-426655440000");
or
function doSomething(uuid: Uuid) { /* ... */ }
// uuid2 is a refined type
// it's actually a 'branded' string, and not an object
// but, at compile time, TypeScript treats it as a unique type
const uuid2 = uuidFrom("123e4567-e89b-12d3-a456-426655440000");
// this works
doSomething(uuid2);
// this produces a TypeScript compile error
doSomething("123e4567-e89b-12d3-a456-426655440000");
Each approach has pros and cons, and we'll cover those below.
Which Approach Is Best?
Use value objects when:
- you are mixing TypeScript and JavaScript in the same code base
- if you want to keep everything as simple as possible
- you are wrapping objects / interfaces
Use refined types when:
- your app is written entirely in TypeScript
- you're comfortable dealing with a mix of value objects and refined types
- you're comfortable with the gotchas of refined types
Note that refined types only work for strings and numbers. For everything else, you have to use value objects anyway.
Foundation Types
Both value objects and refined types rely on a group of underlying, foundational types. We use these types to describe the functions that our smart constructors call.
Type Guard
/**
* A TypeGuard inspects a piece of data to see if the data is the given
* type.
*
* If the given data is the given type, the function returns `true`.
* It returns `false` otherwise.
*
* TypeGuards are a form of runtime robustness check. They're used to
* make sure that the given input is the type you think it is, before you
* try and use that input. They help prevent runtime errors.
*/
export type TypeGuard<T> = (input: unknown) => input is T;
A type guard is a function that tells the TypeScript compiler to treat a value as a given type.
import { TypeGuard } from "@ganbarodigital/ts-lib-value-objects/lib/v2";
const isUuidType: TypeGuard<Uuid> = (input: unknown): input is Uuid => {
if (input instanceof Uuid) {
return true;
}
return false;
}
Type guards are a TypeScript language feature. They're an essential part of getting the benefits of using TypeScript over plain Javascript.
if (isUuidType(input)) {
// TypeScript will let you access all the methods of `Uuid` now
} else {
// TypeScript knows that `input` is NOT a `Uuid` here
}
Notes:
- we recommend ending your type guards with the word
Type
, to tell them apart from data guards. For example,isUuidType()
instead ofisUuid()
. That creates space for aisUuidData()
function later on.
We've added the TypeGuard
type for completeness. It's handy if you're passing a type guard as a parameter into another function / method.
Data Guard
/**
* A DataGuard inspects a piece of data to see if the data meets a
* given contract / specification.
*
* If the given data does meet the contract, the function returns `true`.
* It returns `false` otherwise.
*
* DataGuards work best when they check for one thing, for something that
* can't meaningfully be broken down into multiple things.
*
* That makes them very reusable, and it allows you to build up rich
* error reporting in your code.
*
* `T` is the type of data to be inspected.
*/
export type DataGuard<T> = (input: T) => boolean;
A data guard is a function. It inspects the given data, and returns true
if the data matches its internal rule(s). It returns false
otherwise.
Data guards are an example of a contract or specification, depending on what programming paradigm you subscribe to :)
import { DataGuard } from "@ganbarodigital/ts-lib-value-objects/lib/v2";
const UuidRegex = new RegExp("^[0-9a-f]{8}-[0-9a-f]{4}-[0-5][0-9a-f]{3}-[089ab][0-9a-f]{3}-[0-9a-f]{12}$", "i");
const isUuidData: DataGuard<string> = (input: string): boolean => {
return UuidRegex.test(input);
}
Notes:
- keep your data guards as short as possible, so that they're as reusable as possible
- ideally, each data guard should check just one thing. You can build stricter data guards by composing multiple, smaller, guards together.
Data Guarantee
/**
* A DataGuarantee inspects the given data, to see if the given data
* meets a defined contract / specification.
*
* If the given data does meet the given contract / specification, the
* DataGuarantee returns the given data.
*
* If the given data does not meet the given contract / specification,
* the DataGuarantee calls the supplied OnError handler. The OnError
* handler must throw an Error of some kind.
*
* `T` is the type of data to be inspected
*
* When you implement a DataGuarantee, make it a wrapper around one or more
* TypeGuards and/or DataGuards - and even other DataGuarantees if
* appropriate. That's the best way to make your code as reusable as possible.
*/
export type DataGuarantee<T>
= (input: T, onError: OnError) => void;
A data guarantee is a function. It enforces a contract or specification. It calls one or more data guards to inspect the given input, and if the input doesn't meet the contract / specification, it calls the supplied OnError
handler.
The OnError
handler decides which Error
to throw. If it is called, the OnError
handler always throws an Error
of some kind.
import { OnError } from "@ganbarodigital/ts-lib-error-reporting/lib/v1";
import { DataGuarantee } from "@ganbarodigital/ts-lib-value-objects/lib/v2";
const invalidUuid = Symbol("invalidUuid");
const mustBeUuidData: DataGuarantee<string> = (input: string, onError: OnError): void => {
// does the string contain a well-formatted UUID?
if (!isUuidData(input)) {
onError(new InvalidUuidError({public: {input}}));
}
// if we get here, all is well
}
There's a lot going on here. Let's break it down:
mustBeUuidData()
is a data guarantee. It takes an input string to inspect, and anonError
handler to call if the inspection fails.- It reuses the existing data guard
isUuidData()
. It doesn't define its own checks. - If the
isUuidData()
check fails,mustBeUuidData()
doesn't have its own hard-coded error that it throws. Instead, it calls theOnError
handler (that you provide), so that you can decide what to do and what error to throw.
Data guarantees get used in so-called smart constructors:
import { OnError } from "@ganbarodigital/ts-lib-error-reporting/lib/v1";
import { ValueObject } from "@ganbarodigital/ts-lib-value-objects/lib/v2";
// this is the value object approach
class Uuid extends ValueObject {
// `from()` is our smart constructor
public static from(input: string, onError?: OnError): Uuid {
// make sure we have an error handler
onError = onError ?? defaultUuidErrorHandler;
// enforce the data guarantee
mustBeUuidData(input, onError);
// we tell our parent class' constructor which
// data guarantee and error handler to use, and
// it takes care of all the necessary calls
return new Uuid(input);
}
}
// call the 'smart constructor' to create the value
// `uuid` is an object, and an instanceof Uuid
let uuid = Uuid.from("123e4567-e89b-12d3-a456-426655440000");
import { OnError } from "@ganbarodigital/ts-lib-error-reporting/lib/v1";
import { Branded, makeRefinedTypeFactory } from "@ganbarodigital/ts-lib-value-objects/lib/v2";
// this is an example of "type refinement", using a "branded" string
type Uuid = Branded<string, "uuid">;
// TypeScript can't infer this type
// so we have to define it manually
type UuidBuilder = (input: string, onError?: OnError) => Uuid;
// uuidFrom() is a 'smart constructor'
const uuidFrom: UuidBuilder = makeRefinedTypeFactory(
mustBeUuidData,
defaultUuidErrorHandler,
);
// call the 'smart constructor' to create the value
//
// `uuid` is a string in plain Javascript
// TypeScript sees it as an Intersection type
let uuid = uuidFrom("123e4567-e89b-12d3-a456-426655440000");
Data Coercion
/**
* A DataCoercion inspects the given data, to see if the given data
* meets a defined contract / specification.
*
* If the given data does meet the given contract / specification, the
* DataCoercion returns the given data.
*
* If the given data does not meet the given contract / specification,
* the DataCoercion calls the supplied OnError handler. The OnError
* handler can do any of the following:
*
* a) it can throw an Error (ie it never returns), or
* b) it can return a value that does meet the given contract / specification
*
* `T` is the type of data to be inspected
* `GR` is the return type of the data guarantee function
* - it *must* be compatible with `T` in some way
* - `GR` is also the return type of the supplied `OnError` handler
*
* When you implement a DataCoercion, make it a wrapper around one or more
* TypeGuards and/or DataGuards - and even other DataCoercions if
* appropriate. That's the best way to make your code as reusable as possible.
*/
export type DataCoercion<T, GR extends T = T>
= (input: T, onError: OnError<AnyAppError, GR>) => GR;
A data coercion is a function. It enforces a contract or specification. It inspects the given input, and if the input doesn't meet the contract / specification, it calls the supplied OnError
handler. (So far, that's exactly the same as a data guarantee.)
The OnError
handler can do one of two things:
- it can return a value that does meet the contract / specification (this is new!), or
- it can throw an
Error
of some kind (just like anOnError
handler for a data guarantee)
import { AnyAppError, OnError } from "@ganbarodigital/ts-lib-error-reporting/lib/v1";
import { DataCoercion } from "@ganbarodigital/ts-lib-value-objects/lib/v2";
const mustBeUuidData: DataCoercion<string> = (input: string, onError: OnError<AnyAppError, string|never>): string => {
// does `input` contain a well-formatted UUID?
// if it doesn't, our onError handler has the opportunity to return
// something that is correct
if (!isUuidData(input)) {
input = onError(new InvalidUuidError({public: {input}});
}
// if we get here, all is well
return input;
};
We've added data coercion for the sake of completeness. In our experience, we use data guarantees practically everywhere.
Value Objects
What Is A Value Object?
A value object is:
- an object
- with a type signature
- with a smart constructor (one that enforces a data guarantee)
- that holds one (and only one!) value
- that is immutable
- that allows you to get the stored value
/**
* Value<T> describes the behaviour of data that does have a value,
* but does not have an identity (a primary key).
*
* It is useful for ensuring all value objects have a *minimal* set
* of common behaviour, whether or not they share a common base class.
*
* Use Entity<ID,T> for data that does have an identity.
*/
export interface Value<T> {
/**
* a type-guard.
*
* added mostly for completeness
*/
isValue(): this is Value<T>;
/**
* returns the wrapped value
*
* for types passed by reference, we do NOT return a clone of any kind.
* You have to be careful not to accidentally change this value.
*/
valueOf(): T;
}
/**
* ValueObject<T> is the base class for defining your Value Object
* hierarchies.
*
* Every Value Object:
*
* - has a stored value
* - that you can get the valueOf()
*
* We've deliberately kept this as minimal as possible. We're looking to
* support idiomatic TypeScript code, rather than functional programming.
*
* If you do want fully-functional programming, use one of the many
* excellent libraries that are out there instead.
*
* Use EntityObject<ID,T> for data that has an identity (a primary key).
*/
export class ValueObject<T> implements Value<T> {
/**
* this is the data that we wrap
*
* child classes are welcome to access it directly (to avoid the cost
* of a call to `valueOf()`), but should never modify the data at all
*/
protected readonly value: T;
/**
* this constructor does no contract / specification enforcement at all
* do that in your constructor, before calling super()
*
* if you don't need to enforce a contract, your class can safely
* create a public constructor
*/
protected constructor(input: T) {
this.value = input;
}
/**
* returns the wrapped value
*
* for types passed by reference, we do NOT return a clone of any kind.
* You have to be careful not to accidentally change this value.
*/
public valueOf(): T {
return this.value;
}
/**
* a type-guard. It proves that an object is a wrapper around type `T`.
*
* added mostly for completeness
*/
public isValue(): this is Value<T> {
return true;
}
}
How To Build Value Objects
Define one (or more!) data guards:
const UuidRegex = new RegExp("^[0-9a-f]{8}-[0-9a-f]{4}-[0-5][0-9a-f]{3}-[089ab][0-9a-f]{3}-[0-9a-f]{12}$", "i");
export function isUuidData(input: string): boolean {
return UuidRegex.test(input);
}
Define a data guarantee that uses your data guard(s):
import { OnError } from "@ganbarodigital/ts-lib-error-reporting/lib/v1";
export function mustBeUuidData(input: string, onError: OnError): void {
if (isUuidData(input)) {
return true;
}
return onError(new InvalidUuidError({public: { input}});
}
Define a class that:
- extends
ValueObject
, and - has a smart constructor
import { OnError, THROW_THE_ERROR } from "@ganbarodigital/ts-lib-error-reporting/lib/v1";
import { ValueObject } from "@ganbarodigital/ts-lib-value-objects/lib/v2";
class Uuid extends ValueObject<string> {
public static from(input: string, onError: OnError = THROW_THE_ERROR): Uuid {
// enforce the contract / specification!
mustBeUuidData(input, onError);
// if we get here, we *know* that `input` is safe to store
return new Uuid(input);
}
}
At this point, you can create new Uuid value objects:
// call the 'smart constructor' to create the value
// `uuid` is an object, and an instanceof Uuid
let uuid = Uuid.from("123e4567-e89b-12d3-a456-426655440000");
... and you can use the Uuid
type safely in any of your functions:
function uuidToBytes(uuid: Uuid): Buffer {
// convert a well-formatted UUID into binary data
return Buffer.from(uuid.valueOf().split("-").join(), "hex");
}
ValueObject.isValue()
Every value object comes with a isValue()
method:
class ValueObject {
/**
* a type-guard.
*
* added mostly for completeness
*/
isValue(): this is Value<T>;
}
Honestly, I can't think of an example of where you'd need to call it. It's there if you ever need it!
ValueObject.valueOf()
Every value object comes with a valueOf()
method:
class ValueObject {
/**
* returns the wrapped value
*
* for types passed by reference, we do NOT return a clone of any kind.
* You have to be careful not to accidentally change this value.
*/
valueOf(): T;
}
Use valueOf()
to get access to the wrapped type:
function uuidToBytes(uuid: Uuid): Buffer {
// convert a well-formatted UUID into binary data
return Buffer.from(uuid.valueOf().split("-").join(), "hex");
}
Notes:
- The value returned by
valueOf()
may not be immutable. That's out of our control. So be careful how you use it. In particular, avoid+=
,-=
type operators.
Advantages Of Value Objects
In no particular order ...
- Value objects have type information at runtime. You can use the Javascript
instanceof
operator in your type-guards to be sure that you're working with the type of object that you expect. - Value objects can be extended, to add further behaviours via additional methods (if you prefer an OOP-style of programming).
- Value objects are safer if your code-base is a mixture of TypeScript and Javascript (e.g., you're gradually converting an older Javascript project over to TypeScript).
Disadvantages Of Value Objects
In no particular order ...
- Value objects perform worse than refined types.
- Value objects take up more RAM at runtime than refined types.
- TypeScript v3.7.x (the latest at the time of writing) doesn't correctly support JavaScript's object-to-primitive auto-conversion mechanisms. If you're wrapping a
number
, you might prefer doing that as a refined type.
Entities and EntityObjects
What Is An Entity?
An entity is a value that has some form of identity. For example, database records that have primary keys are entities.
/**
* Entity<ID, T> describes the behaviour of data that has an identity.
*
* It is useful for ensuring all entities have a *minimal* set
* of common behaviour, whether or not they share a common base class.
*/
export interface Entity<ID, T> {
/**
* this entity's identity.
*
* this is normally one (or more) fields from `T`.
*/
idOf(): ID;
/**
* a type-guard. It proves that an object is a wrapper around type `T`
* that has ID `ID`.
*
* added mostly for completeness
*/
isEntity(): this is Entity<ID, T>;
/**
* returns the wrapped value
*
* for types passed by reference, we do NOT return a clone of any kind.
* You have to be careful not to accidentally change this value.
*/
valueOf(): T;
}
How Does An Entity Differ From A Value?
They're very similar, in practice.
- Both are wrappers around a value.
- Both provide smart constructors to make sure that they don't contain illegal values.
- Both implement
valueOf()
to get at that wrapped value. - Both can implement individual
get
accessors to provide access to individual parts of the wrapped value. - They share the same advantages and disadvantages compared to refined types.
The main difference is that an Entity
provides a readonly property called __id__
too. __id__
will always be the identity of the Entity
. For example, in a database record, __id__
will always return the primary key.
How To Build Entities
The process is almost the same as building a value object.
- Define one (or more) data guards.
- Define a data guarantee that uses your data guard(s).
- Define an interface (which we'll call
T
) to represent whatever record will be stored in the Entity object. - Define a type (or re-use an existing type) to represent the identity of your entity.
- Define a class that:
- extends
EntityObject
, - has a smart constructor,
- has a
idOf()
function, - (maybe) has get accessors for the individual members of your type
T
- extends
import { OnError, THROW_THE_ERROR } from "@ganbarodigital/ts-lib-error-reporting/lib/v1";
import { EntityObject } from "@ganbarodigital/ts-lib-value-objects/lib/v2";
class ExampleEntity extends EntityObject<ID, T> {
public static from(input, T: onError: OnError = THROW_THE_ERROR): ExampleEntity {
// call your smart constructor here
mustBeExample(input, onError);
return new ExampleEntity(input);
}
public idOf(): ID {
// replace `primaryKey` with whatever field(s) form the identity
// of your entity
return input.primaryKey;
}
}
Refined Types
What Is A Refined Type?
A refined type is:
- a primitive type
- with a well-defined subset of valid values
- with a smart constructor (one that enforces a data guarantee)
We use refined types where we want to restrict (say) the range of numbers that a function can accept.
The refined type only exists inside the TypeScript compiler. At runtime, the Javascript interpreter only sees the underlying primitive type. Think of them as being like TypeScript interfaces, not Javascript classes.
What Is Type Refinement?
We say that type refinement is the process of turning a more generic type into a stricter type.
For example, all UUIDs are strings, but not all strings are valid UUIDs. In this case, type refinement would be where we take a string
type as input, and return a Uuid
type as output.
Type refinement is always done by the smart constructor.
How To Build A Refined Type
In TypeScript, there are two different ways to create a refined type.
- Type branding is a little stricter: they can only be created using smart constructors
- Type flavouring is a little looser: you can cast primitives to the refined type without having to call a smart constructor
As a rule of thumb, type branding is safer, but sometimes type flavouring can be more convenient.
Type Branding
Define one (or more!) data guards:
const UuidRegex = new RegExp("^[0-9a-f]{8}-[0-9a-f]{4}-[0-5][0-9a-f]{3}-[089ab][0-9a-f]{3}-[0-9a-f]{12}$", "i");
export function isUuidData(input: string): boolean {
return UuidRegex.test(input);
}
Define a data guarantee:
import { OnError } from "@ganbarodigital/ts-lib-error-reporting/lib/v1";
export function mustBeUuidData(input: string, onError: OnError): void {
if (isUuidData(input)) {
return true;
}
return onError(new InvalidUuidError({public: { input }}));
}
Define a default onError handler:
export function throwInvalidUuidError(e: AnyAppError): never {
throw e;
}
Define a branded type and smart constructor:
import { OnError } from "@ganbarodigital/ts-lib-error-reporting/lib/v1";
import {
Branded,
RefinedTypeFactory,
makeRefinedTypeFactory,
} from "@ganbarodigital/ts-lib-value-objects/lib/v2";
// this is our branded type
type Uuid = Branded<string, "uuid">;
// we need to give the TypeScript compiler a bit of help
//
// as of v3.7, it cannot work out by itself that our
// `uuidFrom()` function below returns a `Uuid`
type UuidFactory = RefinedTypeFactory<string, Uuid>;
// this is our smart constructor
const uuidFrom: UuidFactory = makeRefinedTypeFactory<string, Uuid>(
mustBeUuidData, throwInvalidUuidError
)
At this point, you can create new UUID values:
// call the 'smart constructor' to create the value
// `uuid` is a string at runtime, and a `Uuid` interface at compile-time
let uuid = uuidFrom("123e4567-e89b-12d3-a456-426655440000");
... and you can use the Uuid
type safely in any of your functions:
function uuidToBytes(uuid: Uuid): Buffer {
// convert a well-formatted UUID into binary data
//
// NOTE that we do not need to call `valueOf()` here
return Buffer.from(uuid.split("-").join(), "hex");
}
Type Flavouring
(Type flavouring is a technique first documented by Atomic Object.)
There's only one difference between flavoured types and branded types: you can assign a compatible primitive to a flavoured type.
import { Flavoured } from "@ganbarodigital/ts-lib-value-objects/lib/v2";
type Uuid = Flavoured<string, "uuid">;
// here, we tell TypeScript to treat this string
// as a `Uuid` type
const uuid: Uuid = "123e4567-e89b-12d3-a456-426655440000";
Flavoured types are more convenient when loading data from a database.
Gotchas For Branded Types And Flavoured Types
Branded types and flavoured types don't give you the same level of type safety as value objects, even at compile time.
Unfortunately, TypeScript won't tell you if you mix these types in an operation:
import { Branded } from "@ganbarodigital/ts-lib-value-objects/lib/v2";
type Inches = Branded<number, "inches">;
type Centimetres = Branded<number, "centimetres">;
const a = 9 as Inches;
const b = 10 as Centimetres;
// this should not compile ... but it does
const c = a + b;
To use branded types and flavoured types safely, make sure that you wrap any operations in a function:
import { Branded } from "@ganbarodigital/ts-lib-value-objects/lib/v2";
type Inches = Branded<number, "inches">;
type Centimetres = Branded<number, "centimetres">;
function sum(a: Inches, b: Centimetres): Centimetres {
return ((a * 2.54) + b) as Centimetres;
}
const a = 9 as Inches;
const b = 10 as Centimetres;
// this will compile
const c = sum(a, b);
// this will not compile, because the parameters
// are the wrong way around
const c = sum(b, a);
makeRefinedTypeFactory()
makeRefinedTypeFactory()
builds your smart constructor for you.
export type RefinedTypeFactory<BI, BR> = (input: BI, onError?: OnError) => BR;
/**
* makeRefinedTypeFactory creates factories for your branded and
* flavoured types.
*
* You tell it:
*
* - what input type your factory should accept
* - the DataGuarantee to enforce
* - the default error handler to call if the DataGuarantee fails
* - what output type your factory should return
*
* and it will return a type-safe function that you can re-use to validate
* and create your branded and flavoured types.
*
* `BI` is the input type that your factory accepts (e.g. `string`)
* `BR` is the type that your factory returns
*
* @param mustBe
* this will be called every time you use the function that we return.
* Make sure that it has no side-effects whatsoever.
* @param defaultOnError
* the function that we return has an optional `onError` parameter.
* If the caller doesn't provide an `onError` parameter, the function
* will call this error handler instead.
*/
export const makeRefinedTypeFactory = <BI, BR>(
mustBe: DataGuarantee<BI>,
defaultOnError: OnError,
): RefinedTypeFactory<BI, BR>
You pass in your data guarantee and a default OnError
handler, and makeRefinedTypeFactory()
returns a function that you can use as a smart constructor.
See the Type Branding example code above to see it in action.
makeRefinedTypeFactoryWithFormatter()
makeRefinedTypeFactoryWithFormatter()
builds your smart constructor for you.
export type RefinedTypeFactory<BI, BR> = (input: BI, onError?: OnError) => BR;
/**
* makeRefinedTypeFactoryWithFormatter creates factories for your branded and
* flavoured types that support an extra data formatting stage.
*
* You tell it:
*
* - what input type your factory should accept
* - the DataGuarantee to enforce
* - the default error handler to call if the DataGuarantee fails
* - how to reformat the input before it is returned
* - what output type your factory should return
*
* and it will return a type-safe function that you can re-use to validate
* and create your branded and flavoured types.
*
* `BI` is the input type that your factory accepts (e.g. `string`)
* `BR` is the type that your factory returns
*
* @param mustBe
* this will be called every time you use the function that we return.
* Make sure that it has no side-effects whatsoever.
* @param formatter
* this will be called after validation. Use this to modify the
* returned value (e.g. to convert a string to lowercase)
* @param defaultOnError
* the function that we return has an optional `onError` parameter.
* If the caller doesn't provide an `onError` parameter, the function
* will call this error handler instead.
*/
export const makeRefinedTypeFactoryWithFormatter = <BI, BR>(
mustBe: DataGuarantee<BI>,
formatter: (input: BI) => BI,
defaultOnError: OnError = THROW_THE_ERROR,
): RefinedTypeFactory<BI, BR>;
This is a variant of makeRefinedTypeFactory()
. It takes an additional parameter - the formatter
. The formatter
is a function used to format the value before it is returned.
For example, you can force the return value to be a lower-case string like this:
const contentTypeFrom = makeRefinedTypeFactoryWithFormatter(
mustBeContentType,
(x) => x.toLowerCase
);
Advantages Of Refined Types
There's almost no runtime performance penalty.
The TypeScript compiler removes the branding / flavouring from the final compiled JavaScript, leaving only the original type behind. For example, when your code runs, a branded string is just a string. But you're only having to do those checks once per value (in the smart constructor) instead of inside every function / method as part of your defensive programming.
The only runtime performance cost is the data checking work done inside the smart constructor. That can still add up, if you're processing hundreds or thousands of pieces of data in a single operation / API handler.
Disadvantages Of Refined Types
The TypeScript compiler doesn't catch operations that use mixed types (see gotchas above). It only catches assignments involving mixed types.
This is a big disadvantage. It puts the effort of type checking back onto the programmer. You can mitigate it with a disciplined approach (wrap all operations in their own functions), but beware: any approach that requires the developer to do the right thing 100% of the time always leads to bugs being shipped to production. Even experienced developers will make mistakes.
No runtime type checking.
Branded / flavoured types are just like interfaces and function types: they only exist within your TypeScript code. None of them exist in the compiled JavaScript. As a result, it's impossible to write any runtime checks that rely on type checking for your refined types.
Is it a problem in practice? It shouldn't be. It does take some getting used to, though.
Refined Types As Value Objects
If you want to create refined types that are still full-blown value objects, we've got support for that too.
RefinedString and RefinedNumber Classes
RefinedString
and RefinedNumber
are two classes we export for you. They're identical, except that one is for wrapping a string
, and the other is for wrapping a number
.
Both classes extend the ValueObject class documented earlier. They also add some additional methods to help Javascript auto-convert to a primitive in some circumstances (for example, writing to the console.log()
).
Here's an example of how to create a Uuid
value object type, using the RefinedString
class.
import { RefinedString } from "@ganbarodigital/ts-lib-value-objects/lib/v2";
import { OnError, THROW_THE_ERROR } from "@ganbarodigital/ts-error-reporting/lib/v1";
const UuidRegex = new RegExp("^[0-9a-f]{8}-[0-9a-f]{4}-[0-5][0-9a-f]{3}-[089ab][0-9a-f]{3}-[0-9a-f]{12}$", "i");
// it is good practice to define a standalone data guard
export function isUuidData(input: string): boolean {
return UuidRegex.test(input);
}
// it is good practice to define a standalone data guarantee
export function mustBeUuid(input: string, onError: OnError): void {
if (isUuidData(input)) {
return true;
}
return onError(new InvalidUuidError({ public: { input } }));
}
class Uuid extends RefinedString {
public static from(input: string, onError?: OnError = THROW_THE_ERROR): Uuid {
// the parent constructor will handle everything for us
super(input, mustBeUuid, onError);
}
}
Notes:
At the time of writing (TypeScript v3.7.x), TypeScript doesn't understand / support auto-conversion of objects to numbers, even though it is valid JavaScript. See TypeScript issue 2031 for where the bug was introduced, TypeScript issue 4538 for the main bug report, and TypeScript issue 2361 for where (we hope) work on the fix is being tracked.
Until this TypeScript bug is fixed, you're probably better off using a refined type for numbers, instead of value objects.
Converting From V1
API To v2
API
Got existing code that uses the V1
API? Here's the steps that you need to take, to switch over to the v2
API:
- Search/replace
ts-lib-value-objects/V1
to bets-lib-value-objects/lib/v2
- Any value objects must now implement the
Value
interface. It was calledValueObject
in theV1
API. - Any value objects must now extend the
ValueObject
base class. It was calledValue
in thev1
API. - Switch your error handlers to use
@ganbarodigital/ts-lib-error-reporting
.
NPM Scripts
npm run clean
Use npm run clean
to delete all of the compiled code.
npm run build
Use npm run build
to compile the Typescript into plain Javascript. The compiled code is placed into the lib/
folder.
npm run build
does not compile the unit test code.
npm run test
Use npm run test
to compile and run the unit tests. The compiled code is placed into the lib/
folder.
npm run cover
Use npm run cover
to compile the unit tests, run them, and see code coverage metrics.
Metrics are written to the terminal, and are also published as HTML into the coverage/
folder.