Skip to content

Instantly share code, notes, and snippets.

@crystalgreen
Created February 2, 2021 19:54
Show Gist options
  • Save crystalgreen/9bccb79c509ac595523f8e219b5b3ef7 to your computer and use it in GitHub Desktop.
Save crystalgreen/9bccb79c509ac595523f8e219b5b3ef7 to your computer and use it in GitHub Desktop.
Type preserving serializer for arrays
import { expect } from 'chai';
import 'mocha';
import { converter } from './converter';
function test( array: any[] ): void {
const plain = converter.serialize( array );
console.log( JSON.stringify( plain ) );
const array2 = converter.deserialize( array );
expect( array2 ).to.be.deep.equal( array );
}
describe( 'converter', function () {
it( 'convert number array', function () {
test( [ 1.1, 2.2, 3.3 ] );
} )
it( 'convert integer array', function () {
test( [ 1, 2, 3 ] );
} )
it( 'convert mixed1', function () {
test( [ 1, 2, 3, 'foo' ] );
} )
it( 'convert mixed2', function () {
test( [ 1.1, 2.2, 3, 'foo', new Date( 2020, 10, 10, 15, 30, 0 ), false ] );
} )
it( 'convert string array', function () {
test( [ 'foo', 'bar' ] );
} )
it( 'convert nested array', function () {
test( [ 1, 2, 3, [ 'foo', 'bar' ], 4 ] );
} )
} );
/* This converter can serialize JS arrays to JSON compatible types while preserving JS types.
* This is the light-weight version of a more complete converter that also supports classes and custom types.
* In principle we could support the frictionlessdata types.
* - https://github.com/frictionlessdata/tableschema-js#working-with-field
* - https://specs.frictionlessdata.io/table-schema/#types-and-formats
*/
//#region from typy package
function isArray( input: any ) {
return Array.isArray( input );
}
function isObject( input: any ) {
return typeof input === 'object' &&
input === Object( input ) &&
Object.prototype.toString.call( input ) !== '[object Array]' &&
Object.prototype.toString.call( input ) !== '[object Date]';
}
function isString( input: any ) {
return typeof input === 'string';
}
function isBoolean( input: any ) {
return typeof input === typeof true;
}
function isDate( input: any ) {
return input instanceof Date ||
Object.prototype.toString.call( input ) === '[object Date]';
}
function isNumber( input: any ) {
return Number.isFinite( input );
}
//#endregion
const JSONTypes = [ 'string', 'number', 'boolean', 'null', 'object', 'array' ];
function isJsonType( type: string ) {
return JSONTypes.includes( type );
}
export type TypedValue = [ value: any, type: string ];
export interface IMainConverter {
/** Converts value into a structure that can be fed to JSON.stringify without losing type info. */
serialize( value: any ): any;
/** Converts value coming from a JSON.parse and which contains type info to reconstruct JS objects. */
deserialize( value: any ): any;
getTypeName( value: any ): string;
}
export interface IScopedConverter {
/** This is either one of the TypeName values or a user provided class name. */
readonly type: string;
/** Converts value into a structure that can be fed to JSON.stringify without losing type info. */
serialize( value: any, mainConverter: IMainConverter ): any;
/** Converts value coming from a JSON.parse and which contains type info to reconstruct JS objects. */
deserialize( value: any, mainConverter: IMainConverter ): any;
}
/** Converts a specific type to/from plain. */
export abstract class ScopedConverter implements IScopedConverter {
public readonly type: string;
constructor( type: string ) {
this.type = type;
}
abstract serialize( value: any, mainConverter: IMainConverter ): any;
abstract deserialize( value: any, mainConverter: IMainConverter ): any;
}
export class Converter implements IMainConverter {
private scopedConverters: Map<string, IScopedConverter> = new Map<string, IScopedConverter>();
public register( scopedConverter: IScopedConverter ) {
this.scopedConverters.set( scopedConverter.type, scopedConverter );
}
public serialize( value: any ): any {
const converter = this.getConverterForJS( value );
const result = converter.serialize( value, this );
return result;
}
public deserialize( value: any ): any {
const converter = this.getConverterForPlain( value );
const result = converter.deserialize( value, this );
return result;
}
/** Gets a converter for a given JavaScript object (can be class) */
private getConverterForJS( jsValue: any ): IScopedConverter {
const typeName = this.getTypeName( jsValue );
let converter: IScopedConverter = null;
if ( typeName === "class" ) {
throw new Error( "Class types are not supported" );
}
converter ??= this.getConverterForTypeName( typeName )
?? IdentityConverter.instance;
return converter;
}
/** Gets a scoped converter for a given value in plain format (only JSON types). */
private getConverterForPlain( plainValue: any ): IScopedConverter {
let converter: IScopedConverter = null;
// A typed plain object is either a plain json type like string or it is of format [value, type].
if ( isArray( plainValue ) ) {
const [ value, type ] = plainValue as TypedValue;
if ( isArray( value ) ) {
converter = this.scopedConverters.get( 'array' );
} else {
converter = this.getConverterForTypeName( type );
if ( !converter && isObject( value ) )
throw new Error( "Object not supported" );
}
}
return converter ?? IdentityConverter.instance;
}
public getTypeName( value: any ): string {
const type: string = isArray( value )
? 'array'
: isObject( value )
? 'class'
: isString( value )
? 'string'
: isNumber( value )
? value.toString().includes( '.' )
? 'number'
: 'integer'
: isDate( value )
? 'datetime'
: isBoolean( value )
? 'boolean'
: value === null || value === undefined
? 'null'
: 'json';
return type;
}
private getConverterForTypeName( typeName: string ): IScopedConverter {
let converter = this.scopedConverters.get( typeName );
return converter;
}
}
export class IdentityConverter extends ScopedConverter {
public static instance = new IdentityConverter( '' );
public serialize( value: any, mainConverter: IMainConverter ) {
return value;
}
public deserialize( value: any, mainConverter: IMainConverter ) {
return value;
}
}
/** Standard converter for arrays.
* The general serialized form of an array is `[array, type]`.
* - Homogenous arrays (all items have same type) are serialized like this: `[[3,4,2,1], 'integer']`.
* The original array is not copied.
* - Heterogenuous arrays are analyzed for their primary type (the most often occurring type).
* This becomes the array type and items of that type will be written without type info.
* Other items will be written with type info: `[[1.1, 2.2, ['2020-12-03T23:00:00.000Z', 'datetime']], 'number']`.
*/
export class ArrayConverter extends ScopedConverter {
constructor() {
super( 'array' );
}
public serialize( value: any, mainConverter: IMainConverter ) {
const [ primaryType, passThrough ] = this.detectPrimaryType( value, mainConverter );
if ( !passThrough ) {
value = value.map( ( v ) => this.toPlainUsingPrimaryType( v, primaryType, mainConverter ) );
}
return [ value, primaryType ];
}
/** Returns the most frequently found type in the array.
* @param list An array of values whose primary type is to be determined.
* @param mainConverter An IMainConverter whose known types should be used.
* @returns [type, passThrough]
* @param type The name of the primary type.
* @param passThrough Is true if all elements are of the primary type and it is a JSON type like string.
* This means, one can use the array as is without conversion/clone of its elements.
*/
public detectPrimaryType(
list: any[],
mainConverter: IMainConverter
): [ type: string, passThrough: boolean ] {
// Count how often each type was found.
const counts = new Map<string, number>();
for ( const v of list ) {
const type = mainConverter.getTypeName( v );
counts.set( type, ( counts.get( type ) ?? 0 ) + 1 );
}
let type: string;
let passThrough: boolean;
// If we find integer and number, use number even if in minority and don't declare separate types.
if ( counts.size == 2 && counts.get( 'number' ) && counts.get( 'integer' ) ) {
type = 'number';
passThrough = true;
} else {
// Find the type with the highest count.
const count = [ ...counts.entries() ].reduce( ( a, e ) => e[ 1 ] > a[ 1 ] ? e : a
);
type = count ? count[ 0 ] : 'string';
// we currently ignore the case of mixed json types
passThrough = count[ 1 ] === list.length && ( isJsonType( type ) || type === 'integer' ) && !( type === 'array' )
}
return [ type, passThrough ];
}
/** Converts the value to a plain JS type (JSON compatible).
* If value is not of primaryType, it is written as tuple with type info. */
private toPlainUsingPrimaryType( value: any, primaryType: string, mainConverter: IMainConverter ) {
const typedPlainValue = mainConverter.serialize( value );
let plainValue: any;
let valueType: string;
if ( isArray( typedPlainValue ) ) {// of form [value, type]
if ( isArray( typedPlainValue[ 0 ] ) ) // array in array?
return typedPlainValue;
[ plainValue, valueType ] = typedPlainValue;
} else {
[ plainValue, valueType ] = [ typedPlainValue, mainConverter.getTypeName( value ) ];
}
return valueType === primaryType ? plainValue : [ plainValue, valueType ];
}
deserialize( value: any, mainConverter: IMainConverter ) {
// Go over all items in value and check if all are untyped. If yes, check if we can return the array as is or if they need to be converted to this.primaryType.
// If some items are typed, we need to convert all items, untyped to primaryType and the others to their
const [ items, type ] = value;
const isPassThrough = isJsonType( type ) && ( items as any[] ).every( ( v ) => !isArray( v ) ); // no item is an array
if ( isPassThrough )
return items;
const newArray = items
.map( ( v ) => ( isArray( v ) ? v : [ v, type ] ) )
.map( ( v ) => mainConverter.deserialize( v ) );
return newArray;
}
}
export class DateTimeConverter extends ScopedConverter {
// In the future we could use Temporal with DateTime, Date, Time, etc and no longer count on JS Date type or momentjs that is considered obsolete.
// https://momentjs.com/docs/#/-project-status/future/
// Date format: see https://specs.frictionlessdata.io/table-schema/#datetime
constructor() {
super( 'datetime' );
}
serialize( value: any, mainConverter: IMainConverter ) {
return [ ( value as Date ).toISOString(), this.type ];
}
deserialize( value: any, mainConverter: IMainConverter ) {
return DateTimeConverter.parseISOString( value[ 0 ] );
}
private static parseISOString( s ) {
// see https://stackoverflow.com/a/27013409/1654527
const b = s.split( /\D+/ );
return new Date( Date.UTC( b[ 0 ], --b[ 1 ], b[ 2 ], b[ 3 ], b[ 4 ], b[ 5 ], b[ 6 ] ) );
}
}
export class TypedIdentityConverter extends ScopedConverter {
public static instance = new TypedIdentityConverter( '' );
serialize( value: any, mainConverter: IMainConverter ) {
return [ value, this.type ];
}
deserialize( value: any, mainConverter: IMainConverter ) {
return value[ 0 ];
}
}
export const converter = new Converter();
converter.register( new ArrayConverter() );
converter.register( new DateTimeConverter() );
converter.register( new TypedIdentityConverter( 'integer' ) );
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment