typescript-domain
Parsing and validation of JSON, or Javascript objects literals, into classes.
Aims at creating consistency for types both at compile time and runtime.
Contents
Installation
This package doesn't depend on anything else. Just do:
npm i typescript-domain
Objective
Typescript is great!
It helps you to prevent messing up your types during development, reminds you that certain variables can be undefined, gives you some useful come completion.
And it all ultimately cuts down on how many pesky runtime errors you face, which are a hassle for both developer and user.
This works great if whatever you're developing is self-contained but, as soon as you start communicating with external systems and get data from various sources, all of your typings are more like assumptions or good wishes. If a wacky API throws you something you're not expecting you're screwed, and so is your user.
This poses two problems:
-
How do you handle all of these "quirks"? Say if for some reason, the API sometimes returns a number when you're expecting a string; do you just do call toString everywhere, just in case?
-
How can you identify what these corner cases are? How can you address them if they only happen rarely or in a manner that is hard to reproduce?
Motivation
API requests
The straight-forward approach to making requests in Typescript is to:
-
use
fetch
or some other package -
pass the result through
JSON.parse
-
annotate that the returned object literal is of type
interface
-
bada bing bada boom, all done
From here you end up with a variable that is an assumption - you're hoping that the fields are correct but you can't be sure.
For example, you get some data with the format:
[{
"streetName": "Dakota Street",
"streetNumber": "3600",
"postalCode": "213",
"city": "Sunzhuang"
}, {
"streetName": "Hagan Avenue",
"streetNumber": "06",
"postalCode": "91130",
"city": "Sanom"
}]
You then define an interface that matches the data you're expecting:
interface Address {
streetAddress: string;
streetNumber: string;
postalCode: string;
city: string;
}
And when using fetch
you can do:
fetch('user/1/addresses').then((addresses: Address[]) => {})
Addresses here are just whatever was returned. No type integrity is enforced, so these object literals can have other fields, missing fields, different types, you name it.
Initializing class objects
It's a pain in the ass to create and initialize a new class object in Typescript, which is why people naturally gravitate towards just using interfaces.
With an interface you can do:
interface Address {
streetAddress: string;
streetNumber: string;
postalCode: string;
city: string;
}
const address: Address = {
streetName: "Dakota Street",
streetNumber: "3600",
postalCode: "213",
city: "Sunzhuang"
}
and you're done.
Using an actual class you end up needing to do something like:
class Address {
public streetAddress: string
constructor(streetAddress) {
this.streetAddress = streetAddress;
}
}
// or
class Address {
constructor(
public streetAddress: string
)
}
const address = new Address(
"Dakota Street"
)
which can cause confusion when having a bunch of attribute in a class.
An alternative is to make the argument of the constructor an object itself.
class Address {
public streetAddress: string
constructor(address: Address) {
this.streetAddress = address.streetAddress;
}
}
const address = new Address({
streetAddress: "Dakota Street"
})
but this causes problems if Address
was to have methods.
Ok, I guess you can use Partial on the argument of the constructor... but then you have to handle all of the possible undefined's manually, possibly adding default values for everything.
Much work.
Solution
@Model
class Address extends Entity<Address> {
@AutoString() street: string | null = null;
@AutoNumber() streetNumber: number | null = null;
}
const address = new Address({
streetName: "Dakota Street",
streetNumber: 3600
});
http.get('users/1/addresses').pipe(EntityConverter.object(Address))
The objective here was to create a solution that works great with parsing and validating object literals
(like what we get from requests), and makes it easy and readable to create objects in code
, including having error checking leveraging Typescript
.
An important aspect was to also make it easy enough to override default behavior to allow for custom field initialization by the developer.
All that needs to be done is to:
-
add
@Model
to class -
have it extend
Entity
(the generic type in the entity is the same as the class we're decorating) -
decorate the fields that should be initialized and validated automatically. There are decoration for the major types of Javascript:
@AutoObject
- requires type argument.@AutoObject(Address) address: Address | null
@AutoBoolean
@AutoString
@AutoNumber
@AutoDate
@AutoEnum
- requires options argument (the values the enum allows)@AutoObjectArray
- requires type argument@AutoBooleanArray
@AutoStringArray
@AutoNumberArray
@AutoDateArray
@AutoEnumArray
- requires options argument (the values the enum allows)
These property decorators are typed:
- you can't decorate a number field with @AutoString. This would result in a TS error:
@AutoString() streetNumber: number | null = null;
- you can't decorate objects with unrelated object types. This is also a TS error:
@AutoObject(Address) address: Customer | null = null;
- you can't decorate arrays with single variable decorators, and vice-versa. Also an error:
@AutoString() values: string[] = [];
All property decorators allow for an optional argument to be passed, an alias (check next section).
How it works
Flow
The parameter decorators (like @AutoString
) run first, during the class prototype definition stage.
These output what properties from the class are decorated, their type, and the class they are associated with.
This data is stored in the MetadataStorage
singleton, which records the information about every decorated property.
When an object is created: the Entity
constructor runs, then the class constructor runs (where you can initialize your non-Auto fields), then the class decorator @Model
runs.
By using the class decorator @Model
, since it runs after the class constructor, all of the Object.keys
are initialized. All of the keys of the object are then iterated over, checked against what's in the MetadataStorage
and those fields are initialized from the object passed as an argument to the constructor.
Parsing and validation
Every Entity
receives an argument data
, containing the data that will initialize the object.
This data
has the same format as the class it initializes, except that all fields can also be undefined
or null
. For arrays, their members are also allowed to be undefined
or null
.
When a field of the data is being validated the following happens:
- For
boolean
,number
andstring
fields: either its value matches the respective type, or it becomesnull
- For
Date
fields: either the value can be parsed into a date (throughnew Date(value)
), or it becomesnull
- For
enum
fields: either the value matches one of the options, or it becomesnull
- For
object
fields: nesting works recursively infinitely, as long as they're different types (checkLimitations
). By using theisValid
method attached to theEntity
, you can specify is a nested object should be thrown away if some of its fields aren't valid, thus making that field in the parent objectnull
- For
array
fields: all members of the array are validated individually. If any end up not being valid (having anull
value), they are removed from the final array.
Updating values
Every Entity
has available an update
function, which receives the same argument data
as the constructor.
This function, when called, behaves the same as the constructor:
- For the fields decorated with
@Auto
: if the field exists in data, its value replaces the value in the object, otherwise the field is skipped - Fields not decorated are skipped
Alias
All decorators allow for an optional argument to be passed, an alias
(string).
This is useful when mapping JSON fields into the object. If the alias
field isn't found in the object literal, it'll then try the name of the field itself (so creating objects from JSON and object literals in Typescript will work at the same time).**
If both the alias
and the field name
exist in the passed data
, the alias
is always prioritized.
Debugging
Besides the data
used to initialize an Entity
, optional arguments are available for debugging purposes - _debugFunction
and _debugSkipUndef
.
If the _debugFunction
is provided, it will be called every time an error in the typings is detected during the validation phase (either the field doesn't exist in the data or it has the wrong type ex: true
to a number
field). The function is called with information about the class
and field
where it happened, as well as the value
and what type of error
.
If the _debugSkipUndef
argument is true, errors regarding missing fields in the data will be skipped (because it might be ignorable sometimes).
This feature can be useful to collect all of the mistypings that happen at runtime, and feed them to, for example, an API endpoint that stores all of these for later analysis.
Use cases
Basic types
@Model
class Address extends Entity<Address> {
@AutoString() street: string | null = null;
@AutoNumber() streetNumber: number | null = null;
@AutoBoolean() isPrimaryAddress: boolean | null = true;
}
// address1 and address2 are instantiated with the default values
const address1 = new Address();
const address2 = new Address({});
// address3 is instantiated with the passed values
const address3 = new Address({
street: 'Streety St.',
streetNumber: 42,
isPrimaryAddress: false
});
// this will show as a ts error
// Type 'string' is not assignable to type 'number'.ts(2322)
const address3 = new Address({
street: 'Streety St.',
streetNumber: '42',
isPrimaryAddress: false
});
// if using JSON data or coercing the argument type somehow
// the object is still created, but streetNumber will be null
// (since '42' isn't compatible with number | null)
const addressBuilder = (data: any) => new Address(data)
const address4 = addressBuilder({streetNumber: '42'})
Enums
const countries = ['Norway', 'Sweden', 'Denmark'] as const;
type Country = typeof countries[number];
@Model
class Address extends Entity<Address> {
@AutoEnum(countries) country: Country | null = null;
}
// address1 is instantiated with the passed values
const address1 = new Address({country: 'Norway'});
// this will show as a ts error
// Type '"Portugal"' is not assignable to type 'PlainData<"Norway" | "Sweden" | "Denmark" | null> | undefined'.ts(2322)
const address2 = new Address({country: 'Denmark'});
Dates
@Model
class Address extends Entity<Address> {
@AutoDate() lastModified: Date | null = null;
}
// these is instantiated with the passed values
// using the new Date(value) constructor
const address1 = new Address({lastModified: new Date()});
const address2 = new Address({lastModified: 1664309665});
const address3 = new Address({lastModified: '2022-09-27T20:14:25+00:00'});
// if a new Date() can't be created from the value, it becomes null
const address4 = new Address({lastModified: false});
Objects
@Model
class Address extends Entity<Address> {
@AutoString() street: string | null = null;
@AutoNumber() streetNumber: number | null = null;
public isValid(): boolean {
return this.street !== null;
}
}
@Model
class Customer extends Entity<Customer> {
@AutoObject(Address) address: Address | null = null;
}
// the nested address field becomes an Address object
// streetNumber becomes null (the default value)
const customer1 = new Customer({
address: {
street: 'Streety St.'
}
})
// the nested address field becomes null because the result of isValid is false
// isValid always returns true by default
const customer2 = new Customer({
address: {
streetNumber: 4
}
})
// this will show as a ts error
// Type 'number' is not assignable to type 'string'.ts(2322)
// if creating from untyped data, street would become null,
// making the address object not valid and the address field becomes null
const customer3 = new Customer({
address: {
street: 1,
streetNumber: 4
}
})
Arrays
@Model
class Address extends Entity<Address> {
@AutoString() street: string | null = null;
@AutoNumberArray() streetNumbers[]: number[] = [];
public isValid(): boolean {
return this.street !== null;
}
}
@Model
class Customer extends Entity<Customer> {
@AutoObjectArray(Address) addresses: Address[] = [];
}
// addresses will have only the first item
// the second one is not valid, becoming null, and nulls are removed from arrays
const customer1 = new Customer({
addresses: [{
street: 'Street 1',
streetNumbers: [1, 2]
}, {
streetNumbers: [1, 2, 3]
}]
})
// this will output type errors in Typescript
// if parsing untyped data, Street 1 will have [1, 2] for streetNumbers,
// and Street2 will have null
const customer2 = new Customer({
addresses: [{
street: 'Street 1',
streetNumbers: [1, 2, '3', null]
}, {
street: 'Street 2',
streetNumbers: '[1, 2, 3]'
}]
})
Debug info
@Model
class Address extends Entity<Address> {
@AutoString() street: string | null = null;
@AutoNumberArray() streetNumbers[]: number[] = [];
}
// Should output:
// {class: 'Address', field: 'street', value: '1', info: 'value is not a string'}
// {class: 'Address', field: 'streetNumbers', value: undefined, info: 'value is undefined'}
new Address({street: 1}, (output) => {
console.log(output);
}, false);
// Should output:
// {class: 'Address', field: 'street', value: '1', info: 'value is not a string'}
new Address({street: 1}, (output) => {
console.log(output);
}, false);
Manually initialized fields
@Model
class Address extends Entity<Address> {
street: string | null = null;
@AutoNumber() streetNumber: number | null = null;
fullAddress: string = '';
constructor(data?: PlainData<Address>) {
super(data);
this.street = data?.street.toString();
// this won't work,
// data gets initialized in the @Model constructor, which executes after this
this.fullAddress = this.data + this.street;
// you need to call the necessary EntityValidation method yourself
// EntityValidation.(array | boolean | date | enum | number | object | string)
this.fullAddress = (data.street ?? '') + EntityValidation.number(data.streetNumber).validatedValue;
}
}
Default values
@Model
class Address extends Entity<Address> {
@AutoString() street: string | null = 'Street 1';
}
// if an @Auto field doesn't exist in the passed data, the default value is used
const address1 = new Address({});
// if it exists, the validated value replaces the default value
const address2 = new Address({street: 'Street 2'});
// if the value is invalid, it becomes null, NOT the default value
const address3 = new Address({street: 1});
Limitations
Circular dependencies aren't handled automatically.
These fields need to be taken care of in the constructor of your class. Here's an example:
@Model
class NestedObject extends Entity<NestedObject> {
@AutoNumber() id: number | null = null;
nested: NestedObject | null = null;
constructor(data?: PlainData<NestedObject> | null) {
super(data);
this.nested = EntityValidation.object(
NestedObject,
data?.nested
).validatedValue;
}
}