├── .gitignore ├── logo.png ├── src ├── utils.ts ├── index.ts ├── pojo.ts ├── primitive-decoders.ts ├── literal-decoders.ts ├── types.ts └── higher-order-decoders.ts ├── tests ├── generators.ts ├── property.test.ts ├── index.test-d.ts ├── index.test.ts └── unit.test.ts ├── LICENSE ├── package.json ├── tsconfig.json ├── article.md └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | dist/ 2 | node_modules/ 3 | log.md 4 | .vscode/ 5 | -------------------------------------------------------------------------------- /logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tskj/typescript-json-decoder/HEAD/logo.png -------------------------------------------------------------------------------- /src/utils.ts: -------------------------------------------------------------------------------- 1 | export const tag = ( 2 | thing: T, 3 | symbol: S, 4 | ): void => { 5 | (thing as any)[symbol] = true; 6 | }; 7 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export { decode, decodeType, Decoder, DecoderFunction } from './types'; 2 | export { tuple, literal, record, field, fields } from './literal-decoders'; 3 | export { 4 | union, 5 | intersection, 6 | optional, 7 | array, 8 | set, 9 | map, 10 | dict, 11 | nullable, 12 | } from './higher-order-decoders'; 13 | export { 14 | string, 15 | number, 16 | boolean, 17 | undef, 18 | nil, 19 | date, 20 | } from './primitive-decoders'; 21 | export { Pojo } from './pojo'; 22 | -------------------------------------------------------------------------------- /tests/generators.ts: -------------------------------------------------------------------------------- 1 | import fc from 'fast-check'; 2 | import * as d from '../src'; 3 | 4 | type DecoderArbitrary = fc.Arbitrary<{ 5 | value: T; 6 | decoder: d.Decoder; 7 | }>; 8 | 9 | export const string = () => 10 | fc.string().map((s) => ({ 11 | value: s, 12 | decoder: d.string, 13 | })); 14 | 15 | export const number = () => 16 | fc.float().map((n) => ({ 17 | value: n, 18 | decoder: d.number, 19 | })); 20 | 21 | export const boolean = () => 22 | fc.boolean().map((b) => ({ 23 | value: b, 24 | decoder: d.boolean, 25 | })); 26 | 27 | export const undef = () => 28 | fc.constant(undefined).map((u) => ({ 29 | value: u, 30 | decoder: d.undef, 31 | })); 32 | 33 | export const nil = () => 34 | fc.constant(null).map((u) => ({ 35 | value: u, 36 | decoder: d.nil, 37 | })); 38 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Tarjei Skjærset 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /tests/property.test.ts: -------------------------------------------------------------------------------- 1 | import fc from 'fast-check'; 2 | import { date, number, optional, string, tuple } from '../src'; 3 | 4 | test('decode string', () => { 5 | fc.assert( 6 | fc.property(fc.string(), (s) => { 7 | expect(string(s)).toBe(s); 8 | }), 9 | ); 10 | }); 11 | 12 | test('decode optional string', () => { 13 | fc.assert( 14 | fc.property(fc.oneof(fc.string(), fc.constant(undefined)), (s) => { 15 | expect(optional(string)(s)).toBe(s); 16 | }), 17 | ); 18 | }); 19 | 20 | test('decode number', () => { 21 | fc.assert( 22 | fc.property(fc.float(), (n) => { 23 | expect(number(n)).toBe(n); 24 | }), 25 | ); 26 | }); 27 | 28 | test('decode string tuple', () => { 29 | fc.assert( 30 | fc.property(fc.string(), fc.string(), (s1, s2) => { 31 | expect(tuple(string, string)([s1, s2])).toEqual([s1, s2]); 32 | }), 33 | ); 34 | }); 35 | 36 | test('decode string,number tuple', () => { 37 | fc.assert( 38 | fc.property(fc.string(), fc.float(), (s, n) => { 39 | expect(tuple(string, number)([s, n])).toEqual([s, n]); 40 | }), 41 | ); 42 | }); 43 | 44 | test('decode date', () => { 45 | fc.assert( 46 | fc.property(fc.date(), (d) => { 47 | const dateString = d.toISOString(); 48 | expect(date(dateString)).toEqual(d); 49 | }), 50 | ); 51 | }); 52 | -------------------------------------------------------------------------------- /src/pojo.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Javascript Objects 3 | * These are not Json which are strings (javascript (literal) notation), 4 | * these are plain old javascript objects 5 | */ 6 | 7 | export type PojoPrimitive = string | boolean | number | null | undefined; 8 | export const isPojoPrimitve = (value: unknown): value is PojoPrimitive => 9 | typeof value === 'string' || 10 | typeof value === 'boolean' || 11 | typeof value === 'number' || 12 | typeof value === 'undefined' || 13 | (typeof value === 'object' && value === null); 14 | 15 | export type PojoObject = { [key: string]: Pojo }; 16 | export const isPojoObject = (value: unknown): value is PojoObject => 17 | typeof value === 'object' && value !== null; 18 | 19 | export type PojoArray = Pojo[]; 20 | export const isPojoArray = (value: unknown): value is PojoArray => 21 | Array.isArray(value); 22 | 23 | export type Pojo = PojoPrimitive | PojoObject | PojoArray; 24 | export const isPojo = (value: unknown): value is Pojo => 25 | isPojoPrimitve(value) || isPojoObject(value) || isPojoArray(value); 26 | 27 | export function assert_is_pojo(value: unknown): asserts value is Pojo { 28 | if (!isPojo(value)) { 29 | throw `Value \`${value}\` is not a type that can be parsed by this library. Only primitive JS values and regular JS objects or arrays can be parsed, not classes (think anything that is valid JSON).`; 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "typescript-json-decoder", 3 | "version": "1.0.11", 4 | "description": "", 5 | "main": "dist/index.js", 6 | "types": "dist/index.d.ts", 7 | "scripts": { 8 | "test": "jest && npx tsd", 9 | "build": "tsc", 10 | "prepublishOnly": "npm run build && npm run test && git push && git push --tags" 11 | }, 12 | "repository": { 13 | "type": "git", 14 | "url": "git+https://github.com/tskj/typescript-json-decoder.git" 15 | }, 16 | "keywords": [], 17 | "author": "", 18 | "license": "ISC", 19 | "bugs": { 20 | "url": "https://github.com/tskj/typescript-json-decoder/issues" 21 | }, 22 | "homepage": "https://github.com/tskj/typescript-json-decoder#readme", 23 | "prepublish": "tsc", 24 | "devDependencies": { 25 | "@types/jest": "^27.4.0", 26 | "fast-check": "^2.20.0", 27 | "jest": "^27.4.5", 28 | "npx": "^10.2.2", 29 | "ts-jest": "^27.1.2", 30 | "tsc-watch": "^4.2.9", 31 | "tsd": "^0.19.0", 32 | "typescript": "^4.2.3" 33 | }, 34 | "prettier": { 35 | "trailingComma": "all", 36 | "tabWidth": 2, 37 | "semi": true, 38 | "singleQuote": true 39 | }, 40 | "files": [ 41 | "dist/**/*" 42 | ], 43 | "jest": { 44 | "moduleFileExtensions": [ 45 | "ts", 46 | "js" 47 | ], 48 | "globals": { 49 | "ts-jest": { 50 | "tsconfig": "tsconfig.json" 51 | } 52 | }, 53 | "transform": { 54 | "^.+\\.(ts|tsx)$": "ts-jest" 55 | } 56 | }, 57 | "tsd": { 58 | "directory": "tests" 59 | } 60 | } -------------------------------------------------------------------------------- /src/primitive-decoders.ts: -------------------------------------------------------------------------------- 1 | import { assert_is_pojo } from './pojo'; 2 | import { DecoderFunction } from './types'; 3 | 4 | export const string: DecoderFunction = (s: unknown) => { 5 | assert_is_pojo(s); 6 | if (typeof s !== 'string') { 7 | throw `The value \`${JSON.stringify( 8 | s, 9 | )}\` is not of type \`string\`, but is of type \`${typeof s}\``; 10 | } 11 | return s; 12 | }; 13 | 14 | export const number: DecoderFunction = (n: unknown) => { 15 | assert_is_pojo(n); 16 | if (typeof n !== 'number') { 17 | throw `The value \`${JSON.stringify( 18 | n, 19 | )}\` is not of type \`number\`, but is of type \`${typeof n}\``; 20 | } 21 | return n; 22 | }; 23 | 24 | export const boolean: DecoderFunction = (b: unknown) => { 25 | assert_is_pojo(b); 26 | if (typeof b !== 'boolean') { 27 | throw `The value \`${JSON.stringify( 28 | b, 29 | )}\` is not of type \`boolean\`, but is of type \`${typeof b}\``; 30 | } 31 | return b; 32 | }; 33 | 34 | export const undef: DecoderFunction = ((u: unknown) => { 35 | assert_is_pojo(u); 36 | if (typeof u !== 'undefined') { 37 | throw `The value \`${JSON.stringify( 38 | u, 39 | )}\` is not of type \`undefined\`, but is of type \`${typeof u}\``; 40 | } 41 | return u; 42 | }) as any; 43 | 44 | export const nil: DecoderFunction = ((u: unknown) => { 45 | assert_is_pojo(u); 46 | if (u !== null) { 47 | throw `The value \`${JSON.stringify( 48 | u, 49 | )}\` is not of type \`null\`, but is of type \`${typeof u}\``; 50 | } 51 | return u as null; 52 | }) as any; 53 | 54 | export const date: DecoderFunction = (value: unknown) => { 55 | assert_is_pojo(value); 56 | const dateString = string(value); 57 | const timeStampSinceEpoch = Date.parse(dateString); 58 | if (isNaN(timeStampSinceEpoch)) { 59 | throw `String \`${dateString}\` is not a valid date string`; 60 | } 61 | return new Date(timeStampSinceEpoch); 62 | }; 63 | -------------------------------------------------------------------------------- /src/literal-decoders.ts: -------------------------------------------------------------------------------- 1 | import { assert_is_pojo, isPojoObject } from './pojo'; 2 | import { 3 | decodeType, 4 | decode, 5 | Decoder, 6 | DecoderFunction, 7 | JsonLiteralForm, 8 | } from './types'; 9 | import { tag } from './utils'; 10 | 11 | export const literal = 12 |

(literal: p): DecoderFunction

=> 13 | (value: unknown) => { 14 | assert_is_pojo(value); 15 | if (literal !== value) { 16 | throw `The value \`${JSON.stringify( 17 | value, 18 | )}\` is not the literal \`${JSON.stringify(literal)}\``; 19 | } 20 | return literal; 21 | }; 22 | 23 | export const tuple = 24 | , B extends Decoder>( 25 | decoderA: A, 26 | decoderB: B, 27 | ): DecoderFunction<[decodeType, decodeType]> => 28 | (value: unknown) => { 29 | assert_is_pojo(value); 30 | if (!Array.isArray(value)) { 31 | throw `The value \`${JSON.stringify( 32 | value, 33 | )}\` is not a list and can therefore not be parsed as a tuple`; 34 | } 35 | if (value.length !== 2) { 36 | throw `The array \`${JSON.stringify( 37 | value, 38 | )}\` is not the proper length for a tuple`; 39 | } 40 | const [a, b] = value; 41 | return [decode(decoderA as any)(a), decode(decoderB as any)(b)]; 42 | }; 43 | 44 | export const fieldDecoder: unique symbol = Symbol('field-decoder'); 45 | export const fields = }, U>( 46 | decoder: T, 47 | continuation: (x: decodeType) => U, 48 | ): DecoderFunction => { 49 | const dec = (value: unknown) => { 50 | assert_is_pojo(value); 51 | const decoded = decode(decoder)(value); 52 | return continuation(decoded); 53 | }; 54 | tag(dec, fieldDecoder); 55 | return dec; 56 | }; 57 | 58 | export const field = ( 59 | key: string, 60 | decoder: Decoder, 61 | ): DecoderFunction => { 62 | return fields({ [key]: decoder }, (x: any) => x[key]); 63 | }; 64 | 65 | export const record = 66 | }>( 67 | s: schema, 68 | ): DecoderFunction> => 69 | (value: unknown): any => { 70 | assert_is_pojo(value); 71 | if (!isPojoObject(value)) { 72 | throw `Value \`${value}\` is not of type \`object\` but rather \`${typeof value}\``; 73 | } 74 | return Object.entries(s) 75 | .map(([key, decoder]: [string, any]) => { 76 | if (decoder[fieldDecoder] === true) { 77 | return [key, decode(decoder)(value)]; 78 | } 79 | try { 80 | const jsonvalue = value[key]; 81 | return [key, decode(decoder)(jsonvalue)]; 82 | } catch (message) { 83 | throw ( 84 | message + 85 | `\nwhen trying to decode the key \`${key}\` in \`${JSON.stringify( 86 | value, 87 | )}\`` 88 | ); 89 | } 90 | }) 91 | .reduce((acc, [key, value]) => ({ ...acc, [key]: value }), {}); 92 | }; 93 | -------------------------------------------------------------------------------- /tests/index.test-d.ts: -------------------------------------------------------------------------------- 1 | import { expectAssignable, expectType } from 'tsd'; 2 | import { 3 | boolean, 4 | Decoder, 5 | fields, 6 | number, 7 | optional, 8 | record, 9 | string, 10 | undef, 11 | union, 12 | intersection, 13 | array, 14 | literal, 15 | tuple, 16 | decode, 17 | nullable, 18 | dict, 19 | DecoderFunction, 20 | } from '../src'; 21 | 22 | let n = 0; 23 | expectType(n); 24 | 25 | type rec_t = { 26 | data: string; 27 | value: number; 28 | rec: { more: boolean }; 29 | f: string; 30 | option?: string | undefined; 31 | list_of_stuff: (string | boolean)[]; 32 | intersect: { a: number; c: boolean } | { a: 'foo'; b: number; c: boolean }; 33 | }; 34 | const rec_decoder = record({ 35 | data: string, 36 | value: number, 37 | rec: { more: boolean }, 38 | f: fields({ data: string, value: number }, ({ data, value }) => data + value), 39 | option: optional(string), 40 | list_of_stuff: array(union(string, boolean)), 41 | intersect: intersection(union({ a: number }, { a: string, b: number }), { 42 | c: boolean, 43 | a: union(number, decode('foo')), 44 | }), 45 | }); 46 | expectAssignable>(rec_decoder); 47 | expectType( 48 | rec_decoder({ 49 | data: '', 50 | value: 0, 51 | rec: { more: true }, 52 | option: 'yes', 53 | intersect: { a: 'foo' }, 54 | }), 55 | ); 56 | 57 | let union_decoder = union(string, number, record({})); 58 | expectType(union_decoder('test')); 59 | 60 | let intersection_decoder = intersection( 61 | { a: string }, 62 | { a: literal('foo'), b: number }, 63 | ); 64 | expectAssignable<{ a: 'foo'; b: number }>(intersection_decoder({ a: 'foo' })); 65 | expectAssignable<{ a: 'foo' }>(intersection_decoder({ a: 'foo' })); 66 | 67 | expectAssignable<{ a: string; b: number }>( 68 | intersection({ a: string }, { b: number })({}), 69 | ); 70 | expectAssignable( 71 | intersection(optional(number), optional(number))(null), 72 | ); 73 | expectAssignable( 74 | intersection(nullable(number), nullable(number))(null), 75 | ); 76 | 77 | let optional_decoder = optional(union(string, number)); 78 | expectType(optional_decoder('')); 79 | 80 | let discriminated_rec_decoder = union( 81 | { discriminant: literal('one') }, 82 | { discriminant: literal('two'), data: string }, 83 | ); 84 | expectType<{ discriminant: 'one' } | { discriminant: 'two'; data: string }>( 85 | discriminated_rec_decoder({ discriminant: 'one' }), 86 | ); 87 | 88 | let discriminated_tuple_decoder = union( 89 | tuple('one', number), 90 | tuple('two', string), 91 | tuple('three', { data: string }), 92 | ); 93 | let discriminated_tuple_decoder_2 = union( 94 | ['one' as const, number], 95 | ['two' as const, string], 96 | ['three' as const, { data: string }], 97 | ); 98 | let discriminated_tuple_decoder_3 = union( 99 | [literal('one'), number], 100 | [literal('two'), string], 101 | [literal('three'), { data: string }], 102 | ); 103 | type expected_discriminated_tuple_t = 104 | | ['one', number] 105 | | ['two', string] 106 | | ['three', { data: string }]; 107 | expectType( 108 | discriminated_tuple_decoder(['one', 1]), 109 | ); 110 | expectType( 111 | discriminated_tuple_decoder_2(['one', 1]), 112 | ); 113 | expectType( 114 | discriminated_tuple_decoder_3(['one', 1]), 115 | ); 116 | 117 | const a_or_b_literal_decoder = union('a', 'b'); 118 | expectType<'a' | 'b'>(a_or_b_literal_decoder('a')); 119 | 120 | const a_or_b_decoder = union(literal('a'), literal('b')); 121 | expectType<'a' | 'b'>(a_or_b_decoder('a')); 122 | 123 | const a_b_or_r_decoder = union('a', 'b', { test: string }); 124 | expectType<'a' | 'b' | { test: string }>(a_b_or_r_decoder({ test: '' })); 125 | 126 | expectType>>(dict(number)); 127 | expectType>>( 128 | dict(number, ['small', 'medium'] as const), 129 | ); 130 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | import { literal, tuple, record } from './literal-decoders'; 2 | 3 | /** 4 | * Json Literal Decoder 5 | * literal javascript objects used as if they were decoders 6 | * of themselves 7 | */ 8 | 9 | type PrimitiveJsonLiteralForm = string; 10 | const isPrimitiveJsonLiteralForm = ( 11 | v: unknown, 12 | ): v is PrimitiveJsonLiteralForm => typeof v === 'string'; 13 | 14 | type TupleJsonLiteralForm = [Decoder, Decoder]; 15 | const isTupleJsonLiteralForm = (v: unknown): v is TupleJsonLiteralForm => 16 | Array.isArray(v) && v.length === 2 && v.every(isDecoder); 17 | 18 | type RecordJsonLiteralForm = { [key: string]: Decoder }; 19 | const isRecordJsonLiteralForm = (v: unknown): v is RecordJsonLiteralForm => 20 | typeof v === 'object' && v !== null && Object.values(v).every(isDecoder); 21 | 22 | export type JsonLiteralForm = 23 | | PrimitiveJsonLiteralForm 24 | | TupleJsonLiteralForm 25 | | RecordJsonLiteralForm; 26 | const isJsonLiteralForm = (decoder: unknown): decoder is JsonLiteralForm => { 27 | return ( 28 | isPrimitiveJsonLiteralForm(decoder) || 29 | isTupleJsonLiteralForm(decoder) || 30 | isRecordJsonLiteralForm(decoder) 31 | ); 32 | }; 33 | 34 | /** 35 | * Partialify record fields which can be `undefined` 36 | * helper functions 37 | */ 38 | 39 | const a: unique symbol = Symbol(); 40 | type rem = t extends typeof a ? never : t; 41 | 42 | type undefinedKeys = { 43 | [P in keyof T]: [undefined] extends [T[P]] ? P : never; 44 | }[keyof T]; 45 | type addQuestionmarksToRecordFields = { 46 | [P in Exclude>]: R[P]; 47 | } & { 48 | [P in undefinedKeys]?: R[P] | typeof a; 49 | } extends infer P 50 | ? // this last part is just to flatten the intersection (&) 51 | // { [K in keyof P]: [string | symbol] extends [P[K]] ? string | undefined | symbol : Exclude } 52 | { [K in keyof P]: rem } 53 | : never; 54 | 55 | /** 56 | * Run json literal decoder evaluation both at 57 | * type level and runtime level 58 | */ 59 | 60 | // prettier-ignore 61 | type evalJsonLiteralForm = 62 | [decoder] extends [PrimitiveJsonLiteralForm] ? 63 | decoder : 64 | [decoder] extends [[infer decoderA, infer decoderB]] ? 65 | [ decodeTypeRecur, decodeTypeRecur ] : 66 | 67 | addQuestionmarksToRecordFields< 68 | { 69 | [key in keyof decoder]: decodeTypeRecur; 70 | } 71 | > 72 | const decodeJsonLiteralForm = ( 73 | decoder: json, 74 | ): DecoderFunction> => { 75 | if (isPrimitiveJsonLiteralForm(decoder)) { 76 | return literal(decoder) as any; 77 | } 78 | if (isTupleJsonLiteralForm(decoder)) { 79 | return tuple(decoder[0] as any, decoder[1] as any) as any; 80 | } 81 | if (isRecordJsonLiteralForm(decoder)) { 82 | return record(decoder as any) as any; 83 | } 84 | throw `shouldn't happen`; 85 | }; 86 | 87 | /** 88 | * General decoder definition 89 | */ 90 | 91 | export type DecoderFunction = (input: unknown) => T; 92 | const isDecoderFunction = (f: unknown): f is DecoderFunction => 93 | typeof f === 'function'; 94 | 95 | export type Decoder = JsonLiteralForm | DecoderFunction; 96 | const isDecoder = (decoder: unknown): decoder is Decoder => 97 | isJsonLiteralForm(decoder) || isDecoderFunction(decoder); 98 | 99 | /** 100 | * Run evaluation of decoder at both type and 101 | * runtime level 102 | */ 103 | 104 | export type primitive = string | boolean | number | null | undefined; 105 | // prettier-ignore 106 | type decodeTypeRecur = 107 | (decoder extends DecoderFunction ? 108 | [decodeTypeRecur] : 109 | decoder extends JsonLiteralForm ? 110 | [evalJsonLiteralForm]: 111 | 112 | [decoder] 113 | // needs a bit of indirection to avoid 114 | // circular type reference compiler error 115 | )[0]; 116 | // export type decodeType = 117 | // decodeTypeRecur; 118 | 119 | export type decodeType = 120 | // removeA< 121 | decodeTypeRecur; 122 | // > 123 | 124 | export const decode = >( 125 | decoder: D, 126 | ): DecoderFunction> => { 127 | if (!isDecoderFunction(decoder)) { 128 | return decodeJsonLiteralForm(decoder as any); 129 | } 130 | return decoder as any; 131 | }; 132 | 133 | export function isKey(value: unknown, keys: ReadonlyArray): value is K { 134 | return keys.includes(value as any); 135 | } 136 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | /* Visit https://aka.ms/tsconfig.json to read more about this file */ 4 | 5 | /* Basic Options */ 6 | // "incremental": true, /* Enable incremental compilation */ 7 | "target": "es5", /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019', 'ES2020', or 'ESNEXT'. */ 8 | "module": "commonjs", /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', 'es2020', or 'ESNext'. */ 9 | "lib": ["es2017","DOM"], /* Specify library files to be included in the compilation. */ 10 | // "allowJs": true, /* Allow javascript files to be compiled. */ 11 | // "checkJs": true, /* Report errors in .js files. */ 12 | // "jsx": "preserve", /* Specify JSX code generation: 'preserve', 'react-native', or 'react'. */ 13 | "declaration": true, /* Generates corresponding '.d.ts' file. */ 14 | // "declarationMap": true, /* Generates a sourcemap for each corresponding '.d.ts' file. */ 15 | // "sourceMap": true, /* Generates corresponding '.map' file. */ 16 | // "outFile": "./", /* Concatenate and emit output to single file. */ 17 | "outDir": "./dist", /* Redirect output structure to the directory. */ 18 | // "rootDir": "./", /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */ 19 | // "composite": true, /* Enable project compilation */ 20 | // "tsBuildInfoFile": "./", /* Specify file to store incremental compilation information */ 21 | // "removeComments": true, /* Do not emit comments to output. */ 22 | // "noEmit": true, /* Do not emit outputs. */ 23 | // "importHelpers": true, /* Import emit helpers from 'tslib'. */ 24 | // "downlevelIteration": true, /* Provide full support for iterables in 'for-of', spread, and destructuring when targeting 'ES5' or 'ES3'. */ 25 | // "isolatedModules": true, /* Transpile each file as a separate module (similar to 'ts.transpileModule'). */ 26 | 27 | /* Strict Type-Checking Options */ 28 | "strict": true, /* Enable all strict type-checking options. */ 29 | // "noImplicitAny": true, /* Raise error on expressions and declarations with an implied 'any' type. */ 30 | "strictNullChecks": true, /* Enable strict null checks. */ 31 | "strictFunctionTypes": true, /* Enable strict checking of function types. */ 32 | // "strictBindCallApply": true, /* Enable strict 'bind', 'call', and 'apply' methods on functions. */ 33 | // "strictPropertyInitialization": true, /* Enable strict checking of property initialization in classes. */ 34 | // "noImplicitThis": true, /* Raise error on 'this' expressions with an implied 'any' type. */ 35 | "alwaysStrict": true, /* Parse in strict mode and emit "use strict" for each source file. */ 36 | 37 | /* Additional Checks */ 38 | // "noUnusedLocals": true, /* Report errors on unused locals. */ 39 | // "noUnusedParameters": true, /* Report errors on unused parameters. */ 40 | // "noImplicitReturns": true, /* Report error when not all code paths in function return a value. */ 41 | // "noFallthroughCasesInSwitch": true, /* Report errors for fallthrough cases in switch statement. */ 42 | 43 | /* Module Resolution Options */ 44 | // "moduleResolution": "node", /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */ 45 | // "baseUrl": "./", /* Base directory to resolve non-absolute module names. */ 46 | // "paths": {}, /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */ 47 | // "rootDirs": [], /* List of root folders whose combined content represents the structure of the project at runtime. */ 48 | // "typeRoots": [], /* List of folders to include type definitions from. */ 49 | // "types": [], /* Type declaration files to be included in compilation. */ 50 | // "allowSyntheticDefaultImports": true, /* Allow default imports from modules with no default export. This does not affect code emit, just typechecking. */ 51 | "esModuleInterop": true, /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */ 52 | // "preserveSymlinks": true, /* Do not resolve the real path of symlinks. */ 53 | // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */ 54 | 55 | /* Source Map Options */ 56 | // "sourceRoot": "", /* Specify the location where debugger should locate TypeScript files instead of source locations. */ 57 | // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */ 58 | // "inlineSourceMap": true, /* Emit a single file with source maps instead of having a separate file. */ 59 | // "inlineSources": true, /* Emit the source alongside the sourcemaps within a single file; requires '--inlineSourceMap' or '--sourceMap' to be set. */ 60 | 61 | /* Experimental Options */ 62 | // "experimentalDecorators": true, /* Enables experimental support for ES7 decorators. */ 63 | // "emitDecoratorMetadata": true, /* Enables experimental support for emitting type metadata for decorators. */ 64 | 65 | /* Advanced Options */ 66 | "skipLibCheck": true, /* Skip type checking of declaration files. */ 67 | "forceConsistentCasingInFileNames": true /* Disallow inconsistently-cased references to the same file. */ 68 | }, 69 | "include": ["src/*"] 70 | } 71 | -------------------------------------------------------------------------------- /src/higher-order-decoders.ts: -------------------------------------------------------------------------------- 1 | import { nil, undef } from './primitive-decoders'; 2 | import { assert_is_pojo, isPojoObject } from './pojo'; 3 | import { decodeType, decode, Decoder, DecoderFunction, isKey } from './types'; 4 | 5 | type evalOver = t extends unknown ? decodeType : never; 6 | type getSumOfArray = arr extends (infer elements)[] ? elements : never; 7 | 8 | export const union = 9 | []>(...decoders: decoders) => 10 | (value: unknown): evalOver> => { 11 | assert_is_pojo(value); 12 | if (decoders.length === 0) { 13 | throw `Could not match any of the union cases`; 14 | } 15 | const [decoder, ...rest] = decoders; 16 | try { 17 | return decode(decoder as any)(value) as any; 18 | } catch (messageFromThisDecoder) { 19 | try { 20 | return union(...(rest as any))(value) as any; 21 | } catch (message) { 22 | throw `${messageFromThisDecoder}\n${message}`; 23 | } 24 | } 25 | }; 26 | 27 | // intersectUnion = a & b 28 | type intersectUnion = (U extends unknown ? (_: U) => void : never) extends ( 29 | _: infer I, 30 | ) => void 31 | ? I 32 | : never; 33 | 34 | // asObject<[a, b]> = { 0: {_: a}, 1: {_: b} } 35 | type asObject = { 36 | [K in Exclude]: { _: decodeType }; 37 | }; 38 | 39 | // values<{0: a, 1: b}> = a | b 40 | type values = T[keyof T]; 41 | 42 | // fromObject {_: a} = a 43 | type fromObject = T extends { _: infer V } ? V : never; 44 | 45 | // combine helpers to get an intersection of all the item types 46 | type getProductOfDecoderArray[]> = fromObject< 47 | intersectUnion>> 48 | > extends infer P 49 | ? // trick to normalize intersection type 50 | { [K in keyof P]: P[K] } 51 | : never; 52 | 53 | const combineObjectProperties = ( 54 | a: A, 55 | b: B, 56 | ): A & B => { 57 | const keys = [ 58 | ...Object.getOwnPropertyNames(a), 59 | ...Object.getOwnPropertySymbols(a), 60 | ...Object.getOwnPropertyNames(b), 61 | ...Object.getOwnPropertySymbols(b), 62 | ]; 63 | return keys.reduce((acc, key) => { 64 | const aProp = (a as any)[key]; 65 | const bProp = (b as any)[key]; 66 | return { 67 | ...acc, 68 | ...{ 69 | [key]: 70 | key in a 71 | ? key in b 72 | ? (() => { 73 | try { 74 | return combineResults(aProp, bProp); 75 | } catch (message) { 76 | throw `${message}\nWhile trying to combine results for field '${String( 77 | key, 78 | )}'`; 79 | } 80 | })() 81 | : aProp 82 | : bProp, 83 | }, 84 | }; 85 | }, {}) as A & B; 86 | }; 87 | 88 | // Custom classes aren't allowed do to complications when extracting private 89 | // fields and such 90 | const validatePrototype = (a: unknown): void => { 91 | const proto = Object.getPrototypeOf(a); 92 | if (proto !== Object.prototype && proto !== Array.prototype) { 93 | throw `Only Object, and Array, and the primitive types are allowed in intersections, but got ${proto.constructor.name}`; 94 | } 95 | }; 96 | 97 | // For intersections with primitive types, we compare the results to 98 | // make sure they are equal. With objects, recursively combine any properties 99 | const combineResults = (a: A, b: B): A & B => { 100 | const jsType = typeof a; 101 | if (jsType !== typeof b) { 102 | throw `Cannot form intersection of ${typeof a} and ${typeof b}, but got ${a} and ${b}`; 103 | } else if (jsType === 'function') { 104 | throw `Combining functions in intersections is not supported`; 105 | } else if (jsType === 'object') { 106 | if ([a, b].some((x) => x === null)) { 107 | const nonNull = [a, b].find((x) => x !== null); 108 | if (nonNull !== undefined) { 109 | throw `Cannot intersect null with non-null value ${nonNull}`; 110 | } else { 111 | return null as any; 112 | } 113 | } 114 | validatePrototype(a); 115 | validatePrototype(b); 116 | 117 | const result = combineObjectProperties(a, b); 118 | const base = Array.isArray(a) || Array.isArray(b) ? [] : {}; 119 | return Object.assign(base, result); 120 | } else { 121 | if ((a as any) !== (b as any)) { 122 | throw `Intersections must produce matching values in all branches, but got ${a} and ${b}`; 123 | } 124 | return a as A & B; 125 | } 126 | }; 127 | 128 | // NB: if multiple cases create properties with identical keys, only the last one is kept 129 | export const intersection = 130 | []>(...decoders: decoders) => 131 | (value: unknown): getProductOfDecoderArray => { 132 | assert_is_pojo(value); 133 | const errors: any[] = []; 134 | const results: any[] = []; 135 | for (const decoder of decoders) { 136 | try { 137 | results.push(decode(decoder)(value)); 138 | } catch (message) { 139 | errors.push(message); 140 | } 141 | } 142 | if (errors.length === 0) { 143 | return results.length === 0 144 | ? ({} as any) 145 | : results.reduce((acc, result) => combineResults(acc, result)); 146 | } else { 147 | errors.push(`Could not match all of the intersection cases`); 148 | throw errors.join('\n'); 149 | } 150 | }; 151 | 152 | export const nullable = >( 153 | decoder: T, 154 | ): DecoderFunction | null> => { 155 | return union(nil, decoder as any); 156 | }; 157 | 158 | export const optional = >( 159 | decoder: T, 160 | ): DecoderFunction | undefined> => union(undef, decoder as any); 161 | 162 | export function array>( 163 | decoder: D, 164 | ): DecoderFunction[]> { 165 | return (xs: unknown): any => { 166 | assert_is_pojo(xs); 167 | const arrayToString = (arr: any) => `${JSON.stringify(arr)}`; 168 | if (!Array.isArray(xs)) { 169 | throw `The value \`${arrayToString( 170 | xs, 171 | )}\` is not of type \`array\`, but is of type \`${typeof xs}\``; 172 | } 173 | let index = 0; 174 | try { 175 | return xs.map((x, i) => { 176 | index = i; 177 | return decode(decoder as any)(x); 178 | }) as any; 179 | } catch (message) { 180 | throw ( 181 | message + 182 | `\nwhen trying to decode the array (at index ${index}) \`${arrayToString( 183 | xs, 184 | )}\`` 185 | ); 186 | } 187 | }; 188 | } 189 | 190 | export const set = 191 | >( 192 | decoder: D, 193 | ): DecoderFunction>> => 194 | (list: unknown) => { 195 | assert_is_pojo(list); 196 | try { 197 | return new Set(decode(array(decoder))(list)); 198 | } catch (message) { 199 | throw message + `\nand can therefore not be parsed as a set`; 200 | } 201 | }; 202 | 203 | export const map = 204 | >( 205 | decoder: D, 206 | key: (x: decodeType) => K, 207 | ): DecoderFunction>> => 208 | (listOfObjects: unknown) => { 209 | assert_is_pojo(listOfObjects); 210 | try { 211 | const parsedObjects = decode(array(decoder))(listOfObjects); 212 | const map = new Map(parsedObjects.map((value) => [key(value), value])); 213 | if (parsedObjects.length !== map.size) { 214 | console.warn( 215 | `Probable duplicate key in map: List \`${parsedObjects}\` isn't the same size as the parsed \`${map}\``, 216 | ); 217 | } 218 | return map; 219 | } catch (message) { 220 | throw message + `\nand can therefore not be parsed as a map`; 221 | } 222 | }; 223 | 224 | export function dict, K extends string = string>( 225 | decoder: D, 226 | keys?: ReadonlyArray, 227 | ): DecoderFunction>> { 228 | return (map: unknown) => { 229 | assert_is_pojo(map); 230 | if (!isPojoObject(map)) { 231 | throw `Value \`${map}\` is not an object and can therefore not be parsed as a map`; 232 | } 233 | const decodedPairs = Object.entries(map).map(([key, value]) => { 234 | try { 235 | if (keys && !isKey(key, keys)) { 236 | throw `Key \`${key}\` is not in given keys`; 237 | } 238 | 239 | return [key, decode(decoder)(value)] as [K, decodeType]; 240 | } catch (message) { 241 | throw message + `\nwhen decoding the key \`${key}\` in map \`${map}\``; 242 | } 243 | }); 244 | return new Map(decodedPairs); 245 | }; 246 | } 247 | -------------------------------------------------------------------------------- /tests/index.test.ts: -------------------------------------------------------------------------------- 1 | import { 2 | nullable, 3 | boolean, 4 | date, 5 | number, 6 | string, 7 | array, 8 | dict, 9 | map, 10 | optional, 11 | set, 12 | union, 13 | field, 14 | fields, 15 | literal, 16 | record, 17 | tuple, 18 | decodeType, 19 | Pojo, 20 | Decoder, 21 | decode, 22 | intersection, 23 | } from '../src'; 24 | 25 | test('everything', () => { 26 | const discriminatedUnion = union( 27 | { discriminant: literal('one') }, 28 | { discriminant: literal('two'), data: string }, 29 | ); 30 | 31 | const message = union( 32 | tuple('message', string), 33 | tuple('something-else', { somestuff: string }), 34 | ); 35 | 36 | // test impl 37 | const always = 38 | (x: T): Decoder => 39 | (json: unknown) => 40 | x; 41 | always(false); 42 | type IEmployee = decodeType; 43 | 44 | const employeeDecoder = record({ 45 | renamedfield: field('phoneNumbers', array(string)), 46 | month2: fields({ dateOfBirth: date }, ({ dateOfBirth }) => 47 | dateOfBirth.getMonth(), 48 | ), 49 | maybessn: fields({ ssn: optional(string) }, ({ ssn }) => ssn), 50 | employeeIdentifier2: fields( 51 | { name: string, employeeId: optional(number) }, 52 | ({ name, employeeId }) => `${name}:${employeeId || 0}`, 53 | ), 54 | month: field('dateOfBirth', (x) => date(x).getMonth()), 55 | employeeIdentifier: fields( 56 | { 57 | name: string, 58 | employeeId: number, 59 | }, 60 | ({ name, employeeId }) => `${name}:${employeeId}`, 61 | ), 62 | employeeId: number, 63 | name: string, 64 | set: set(union(string, number, { data: boolean })), 65 | employees: map( 66 | { 67 | employeeId: number, 68 | name: string, 69 | ssn: optional(string), 70 | }, 71 | (x) => x.employeeId, 72 | ), 73 | dict: dict(union(string, number)), 74 | phoneNumbers: array(string), 75 | address: { 76 | city: string, 77 | }, 78 | secondAddrese: optional({ city: string, option: optional(number) }), 79 | ageAndReputation: [number, string], 80 | discriminatedUnion, 81 | intersection: intersection(discriminatedUnion, { extraData: string }), 82 | message, 83 | uni: union('uni', { lol: string }), 84 | likes: array([literal('likt'), number]), 85 | likes2: array(tuple('likt', number)), 86 | isEmployed: boolean, 87 | dateOfBirth: date, 88 | ssn: optional(string), 89 | girlfriend: nullable(string), 90 | test: fields( 91 | { girlfriend: nullable(string), dateOfBirth: date }, 92 | ({ girlfriend, dateOfBirth }) => girlfriend ?? dateOfBirth, 93 | ), 94 | just: array(union(boolean, always(false))), 95 | }); 96 | 97 | const toDecode: Pojo = { 98 | employeeId: 2, 99 | name: 'asdfasd', 100 | set: ['7', 7, { data: true }], 101 | employees: [ 102 | { employeeId: 1, name: 'lollern' }, 103 | { employeeId: 3, name: 'other guy', ssn: '4' }, 104 | ], 105 | dict: { somestuff: 'lol', morestuff: 7 }, 106 | message: ['something-else', { somestuff: 'a' }], 107 | discriminatedUnion: { discriminant: 'two', data: '2' }, 108 | intersection: { discriminant: 'one', extraData: 'hiya' }, 109 | address: { city: 'asdf' }, 110 | secondAddrese: { city: 'secondcity' }, 111 | uni: 'uni', 112 | likes: [ 113 | ['likt', 3], 114 | ['likt', 0], 115 | ], 116 | likes2: [ 117 | ['likt', 1], 118 | ['likt', 2], 119 | ], 120 | phoneNumbers: ['733', 'dsfadadsa', '', '4'], 121 | ageAndReputation: [12, 'good'], 122 | dateOfBirth: '1995-12-14T00:00:00.0Z', 123 | isEmployed: true, 124 | girlfriend: null, 125 | just: ['blah', true, false], 126 | }; 127 | 128 | const x: IEmployee = employeeDecoder(toDecode); 129 | 130 | expect(x).toEqual({ 131 | employeeId: 2, 132 | name: 'asdfasd', 133 | set: new Set(['7', 7, { data: true }]), 134 | employees: new Map([ 135 | [1, { employeeId: 1, name: 'lollern' }], 136 | [3, { employeeId: 3, name: 'other guy', ssn: '4' }], 137 | ]), 138 | dict: new Map([ 139 | ['somestuff', 'lol'], 140 | ['morestuff', 7], 141 | ]), 142 | message: ['something-else', { somestuff: 'a' }], 143 | discriminatedUnion: { discriminant: 'two', data: '2' }, 144 | intersection: { discriminant: 'one', extraData: 'hiya' }, 145 | address: { city: 'asdf' }, 146 | secondAddrese: { city: 'secondcity', option: undefined }, 147 | employeeIdentifier2: 'asdfasd:2', 148 | employeeIdentifier: 'asdfasd:2', 149 | uni: 'uni', 150 | likes: [ 151 | ['likt', 3], 152 | ['likt', 0], 153 | ], 154 | likes2: [ 155 | ['likt', 1], 156 | ['likt', 2], 157 | ], 158 | phoneNumbers: ['733', 'dsfadadsa', '', '4'], 159 | renamedfield: ['733', 'dsfadadsa', '', '4'], 160 | ageAndReputation: [12, 'good'], 161 | dateOfBirth: new Date('1995-12-14T00:00:00.0Z'), 162 | month: 11, 163 | month2: 11, 164 | isEmployed: true, 165 | test: new Date('1995-12-14T00:00:00.0Z'), 166 | girlfriend: null, 167 | ssn: undefined, 168 | just: [false, true, false], 169 | }); 170 | }); 171 | 172 | const nameDecoder = record({ first: string, last: string }); 173 | 174 | const guestDecoder = record({ 175 | type: decode('Guest'), 176 | email: string, 177 | employer: optional({ 178 | id: string, 179 | corporateId: string, 180 | companyName: string, 181 | }), 182 | reference: union( 183 | { 184 | ref: decode('SsoMicrosoft'), 185 | tid: string, 186 | oid: string, 187 | }, 188 | { 189 | ref: decode('SsoMicrosoftPersonal'), 190 | oid: string, 191 | }, 192 | ), 193 | }); 194 | 195 | const rolesDecoder = array( 196 | union( 197 | { 198 | type: decode('Accountant'), 199 | employer: { 200 | id: string, 201 | corporateId: string, 202 | companyName: string, 203 | }, 204 | }, 205 | { 206 | type: decode('Employer'), 207 | employer: { 208 | id: string, 209 | corporateId: string, 210 | companyName: string, 211 | }, 212 | }, 213 | { 214 | type: decode('Company'), 215 | corporateId: string, 216 | companyName: string, 217 | }, 218 | ), 219 | ); 220 | 221 | const userDecoder = record({ 222 | type: decode('User'), 223 | account: { 224 | id: string, 225 | name: nameDecoder, 226 | email: string, 227 | }, 228 | roles: rolesDecoder, 229 | intent: optional(union('Accountant', 'Company')), 230 | }); 231 | 232 | const personDecoder = union(userDecoder, guestDecoder); 233 | 234 | const tokenDecoder = record({ 235 | token: string, 236 | user: personDecoder, 237 | }); 238 | 239 | const token = { 240 | token: '', 241 | user: { 242 | type: 'User', 243 | account: { 244 | id: 'ac000002-0000-0000-0000-000000000001', 245 | name: { 246 | first: 'Andreas', 247 | last: 'Johansson', 248 | }, 249 | email: 'andreas@gmail.se', 250 | }, 251 | roles: [ 252 | { 253 | type: 'Accountant', 254 | employer: { 255 | id: 'e1000000-0000-0000-0000-000000000001', 256 | corporateId: '551191-3113', 257 | companyName: 'FRA', 258 | }, 259 | }, 260 | ], 261 | }, 262 | }; 263 | 264 | const myToken = tokenDecoder(token); 265 | 266 | type Address = decodeType; 267 | const addressDecoder = record({ 268 | houseNumber: number, 269 | street: optional(string), 270 | test: optional(number), 271 | }); 272 | 273 | const address: Address = { houseNumber: 0 }; 274 | const address2: Address = { 275 | houseNumber: 1, 276 | street: undefined, 277 | }; 278 | const address3: Address = { 279 | houseNumber: 2, 280 | street: '', 281 | }; 282 | 283 | type ThingWithAddress = decodeType; 284 | const thingDecoder = record({ 285 | thing: optional(string), 286 | address: addressDecoder, 287 | }); 288 | 289 | const thing: ThingWithAddress = { 290 | thing: '', 291 | address: { 292 | houseNumber: 1, 293 | street: '', 294 | }, 295 | }; 296 | 297 | test('partial thing', () => { 298 | type Address2 = decodeType; 299 | const addressDecoder2 = record({ 300 | houseNumber: optional(string), 301 | 302 | // if you remove these three, everything breaks 303 | test: optional(number), 304 | street: string, 305 | number: number, 306 | }); 307 | 308 | type ThingWithAddress2 = decodeType; 309 | const thingDecoder2 = record({ 310 | id: optional(string), 311 | address: addressDecoder2, 312 | optionalAddress: optional(addressDecoder2), 313 | test: optional(number), 314 | }); 315 | 316 | const thing2: ThingWithAddress2 = { 317 | id: '', 318 | address: { 319 | number: 1, 320 | houseNumber: '1', 321 | street: '', 322 | }, 323 | test: 2, 324 | }; 325 | 326 | const houseNumber: string | undefined = thing2.address.houseNumber; 327 | 328 | const optionalHouseNumber: Address2 | undefined = thing2.optionalAddress; 329 | 330 | expect(houseNumber).toEqual('1'); 331 | expect(optionalHouseNumber).toEqual(undefined); 332 | }); 333 | -------------------------------------------------------------------------------- /article.md: -------------------------------------------------------------------------------- 1 | # Json decoders in TypeScript 2 | 3 | TypeScript is great because it lets you write statically verified code. You'll never try to access a property on an object that doesn't have it, and you'll never get a `undefined is not a function`. Except there are holes. 4 | 5 | Have you ever written code like this? 6 | 7 | ```typescript 8 | const users = fetch('/users') as Promise; 9 | ``` 10 | 11 | This sucks, because we are just hoping and praying the response from the `/users` endpoint matches our definition of the `User` type, which might look something like the following. 12 | 13 | ```typescript 14 | type User = { 15 | id: number; 16 | username: string; 17 | friends: number[]; 18 | }; 19 | ``` 20 | 21 | Usually it does, but there is never a guarantee - and especially not a guarantee that the data from the endpoint never changes. It's even more insidiuous if the data mostly matches, but sometimes it returns something slightly different, because you'll probably not catch it when developing and maybe not even when testing. And that's how you ship a bug to production that should (and could!) have been caught at compile time. 22 | 23 | Alright but how do you catch that at compile time? Without [some kind of integration of your backend and frontend types,](https://functional.christmas/2020/6) how do you statically verify the shape of your (possibly external) API's data? Well you can't really do that easily, but you can *verify* that the data actually does match the type you say it has, as it comes in. So that's the idea, verify early and fail hard. Instead of mindlessly casting (using TypeScripts `as` operator) where you introduce the possibility of error, we would like to actually test and make sure the data actually conforms to our type and instead reject the Promise if it doesn't. Which I think makes sense, there is no practical difference to your app whether the external endpoint is down or it returns to you data that you don't understand. 24 | 25 | But this seems annoying, should we write parsers or validators for every type that comes in from an external source? That takes a lot of time, and is doubly annoying to maintain as our app and our APIs grow and change. That's why I wrote TypeScript Json Decoder. It is a library that automatically creates what is often known as "decoders", functions that make sure your data looks the way you say it does, based on your types. It has no external dependencies, is lightweight, and one of its core values is that it resembles and can be swapped in, in place of your existing TypeScript type definitions without any modifications or limitations. I want this to be idiomatic, regular TypeScript with as little friction as possible. The API surface of the library is designed to be intuitive and as small as possible, so that it's not a case of "yet another library" for people to learn and manage in their code. This is merely how I wish TypeScript already worked. 26 | 27 | Check out [the GitHub page](https://github.com/tskj/typescript-json-decoder) to see lots of examples and explanations of the more advanced features needed to express everything a TypeScript type can. 28 | 29 | Let me give you a basic introduction to the underlying idea. If we wanted to replace our `User` type with a decoder for that type, we would instead write the following. 30 | 31 | ```typescript 32 | import { decodeType, record, number, string, array } from 'typescript-json-decoder'; 33 | 34 | type User = decodeType; 35 | const userDecoder = record({ 36 | id: number, 37 | username: string, 38 | friends: array(number), 39 | }); 40 | ``` 41 | 42 | And to use it we replace the cast with a call to the decoder. 43 | 44 | ```typescript 45 | const users = fetch('/users').then(x => x.json()).then(array(userDecoder)); 46 | ``` 47 | 48 | Or rather a decoder of an array of `userDecoder`s, assuming our endpoint returns a list of users. Notice we have to parse the json first using a call to `fetch`'s standard Json parser, `x.json()`. This is because although the library is called `typescript-json-decoder`, it actually operates on plain old JavaScript values, such as objects, arrays, numbers and strings. It's maybe a bit of a misnomer, but it mostly makes sense to think about it this way. The nice thing about it is that you can pass any valid JS value to a decoder, and it will either return the decoded value or throw an error. The error reporting actually just throws a string describing the error, which rejects the promise if you use it in an async context. The decoder itself isn't async or aware of promises or anything like that. 49 | 50 | At the cost of one line of boilerplate this gives you complete type safety when handling external data. I think this pattern should be used *every* place you receive untrusted data, and that includes endpoints you own. I at least don't trust myself to get these things right, there are too many opportunities to mess up APIs, even if it's just misspelling a field name when typing in the type. 51 | 52 | ## How it works 53 | 54 | The way this works under the hood is pretty interesting. If you pay close attention you see that what we have defined is not a type at all, it's actually a JavaScript object that kind of looks like a type definition. This is because it's actually impossible in TypeScript (without hooking into the compiler) to do the kind of metaprogramming where you inspect the type itself. So instead we do the opposite, we create a regular value which represents the type we wish to generate, and from that we generate both the type itself (and call it `User`) and the decoder. The special TypeScript operator `typeof` (named after a similar, but different, JavaScript operator with the same name) is used to extract the type of the decoder from the JavaScript object that defines it, in our case `userDecoder`. This type is then transformed to the corresponding type which the decoder is supposed to decode to. 55 | 56 | The idea is that the library supplies all the primitive decoders such as `number`, `string`, and even `array` (which takes another decoder as a parameter), and then we combine these to build bigger decoders. The way these decoders are defined is really simple, take a look at the following definition of the `string` decoder. 57 | 58 | ```typescript 59 | const string: DecoderFunction = (s: Pojo) => { 60 | if (typeof s !== 'string') { 61 | throw `The value \`${s}\` is not of type \`string\`, but is of type \`${typeof s}\``; 62 | } 63 | return s; 64 | }; 65 | ``` 66 | 67 | Note that the library refers to regular JavaScript objects as `Pojo`s. This decoder doesn't actually *do* anything! It just returns the string it's passed, if it is a string, or if not, throws. So this is actually the identity function for strings. 68 | 69 | The complexity in the library is elsewhere; the core idea is that JavaScript values are considered to be decoders of themselves. I call these literal decoders. For example, the string `"hello"` is a decoder of the literal value `"hello"`, and only that value. That might not seem so useful, but once you extend it to records, it becomes quite powerful. A record containing decoders, *is a decoder of a record with the same fields*. In a sense, it is a decoder of itself. In other words, it decodes a thing that looks like itself. And this is defined recursively, which allows us to nest objects, or have arrays of objects, or arrays containing objects of objects containing arrays, and so on and so on. In this way you can define your own "custom" decoders and compose them arbitrarily. 70 | 71 | The way the decoders are evaluated, then, is a pretty straightforward recursive traversal of the tree structure of decoders where it applies the decoders it finds. What is much more interesting, and honestly where all the complexity of this library resides, is in the innocent looking `decode` type level function, which is responsible for taking a decoder and producing the type of the thing that decoder decodes. 72 | 73 | What. A type level function? So TypeScript actually has an incredibly sophisticated type system. I come from a background of having fallen in love with the intricacies of Haskell's type system, but TypeScript is in many ways more advanced. This comes from the necessity of being able to express a lot of the patterns commonly found in JavaScript, which is by nature very dynamic. Anyway, a type level function is a function, at the type level. Bear with me. `decode` in the example above is not actually itself a type, rather it is something that when given a type, returns a type. A function of types! A function from a type to a type? Can types have types? This is sometimes referred to as "the kind" of a type, but if we don't want to confuse ourselves too much, we'll just think of this as something that you can give an existing type to, and get another, new, type out of. 74 | 75 | If you think about it, a decoder by necessity does not have the same type as the thing it decodes. Instead maybe it has the following type: `(x: Pojo) => User`. So it takes any plain old JavaScript object, and returns a `User` (if it can, that is; TypeScript doesn't have checked exceptions, so you won't see the failure case in the type). The type this decoder decodes, is `User`. So the following type expression: `decode<(x: Pojo) => User>` evaluates to the type `User`, or generally whatever the decoder decodes. This all is complicated further by the fact previously mentioned that not all decoders are functions; some decoders, for instance a record decoder, has a literal form. 76 | 77 | However, let's ignore that and first think about how to extract the type that a (function) decoder will decode. 78 | 79 | ```typescript 80 | type decodeType = decoder extends (x: Pojo) => (infer T) ? T : never; 81 | ``` 82 | 83 | Now this is the kind of metaprogramming that gets me going. What in the world is going on. Well, first of all we have a ternary - essentially an if test on types. The thing we are testing on is the `decoder extends (x: Pojo) => (infer T)` part, which is a *subtype test*. The extend keyword, I think, is a horribly chosen name in TypeScript, mostly carried over from other contexts. What it means is "is a subtype of". It is a question asked of the type parameter `decoder`, are you a subtype of the type `(x: Pojo) => (infer T)`? Which begs the question, what is `infer T`? Well, it is whatever it needs to be to satisfy the subtype test. If `decoder` is the function type defined above, `(x: Pojo) => User`, then `T` would need to be `User` for the one to be the subtype of the other - at least if you consider being the same type as being a subtype. The keyword `infer` is used to introduce a new type variable. In the first branch of the ternary we return the type `T` if we have a match (that is, the decoder is a function type), and in the second branch we return TypeScript's bottom type `never`, indicating this should never happen. If this does happen, and we try to use the resulting `never` type for anything, we get a compiler error. `never` is the empty set, if you are inclined to think about types as sets. There is no value of this type. 84 | 85 | This is actually the definition of the type level function for extracting the return type of a function (that's a mouthful), and is in fact available in the standard library under the name `ReturnType`! So that was a lot of type theory for very little. We need to go further. I mentioned that the essence of this library is to understand literal values as decoders of themselves, and to define this recursively in the case of records. So let's add records! 86 | 87 | ```typescript 88 | type decodeType = 89 | decoder extends (x: Pojo) => (infer T) 90 | ? T 91 | : { [key in keyof decoder]: decodeType } 92 | ``` 93 | 94 | Here I've formatted the ternary to make it resemble a classical if block a bit more. It's kind of elegant in a way. It's the classic recursive pattern, a base case where we break the recursion when there is no more work to do, and a body where we recursively call ourselves with a smaller set of the problem. Here we use TypeScript's well understood mapped types to iterate, or map I guess, over the keys and values of the record. For every key we evaluate `decodeType` on its value. So the following type, which would be the type of the decoder of our `User` type, 95 | 96 | ```typescript 97 | type User = decodeType<{ 98 | id: (x: Pojo) => number; 99 | username: (x: Pojo) => string; 100 | friends: (x: Pojo) => number[]; 101 | }>; 102 | ``` 103 | 104 | would evaluate to what we expect, namely the `User` type itself. 105 | 106 | ```typescript 107 | type User = { 108 | id: number; 109 | username: string; 110 | friends: number[]; 111 | }; 112 | ``` 113 | 114 | Now hopefully it makes sense to think about the series of steps it would take to evaluate a nested type defintion. So if we were to add an `address` to our `User`: 115 | 116 | ```typescript 117 | type User = decodeType; 118 | const userDecoder = record({ 119 | id: number, 120 | username: string, 121 | friends: array(number), 122 | address: { 123 | city: string, 124 | zip: number, 125 | }, 126 | }); 127 | ``` 128 | 129 | we would get the following evaluation steps. 130 | 131 | ```typescript 132 | type User = decodeType<{ 133 | id: (x: Pojo) => number; 134 | username: (x: Pojo) => string; 135 | friends: (x: Pojo) => number[]; 136 | address: { 137 | city: (x: Pojo) => string; 138 | zip: (x: Pojo) => number; 139 | } 140 | }>; 141 | 142 | type User = { 143 | id: decodeType<(x: Pojo) => number>; 144 | username: decodeType<(x: Pojo) => string>; 145 | friends: decodeType<(x: Pojo) => number[]>; 146 | address: decodeType<{ 147 | city: (x: Pojo) => string; 148 | zip: (x: Pojo) => number; 149 | }>, 150 | }>; 151 | 152 | type User = { 153 | id: number; 154 | username: string; 155 | friends: number[]; 156 | address: { 157 | city: decodeType<(x: Pojo) => string>; 158 | zip: decodeType<(x: Pojo) => number>; 159 | }, 160 | }>; 161 | 162 | type User = { 163 | id: number; 164 | username: string; 165 | friends: number[]; 166 | address: { 167 | city: string; 168 | zip: number; 169 | }, 170 | }>; 171 | ``` 172 | 173 | And that's basically it! If you're curious to see the actual implementation I suggest checking out the source code on GitHub. It's a bit more involved, but I have tried to keep it pretty clean. I would very much welcome PR's and suggestions for improvements. Thanks for reading! 174 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![logo](logo.png) 2 | # 3 | 4 | TypeScript Json Decoder is a library for decoding untrusted data as it comes in to your system, inspired by elm-json-decode. 5 | 6 | Detecting at runtime that your type does not in fact match the value returned by your API sucks, and not being able to parse the data to a data structure of your liking in a convenient way sucks majorly - and having a type definition separate from its parser is unacceptable. 7 | 8 | Installation: [npmjs.com/package/typescript-json-decoder](https://www.npmjs.com/package/typescript-json-decoder) 9 | 10 | Try it here: [sandbox](https://codesandbox.io/s/typescript-json-decoder-playground-5751w5) 11 | 12 | I've also written a piece about how it works internally and the underlying idea [here.](article.md) 13 | 14 | ## The idea 15 | 16 | The following is an example of a simple decoder which defines a decoder of type `User`. 17 | 18 | ```typescript 19 | import { decodeType, record, number, string, boolean } from 'typescript-json-decoder'; 20 | 21 | type User = decodeType; 22 | const userDecoder = record({ 23 | id: number, 24 | username: string, 25 | isBanned: boolean, 26 | }); 27 | ``` 28 | 29 | `userDecoder` is a function from any JavaScript object to `User`, which is the generated type. This type is inferred to be exactly what you expect. `number`, `string`, and `boolean` are also decoders in the same way, and decode values of their respective types. If any of these decoders fail they throw with an appropriate error message. 30 | 31 | The idea is to have one declaration of the types in your system the same way as you would if you only used TypeScript, but also have decoders of those types. Although we declare decoders and infer the corresponding types, I like to think of the declaration as a normal type declaration like you are used to, and incidentally also getting a decoder. 32 | 33 | To use this decoder with an endpoint which returns a user object, you would do the following. 34 | 35 | ```typescript 36 | const user: Promise = 37 | fetch('/users/1') 38 | .then(x => x.json()) 39 | .then(userDecoder); 40 | ``` 41 | 42 | Although, the `Promise` declaration is redundant; the correct type will be inferred for us. If the decoder fails, the promise is rejected. 43 | 44 | ## Benefits 45 | 46 | - You have *one* definition of your type which is easy to change and mirrors regular TypeScript definitions. The decoder is a free bonus. 47 | 48 | - Immediate error messages anytime your assumption about your API is wrong, or if it were to change in the future. No more runtime errors that only occasionally occur. 49 | 50 | - If you use the decoder at the end of a fetch call, the promise will reject - meaning you can consider a decoding failure the same as any other network failure. 51 | 52 | - A decoder for an object will pick out all the keys it needs, and discard the rest. This means that if your API has superfluous keys you don't care about, you won't carry unnecessary data around in the objects in your app. 53 | 54 | - All the standard types have decoders provided which you can use directly and never have to write a custom decoder. 55 | 56 | - If you'd like you can write custom decoders, operating on whatever data you want and producing whatever you want. Decoders are just functions, and functions can be composed! 57 | 58 | - Decoders can do arbitrary transformations of your data, massaging it to have the exact shape and structure you want. There is no reason to be stuck with whatever data structure your API supplies. 59 | 60 | - Decoders can do validation! If you want to write a decoder that does validation, simply pass the data through your decoder unchanged if it satisfies your rules, or throw an error if it doesn't. 61 | 62 | ## Usage 63 | 64 | This library supports all the regular TypeScript types you are used to and can be composed arbitrarily to describe your types - with a goal of being as close to the regular type syntax as possible. 65 | 66 | Expanding on the `User` example, we could for instance have an optional ssn and a list of phone numbers. 67 | 68 | ```typescript 69 | import { decodeType, record, number, string, boolean, array, optional } from 'typescript-json-decoder'; 70 | 71 | type User = decodeType; 72 | const userDecoder = record({ 73 | id: number, 74 | username: string, 75 | isBanned: boolean, 76 | phoneNumbers: array(string), 77 | ssn: optional(string), 78 | }); 79 | ``` 80 | 81 | I call these higher order decoders, as they are functions accepting any decoder and returning the matching decoder. If you provide a function from any JavaScript object to a type `T`, that is a decoder of T (`Decoder`) and can be used in any combination with each other. 82 | 83 | Another useful kind of "type combinator" in TypeScript is the concept of a union of two types, for instance written `string | number` for the union of strings and numbers. We can imagine a user has a credit card number which is either a string or a number. Don't refer to me for domain modeling advice. 84 | 85 | ```typescript 86 | import { decodeType, record, number, string, boolean, array, optional, union } from 'typescript-json-decoder'; 87 | 88 | type User = decodeType; 89 | const userDecoder = record({ 90 | id: number, 91 | username: string, 92 | isBanned: boolean, 93 | phoneNumbers: array(string), 94 | ssn: optional(string), 95 | creditCardNumber: union(string, number), 96 | }); 97 | ``` 98 | 99 | Union takes an arbitrary number of parameters. 100 | 101 | Similarly, you can use something like `intersection({email: string}, userDecoder)` to get a decoder for `{email: string} & user`, the subtype of users that also have an e-mail address. 102 | 103 | Lastly we can add some more stuff, and if you wish to fetch a list of your users, do it like the following. 104 | 105 | ```typescript 106 | import { decodeType, record, number, string, boolean, array, optional, union } from 'typescript-json-decoder'; 107 | 108 | type User = decodeType; 109 | const userDecoder = record({ 110 | id: number, 111 | username: string, 112 | isBanned: boolean, 113 | phoneNumbers: array(string), 114 | ssn: optional(string), 115 | creditCardNumber: union(string, number), 116 | address: { 117 | city: string, 118 | timezones: array({ info: string, optionalInfo: optional(array(number)) }) 119 | } 120 | }); 121 | 122 | const users: Promise = 123 | fetch('/users') 124 | .then(x => x.json()) 125 | .then(array(userDecoder)) 126 | ``` 127 | 128 | ## Advanced usage 129 | 130 | Everything so far should cover most APIs you need to model. However, I really want to give you the tools to model any kind of API you come across or want to create. Therefore we will look at some more complicated and useful constructs. 131 | 132 | Although not as common in Json APIs (yet?), tuples are a very useful data structure. In JavaScript we usually encode them as lists with exactly two elements and possibly of different types, and TypeScript understands this. A tuple with a string and a number (such as `['user', 2]`) can be expressed with the type `[string, number]`. In this library we can use the `tuple` function to the same effect. 133 | 134 | ```typescript 135 | import { decodeType, tuple, string, number } from 'typescript-json-decoder'; 136 | 137 | type StringAndNumber = decodeType; 138 | const stringAndNumberDecoder = tuple(string, number); 139 | const myTuple = stringAndNumberDecoder(['user', 2]); 140 | ``` 141 | 142 | This doesn't really match the syntax of regular TypeScript as much as I would like, so as a convenience feature we also allow a *literal syntax* for tuples. The idea is that a two element list of decoders can be cansidered itself a decoder of the corresponding tuple. The same example as above written in the literal form would be as follows. 143 | 144 | ```typescript 145 | import { decodeType, decode, string, number } from 'typescript-json-decoder'; 146 | 147 | type StringAndNumber = decodeType; 148 | const stringAndNumberDecoder = decode([string, number]); 149 | const myTuple = stringAndNumberDecoder(['user', 2]); 150 | ``` 151 | 152 | Notice we now need a call to a `decode` function to make it into an actually callable decoder. `decode` is the low level implementation which all the other decoders are implemented in terms of; that is `record`, `tuple`, and all the other built in decoders eventually call `decode` to do the dirty work. But that's a tangent, the advantage to this approach is that you can use the literal tuple syntax directly in an object, such as the following. 153 | 154 | ```typescript 155 | const myDecoder = record({ 156 | username: string, 157 | result: [string, number], 158 | results: array([string, number]), 159 | }); 160 | ``` 161 | 162 | It turns out this idea of literal form decoders is actually a lot more general. In fact, you can consider the first example of the `User` type to be a literal decoder where the `User` decoder object is a decoder of a JavaScript object of the same form. For this reason we also consider strings as literal decoders of themselves, that is `decoder('hey')` literally decodes the string `'hey'`. That might seem dumb, but it allows some really cool stuff. Firstly it allows us to decode an object which looks exactly like the following. 163 | 164 | ```typescript 165 | const x = { type: 'cool', somestuff: "" }; 166 | ``` 167 | 168 | With this decoder. 169 | 170 | ```typescript 171 | import { decodeType, decode, record, string } from 'typescript-json-decoder'; 172 | 173 | type Cool = decodeType; 174 | const coolDecoder = record({ type: decode('cool'), somestuff: string }); 175 | ``` 176 | 177 | Similarly we can define another decoder of this type. 178 | 179 | ```typescript 180 | const y = { type: 'dumb', otherstuff: 'starbucks' }; 181 | ``` 182 | 183 | With a decoder that looks like this. 184 | 185 | ```typescript 186 | import { decodeType, decode, record, string } from 'typescript-json-decoder'; 187 | 188 | type Dumb = decodeType; 189 | const dumbDecoder = record({ type: decode('dumb'), otherstuff: string }); 190 | ``` 191 | 192 | This ensures that the `type` key is exactly the string `cool` or `dumb` respectively. If we now combine these decoders using a union we get what is known as a "discriminated union". 193 | 194 | ```typescript 195 | import { decodeType, union } from 'typescript-json-decoder'; 196 | 197 | type Stuff = decodeType; 198 | const stuffDecoder = union(coolDecoder, dumbDecoder); 199 | ``` 200 | 201 | The type `Stuff` represents the union of these two other types, and TypeScript now requires us to check the `type` field before trying to access either `somestuff` or `otherstuff` since they do not appear in both types - but one of them are guaranteed to exist. 202 | 203 | ## Custom decoders 204 | 205 | All the decoders we have defined so far are in a way custom decoders and can be combined freely, however I encourage people to create arbitrary parsing functions which transform and validate data. Simply create a function which tries to build the data structure you want and throw an error message if you are unable to signify failure. Decoders can be reused and combined however you want, and composition of decoders is simply function composition. 206 | 207 | Here are some decoders I wrote mostly for fun. 208 | 209 | `date` is a decoder which returns a native `Date` object. This is actually more expressive than what you usually get from a Json API typed with TypeScript, which might have the following type. 210 | 211 | ```typescript 212 | type BlogPost = { 213 | title: string; 214 | content: string; 215 | createdDate: string; 216 | } 217 | ``` 218 | 219 | However we know that `createdDate` is a string representing a date, and at some point we might or might not like to work with it as a `Date` object. Here I simply invoke the regular `string` decoder and then try to parse that string as a `Date`, and throw otherwise. That might look like the following. 220 | 221 | ```typescript 222 | import { string } from 'typescript-json-decoder'; 223 | 224 | const date = (value: Pojo) => { 225 | const dateString = string(value); 226 | const timeStampSinceEpoch = Date.parse(dateString); 227 | if (isNaN(timeStampSinceEpoch)) { 228 | throw `String \`${dateString}\` is not a valid date string`; 229 | } 230 | return new Date(timeStampSinceEpoch); 231 | }; 232 | ``` 233 | 234 | I provide this decoder with the library, and we can use it as follows. 235 | 236 | ```typescript 237 | import { decodeType, record, date } from 'typescript-json-decoder'; 238 | 239 | type blogpost = decodeType; 240 | const blogpostdecoder = record({ 241 | title: string, 242 | content: string, 243 | createddate: date, 244 | }); 245 | ``` 246 | 247 | Look at that: actual, type safe, automatic parsing of a date encoded as a Json string. 248 | 249 | At his point I went a little crazy implementing fun data structures. How about a dictionary? A dictionary is a map from strings to your type `T`, that is, the type `Map`. The function `dict` then takes a decoder of `T` and creates a decoder which parses *JavaScript object literals* as maps. Take a look at the following example to understand how it works. 250 | 251 | ```typescript 252 | import { dict } from 'typescript-json-decoder'; 253 | 254 | const myDictionary = { 255 | one: 1, 256 | two: 2, 257 | three: 3, 258 | }; 259 | 260 | const numberDictionaryDecoder = dict(number); 261 | const myMap = numberDictionaryDecoder(myDictionary); // Map 262 | console.log(myMap.get('two')); // 2 263 | ``` 264 | 265 | Although this makes a lot of sense, few APIs actually use Json literals to encode maps. Rather you often see lists of objects, for example lists of `User` objects, which in a sense *are* maps, and maybe you want to treat those as maps from their user id to the user object. Enter the `map` decoder. 266 | 267 | The `map` decoder is a function which takes a decoder and a "key" function. The key function takes the decoded object and returns its key. Imagine you have Json of the following form. 268 | 269 | ```json 270 | [ 271 | { 272 | "id": 1, 273 | "username": "Fred", 274 | "isBanned": true, 275 | }, 276 | { 277 | "id": 2, 278 | "username": "Olga", 279 | "isBanned": false, 280 | } 281 | ] 282 | ``` 283 | 284 | A decoder which understands this is data structure can be specified as the following. 285 | 286 | ```typescript 287 | import { map, Decoder } from 'typescript-json-decoder'; 288 | 289 | const userListDecoder: Decoder> = 290 | map(userDecoder, x => x.id); 291 | ``` 292 | 293 | Here too the type declaration is redundant and will be inferred if you wish. You can also inline the definition if it only appears one place and you don't need a name for it. 294 | 295 | ```typescript 296 | import { number, string, boolean, map, Decoder } from 'typescript-json-decoder'; 297 | 298 | const userListDecoder = 299 | map({ 300 | id: number, 301 | username: string, 302 | isBanned: boolean, 303 | }, x => x.id); 304 | ``` 305 | 306 | ## Low level access 307 | 308 | Sometimes you need direct access to the fields of the object you're decoding. Maybe you want to use the same fields to calculate two different things, or maybe you want to combine two or more different fields. 309 | 310 | The `field` decoder accepts a string, the name of the key, and a decoder which decodes the value found at this key. 311 | 312 | Say you have some date in an iso-date-string format in the field `"dateOfBirth"` but are only interested in the year and month, you could use the `field` decoder to access it in the following way. 313 | 314 | ```typescript 315 | import { decodeType, record, field } from 'typescript-json-decoder'; 316 | 317 | type User = decodeType; 318 | const userDecoder = record({ 319 | month: field('dateOfBirth', x => date(x).getMonth() + 1), 320 | year: field('dateOfBirth', x => date(x).getFullYear()), 321 | }); 322 | ``` 323 | 324 | Alternatively if you need both username and id you can use the `fields` decoder. This has a slightly different api. The `fields` decoder accepts an object decoder in the same way `decoder` does, and a second argument, a continuation, which accepts the result of this decoder and produces the resulting value of the `fields` decoder. An example is maybe more explanatory. 325 | 326 | ```typescript 327 | import { decodeType, record, fields } from 'typescript-json-decoder'; 328 | 329 | type User = decodeType; 330 | const userDecoder = record({ 331 | identifier: fields({ username: string, userId: number }, 332 | ({ username, userId }) => `user:${username}:${userid}`), 333 | }); 334 | ``` 335 | 336 | This is read as "the `userDecoder` decodes an object which might look like `{ username: "hunter2", userId: 3 }` and decodes to an object which looks like `{ identifier: "user:hunter2:3" }`". 337 | 338 | Both the `field` and the `fields` decoder are meant to be used "inside" a record decoder in the way shown here. 339 | -------------------------------------------------------------------------------- /tests/unit.test.ts: -------------------------------------------------------------------------------- 1 | import { 2 | decode, 3 | boolean, 4 | decodeType, 5 | number, 6 | string, 7 | tuple, 8 | literal, 9 | record, 10 | undef, 11 | nil, 12 | field, 13 | fields, 14 | union, 15 | date, 16 | optional, 17 | array, 18 | set, 19 | map, 20 | dict, 21 | nullable, 22 | intersection, 23 | Decoder, 24 | } from '../src'; 25 | 26 | test('homogeneous tuple', () => { 27 | const t: [string, string] = ['a', 'b']; 28 | 29 | type tuple = decodeType; 30 | const tuple_decoder = tuple(string, string); 31 | 32 | expect(tuple_decoder(t)).toEqual(t); 33 | }); 34 | 35 | test('heterogeneous tuple', () => { 36 | const t: [string, number] = ['a', 0]; 37 | 38 | type tuple = decodeType; 39 | const tuple_decoder = tuple(string, number); 40 | 41 | expect(tuple_decoder(t)).toEqual(t); 42 | }); 43 | 44 | test('homogeneous tuple literal', () => { 45 | const t: [string, string] = ['a', 'aa']; 46 | 47 | type tuple = decodeType; 48 | const tuple_decoder = decode([string, string]); 49 | 50 | expect(tuple_decoder(t)).toEqual(t); 51 | }); 52 | 53 | test('heterogeneous tuple literal', () => { 54 | const t: [string, number] = ['a', 1]; 55 | 56 | type tuple = decodeType; 57 | const tuple_decoder = decode([string, number]); 58 | 59 | expect(tuple_decoder(t)).toEqual(t); 60 | }); 61 | 62 | test('nested tuple', () => { 63 | const t: [string, [string, string]] = ['a', ['b', 'c']]; 64 | 65 | type tuple = decodeType; 66 | const tuple_decoder = decode(tuple(string, tuple(string, string))); 67 | 68 | expect(tuple_decoder(t)).toEqual(t); 69 | }); 70 | 71 | test('nested tuple literal', () => { 72 | const t: [string, [string, string]] = ['a', ['b', 'c']]; 73 | 74 | type tuple = decodeType; 75 | const tuple_decoder = decode([string, [string, string]]); 76 | 77 | expect(tuple_decoder(t)).toEqual(t); 78 | }); 79 | 80 | test('literal string', () => { 81 | const l1: 'a' = 'a' as const; 82 | 83 | type literal = decodeType; 84 | const literal_decoder = literal(l1); 85 | 86 | expect(literal_decoder(l1)).toEqual(l1); 87 | expect(() => literal_decoder('b')).toThrow(); 88 | }); 89 | 90 | // not supported for some reason 91 | // test('literal number', () => { 92 | // const l1: 1 = 1 as const; 93 | 94 | // type literal = decodeType; 95 | // const literal_decoder = literal(1); 96 | 97 | // expect(literal_decoder(l1)).toEqual(l1); 98 | // }); 99 | 100 | test('literal string union', () => { 101 | type decoderType = decodeType; 102 | const decoder = union('a', 'b'); 103 | 104 | expect(decoder('a')).toEqual('a'); 105 | expect(decoder('b')).toEqual('b'); 106 | expect(() => decoder('c')).toThrow(); 107 | }); 108 | 109 | test('literal literal string union', () => { 110 | type decoderType = decodeType; 111 | const decoder = union(literal('a'), literal('b')); 112 | 113 | expect(decoder('a')).toEqual('a'); 114 | expect(decoder('b')).toEqual('b'); 115 | expect(() => decoder('c')).toThrow(); 116 | }); 117 | 118 | test('record intersection', () => { 119 | type decoderType = decodeType; 120 | const decoder = intersection( 121 | record({a: union('foo', 'bar'), b: nullable(string)}), 122 | record({a: union('bar', 'baz'), b: string, c: optional(number)}) 123 | ); 124 | 125 | expect(decoder({a: 'bar', b: 'str'})).toEqual({a: 'bar', b: 'str'}); 126 | expect(() => decoder({a: 'bar', b: null})).toThrow(); 127 | }) 128 | 129 | test('union intersection', () => { 130 | type decoderType = decodeType; 131 | const decoder = intersection( 132 | union('foo', 'bar', 'baz'), 133 | union('bar', 'baz', number), 134 | union('baz', boolean, 'foo') 135 | ) 136 | 137 | expect(decoder('baz')).toEqual('baz'); 138 | expect(() => decoder('foo')).toThrow(); 139 | }) 140 | 141 | test('same props intersection', () => { 142 | type decoderType = decodeType; 143 | const decoder = intersection( 144 | record({a: string}), 145 | record({a: field('a', a => string(a).toUpperCase())}), 146 | ); 147 | 148 | expect(decoder({a: 'FOO'})).toEqual({a: 'FOO'}); 149 | expect(() => decoder({a: 'Foo'})).toThrow(); 150 | }) 151 | 152 | test('deep intersection', () => { 153 | type decoderType = decodeType; 154 | const decoder = intersection( 155 | record({a: string, b: {c: number, d: boolean}, e: {}}), 156 | record({b: {d: boolean, f: nil}, g: number}), 157 | ); 158 | 159 | expect(decoder({a: 'foo', b: {c: 42, d: false, f: null}, e: {}, g: 53, h: 'discard'})) 160 | .toEqual({ a: 'foo', b: { c: 42, d: false, f: null }, e: {}, g: 53 }); 161 | expect(() => decoder({a: 'foo', b: {c: 42, d: false, f: null}, e: {}, h: 'discard'})).toThrow(); 162 | }) 163 | 164 | test('decode string', () => { 165 | const l1: 'a' = 'a' as const; 166 | 167 | type literal = decodeType; 168 | const literal_decoder = decode(l1); 169 | 170 | expect(literal_decoder(l1)).toEqual(l1); 171 | expect(() => literal_decoder('b')).toThrow(); 172 | }); 173 | 174 | test('decode record', () => { 175 | const l1: {} = {} as const; 176 | 177 | type literal = decodeType; 178 | const literal_decoder = decode({}); 179 | 180 | expect(literal_decoder(l1)).toEqual(l1); 181 | expect(() => literal_decoder(null)).toThrow(); 182 | }); 183 | 184 | test('record decoder', () => { 185 | const l1: {} = {} as const; 186 | 187 | type record = decodeType; 188 | const record_decoder = record({}); 189 | 190 | expect(record_decoder(l1)).toEqual(l1); 191 | expect(() => record_decoder(null)).toThrow(); 192 | }); 193 | 194 | test('record decoder with some data', () => { 195 | const l1 = { str: 'hei', num: 85, bool: true, missing: null }; 196 | 197 | type record = decodeType; 198 | const record_decoder = record({ 199 | str: string, 200 | num: number, 201 | bool: boolean, 202 | missing: nil, 203 | }); 204 | 205 | expect(record_decoder(l1)).toEqual(l1); 206 | expect(() => record_decoder({ str: 'hei' })).toThrow(); 207 | }); 208 | 209 | test('record decoder with nested literal data', () => { 210 | const l1 = { str: 'hei', num: 85, rec: { str: 'dete', data: 'data' } }; 211 | 212 | type record = decodeType; 213 | const record_decoder = record({ 214 | str: string, 215 | num: number, 216 | rec: { 217 | str: string, 218 | data: 'data', 219 | }, 220 | }); 221 | 222 | expect(record_decoder(l1)).toEqual(l1); 223 | expect(() => 224 | record_decoder({ 225 | str: 'hei', 226 | num: 85, 227 | rec: { str: 'dete', data: 'wrong string' }, 228 | }), 229 | ).toThrow(); 230 | }); 231 | 232 | test('field decoder', () => { 233 | const data = 'data'; 234 | const l1 = { f: data }; 235 | 236 | type record = decodeType; 237 | const record_decoder = record({ 238 | f: field('f', string), 239 | }); 240 | 241 | expect(record_decoder(l1)).toEqual(l1); 242 | expect(() => record_decoder({ g: 'data' })).toThrow(); 243 | }); 244 | 245 | test('field decoder: rename', () => { 246 | const data = 'data'; 247 | const l1 = { f: data }; 248 | 249 | type record = decodeType; 250 | const record_decoder = record({ 251 | g: field('f', string), 252 | }); 253 | 254 | expect(record_decoder(l1)).toEqual({ g: data }); 255 | expect(() => record_decoder({ g: 'data' })).toThrow(); 256 | }); 257 | 258 | test('field decoder: nested', () => { 259 | const data = 'data'; 260 | const l1 = { data: { f: data } }; 261 | 262 | type record = decodeType; 263 | const record_decoder = record({ 264 | data: { f: field('f', string) }, 265 | }); 266 | 267 | expect(record_decoder(l1)).toEqual(l1); 268 | expect(() => record_decoder({ g: 'data' })).toThrow(); 269 | }); 270 | 271 | test('fields decoder', () => { 272 | const data = 'data'; 273 | const l1 = { f: data, g: 0 }; 274 | 275 | type record = decodeType; 276 | const record_decoder = record({ 277 | f: string, 278 | g: number, 279 | h: fields({ f: string, g: number }, ({ f, g }) => f + g), 280 | }); 281 | 282 | const result = { ...l1, h: l1.f + l1.g }; 283 | 284 | expect(record_decoder(l1)).toEqual(result); 285 | expect( 286 | record_decoder({ ...l1, h: 'this key will be ignored' }), 287 | ).toEqual(result); 288 | }); 289 | 290 | test('union decoder', () => { 291 | const l1 = 'test data'; 292 | 293 | type union = decodeType; 294 | const union_decoder = union(string, number); 295 | 296 | expect(union_decoder(l1)).toEqual(l1); 297 | expect(union_decoder(34)).toEqual(34); 298 | expect(() => union_decoder(true)).toThrow(); 299 | }); 300 | 301 | test('multi union decoder', () => { 302 | const data = 'test data'; 303 | 304 | type union = decodeType; 305 | const union_decoder = union( 306 | string, 307 | number, 308 | boolean, 309 | undef, 310 | nil, 311 | tuple(string, string), 312 | record({ data: string }), 313 | ); 314 | 315 | expect(union_decoder(data)).toEqual(data); 316 | expect(union_decoder(34)).toEqual(34); 317 | expect(union_decoder(true)).toEqual(true); 318 | expect(union_decoder(undefined)).toEqual(undefined); 319 | expect(union_decoder(null)).toEqual(null); 320 | expect(union_decoder([data, data])).toEqual([data, data]); 321 | expect(union_decoder({ data })).toEqual({ data }); 322 | expect(() => union_decoder({ fail: '' })).toThrow(); 323 | }); 324 | 325 | test('union with default case pattern', () => { 326 | const l1 = 'test data'; 327 | 328 | type union = decodeType; 329 | const union_decoder = union(string, () => ''); 330 | 331 | expect(union_decoder('')).toEqual(''); 332 | expect(union_decoder(l1)).toEqual(l1); 333 | expect(union_decoder(null)).toEqual(''); 334 | expect(union_decoder(false)).toEqual(''); 335 | }); 336 | 337 | test('discriminated union with records', () => { 338 | const one = { discriminant: 'one' }; 339 | const two = { discriminant: 'two', data: 'stuff' }; 340 | 341 | type adt = decodeType; 342 | const adt_decoder = union( 343 | { discriminant: literal('one') }, 344 | { discriminant: literal('two'), data: string }, 345 | ); 346 | 347 | expect(adt_decoder(one)).toEqual(one); 348 | expect(adt_decoder(two)).toEqual(two); 349 | expect(() => adt_decoder({ ...two, data: undefined })).toThrow(); 350 | }); 351 | 352 | test('discriminated union with tuples', () => { 353 | const one = ['one', 1]; 354 | const two = ['two', 'stuff']; 355 | const three = ['three', { data: 'stuff' }]; 356 | 357 | type adt = decodeType; 358 | const adt_decoder = union( 359 | tuple('one', number), 360 | tuple('two', string), 361 | tuple('three', { data: string }), 362 | ); 363 | 364 | expect(adt_decoder(one)).toEqual(one); 365 | expect(adt_decoder(two)).toEqual(two); 366 | expect(adt_decoder(three)).toEqual(three); 367 | expect(() => adt_decoder(['three', { data: undefined }])).toThrow(); 368 | }); 369 | 370 | test('optional string decoder', () => { 371 | const l1 = 'test data'; 372 | 373 | type optional = decodeType; 374 | const optional_decoder = optional(string); 375 | 376 | expect(optional_decoder('')).toEqual(''); 377 | expect(optional_decoder(l1)).toEqual(l1); 378 | expect(optional_decoder(undefined)).toEqual(undefined); 379 | expect(() => optional_decoder(null)).toThrow(); 380 | 381 | const data = { optional_decoder: l1 }; 382 | expect(record({ optional_decoder })(data)).toEqual(data); 383 | expect(record({ optional_decoder })({})).toEqual({ 384 | optional_decoder: undefined, 385 | }); 386 | }); 387 | 388 | test('array decoder', () => { 389 | const l1 = 'test data'; 390 | 391 | type array = decodeType; 392 | const array_decoder = array(string); 393 | 394 | expect(array_decoder([])).toEqual([]); 395 | expect(array_decoder([l1])).toEqual([l1]); 396 | expect(array_decoder([l1, ''])).toEqual([l1, '']); 397 | expect(() => array_decoder('')).toThrow(); 398 | expect(() => array_decoder([l1, true])).toThrow(); 399 | expect(() => array_decoder([l1, undefined])).toThrow(); 400 | expect(() => array_decoder([[]])).toThrow(); 401 | expect(() => array_decoder(null)).toThrow(); 402 | expect(() => array_decoder({})).toThrow(); 403 | }); 404 | 405 | test('set decoder', () => { 406 | const l1 = 'test data'; 407 | 408 | type set = decodeType; 409 | const set_decoder = set(string); 410 | 411 | expect(set_decoder([])).toEqual(new Set()); 412 | expect(set_decoder([l1])).toEqual(new Set([l1])); 413 | expect(set_decoder([l1, ''])).toEqual(new Set(['', l1])); 414 | expect(() => set_decoder([l1, true])).toThrow(); 415 | expect(() => set_decoder([l1, undefined])).toThrow(); 416 | expect(() => set_decoder([[]])).toThrow(); 417 | expect(() => set_decoder(null)).toThrow(); 418 | expect(() => set_decoder({})).toThrow(); 419 | }); 420 | 421 | test('map (list-of-records) decoder', () => { 422 | const l1 = [ 423 | { data: 'one', meta_data: 1 }, 424 | { data: 'two', meta_data: 2 }, 425 | ]; 426 | 427 | type map = decodeType; 428 | const map_decoder = map( 429 | { data: string, meta_data: number }, 430 | ({ data }) => data, 431 | ); 432 | 433 | expect(map_decoder([])).toEqual(new Map()); 434 | expect(map_decoder(l1)).toEqual(new Map(l1.map((x) => [x.data, x]))); 435 | }); 436 | 437 | test('map (list-of-strings) decoder', () => { 438 | const l1 = ['one thing', 'and another']; 439 | 440 | type map = decodeType; 441 | const map_decoder = map(string, (s) => s.length); 442 | 443 | expect(map_decoder([])).toEqual(new Map()); 444 | expect(map_decoder(l1)).toEqual(new Map(l1.map((x) => [x.length, x]))); 445 | }); 446 | 447 | test('dict decoder', () => { 448 | const l1 = { one: 1, two: 2 }; 449 | 450 | type dict = decodeType; 451 | const dict_decoder = dict(number); 452 | 453 | expect(dict_decoder({})).toEqual(new Map()); 454 | expect(dict_decoder(l1)).toEqual( 455 | new Map([ 456 | ['one', 1], 457 | ['two', 2], 458 | ]), 459 | ); 460 | }); 461 | 462 | test('dict decoder with typed key', () => { 463 | const l1 = { small: true, medium: false }; 464 | const l2 = { xlarge: true, small: false }; 465 | 466 | const SizeValues = ['small', 'medium', 'large'] as const; 467 | 468 | type dict_with_typed_keys = decodeType; 469 | const dict_with_typed_keys_decoder = dict(boolean, SizeValues); 470 | 471 | expect(dict_with_typed_keys_decoder({})).toEqual( 472 | new Map(), 473 | ); 474 | expect(dict_with_typed_keys_decoder(l1)).toEqual( 475 | new Map([ 476 | ['small', true], 477 | ['medium', false], 478 | ]), 479 | ); 480 | expect(() => dict_with_typed_keys_decoder(l2)).toThrow(); 481 | }); 482 | 483 | test('nullable decoder', () => { 484 | type nullable = decodeType; 485 | const nullable_decoder = nullable(boolean); 486 | 487 | expect(nullable_decoder(true)).toEqual(true); 488 | expect(nullable_decoder(false)).toEqual(false); 489 | expect(nullable_decoder(null)).toEqual(null); 490 | expect(() => nullable_decoder(undefined)).toThrow(); 491 | expect(() => nullable_decoder('')).toThrow(); 492 | expect(() => nullable_decoder([])).toThrow(); 493 | expect(() => nullable_decoder({})).toThrow(); 494 | }); 495 | 496 | test('string decoder', () => { 497 | type primitve_type = decodeType; 498 | const decoder = string; 499 | 500 | expect(decoder('')).toEqual(''); 501 | expect(decoder('test data')).toEqual('test data'); 502 | expect(() => decoder(0)).toThrow(); 503 | expect(() => decoder(false)).toThrow(); 504 | expect(() => decoder(undefined)).toThrow(); 505 | expect(() => decoder(null)).toThrow(); 506 | expect(() => decoder([])).toThrow(); 507 | expect(() => decoder({})).toThrow(); 508 | }); 509 | 510 | test('number decoder', () => { 511 | type primitve_type = decodeType; 512 | const decoder = number; 513 | 514 | expect(decoder(0)).toEqual(0); 515 | expect(decoder(1)).toEqual(1); 516 | expect(decoder(-1)).toEqual(-1); 517 | expect(decoder(Infinity)).toEqual(Infinity); 518 | expect(decoder(-Infinity)).toEqual(-Infinity); 519 | expect(decoder(NaN)).toEqual(NaN); 520 | expect(() => decoder('')).toThrow(); 521 | expect(() => decoder('0')).toThrow(); 522 | expect(() => decoder('1')).toThrow(); 523 | expect(() => decoder(false)).toThrow(); 524 | expect(() => decoder(undefined)).toThrow(); 525 | expect(() => decoder(null)).toThrow(); 526 | expect(() => decoder([])).toThrow(); 527 | expect(() => decoder({})).toThrow(); 528 | }); 529 | 530 | test('boolean decoder', () => { 531 | type primitve_type = decodeType; 532 | const decoder = boolean; 533 | 534 | expect(decoder(true)).toEqual(true); 535 | expect(decoder(false)).toEqual(false); 536 | expect(() => decoder('')).toThrow(); 537 | expect(() => decoder(0)).toThrow(); 538 | expect(() => decoder(undefined)).toThrow(); 539 | expect(() => decoder(null)).toThrow(); 540 | expect(() => decoder([])).toThrow(); 541 | expect(() => decoder({})).toThrow(); 542 | }); 543 | 544 | test('undefined decoder', () => { 545 | type primitve_type = decodeType; 546 | const decoder = undef; 547 | 548 | expect(decoder(undefined)).toEqual(undefined); 549 | expect(() => decoder('')).toThrow(); 550 | expect(() => decoder(0)).toThrow(); 551 | expect(() => decoder(true)).toThrow(); 552 | expect(() => decoder(null)).toThrow(); 553 | expect(() => decoder([])).toThrow(); 554 | expect(() => decoder({})).toThrow(); 555 | }); 556 | 557 | test('null decoder', () => { 558 | type primitve_type = decodeType; 559 | const decoder = nil; 560 | 561 | expect(decoder(null)).toEqual(null); 562 | expect(() => decoder('')).toThrow(); 563 | expect(() => decoder(0)).toThrow(); 564 | expect(() => decoder(true)).toThrow(); 565 | expect(() => decoder(undefined)).toThrow(); 566 | expect(() => decoder([])).toThrow(); 567 | expect(() => decoder({})).toThrow(); 568 | }); 569 | 570 | test('date decoder', () => { 571 | type primitve_type = decodeType; 572 | const decoder = date; 573 | 574 | expect(decoder('2020-02-20')).toEqual(new Date('2020-02-20')); 575 | expect(decoder('2020-02-20T23:59')).toEqual( 576 | new Date('2020-02-20T23:59'), 577 | ); 578 | expect(decoder('2222')).toEqual(new Date('2222-01-01')); 579 | expect(() => decoder('')).toThrow(); 580 | expect(() => decoder(0)).toThrow(); 581 | expect(() => decoder(true)).toThrow(); 582 | expect(() => decoder(null)).toThrow(); 583 | expect(() => decoder(undefined)).toThrow(); 584 | expect(() => decoder([])).toThrow(); 585 | expect(() => decoder({})).toThrow(); 586 | }); 587 | 588 | test('intersection fails to override properties', () => { 589 | const test_value = { a: 'test' }; 590 | 591 | type intersection = decodeType; 592 | const intersect_decoder = intersection({ a: number }, { a: string }); 593 | 594 | // expect(intersect_decoder(test_value)).toEqual(test_value); 595 | expect(() => intersect_decoder({ a: '0' })).toThrow(); 596 | expect(() => intersect_decoder({ a: 1 })).toThrow(); 597 | }); 598 | 599 | test('intersection of objects cumulates fields', () => { 600 | const test_value = { a: 'test', b: 1 }; 601 | 602 | type intersection = decodeType; 603 | const intersect_decoder = intersection({ a: string }, { a: string, b: number }); 604 | 605 | expect(intersect_decoder(test_value)).toEqual(test_value); 606 | expect(() => intersect_decoder({ a: '' })).toThrow() 607 | expect(() => intersect_decoder({ b: 0 })).toThrow() 608 | }); 609 | 610 | test('intersection of objects is mixins/multi-inheritance', () => { 611 | const test_value = { a: 'test', b: 1 }; 612 | 613 | type intersection = decodeType; 614 | const intersect_decoder = intersection({ a: string }, { b: number }); 615 | 616 | expect(intersect_decoder(test_value)).toEqual(test_value); 617 | expect(intersect_decoder(test_value).a).toEqual(test_value.a); 618 | expect(intersect_decoder(test_value).b).toEqual(test_value.b); 619 | expect(() => intersect_decoder({ a: '' })).toThrow() 620 | expect(() => intersect_decoder({ b: 0 })).toThrow() 621 | }); 622 | 623 | test('intersection of empty object', () => { 624 | const test_value = { a: 'test' }; 625 | 626 | type intersection = decodeType; 627 | const intersect_decoder = intersection({}, { a: string }); 628 | 629 | expect(intersect_decoder(test_value)).toEqual(test_value); 630 | expect(intersect_decoder(test_value).a).toEqual(test_value.a); 631 | expect(intersect_decoder({ a: '' })).toEqual({ a: '' }) 632 | expect(() => intersect_decoder({ a: 0 })).toThrow() 633 | expect(() => intersect_decoder({ b: '' })).toThrow() 634 | 635 | }); 636 | 637 | test('intersection of arrays', () => { 638 | const test_value = [ 1, 2, 3, ] 639 | 640 | type intersection = decodeType; 641 | const intersect_decoder = intersection(array(number), array(number)); 642 | 643 | expect(intersect_decoder(test_value)).toEqual(test_value); 644 | expect(intersect_decoder([])).toEqual([]); 645 | expect(() => intersect_decoder({ a: '' })).toThrow() 646 | expect(() => intersect_decoder({ b: 0 })).toThrow() 647 | }); 648 | 649 | test('intersection of objects of objects', () => { 650 | const test_value = { x: { a: 'a', b: 2 }, y: 'false', z: true } 651 | 652 | type intersection = decodeType; 653 | const intersect_decoder = 654 | intersection({ x: { a: string }, y: string } 655 | , { x: { b: number }, y: string, z: boolean }); 656 | 657 | expect(intersect_decoder(test_value)).toEqual(test_value); 658 | expect(() => intersect_decoder({})).toThrow(); 659 | }); 660 | 661 | test('intersection of tuple and array', () => { 662 | const test_value = [ 1, 2 ] 663 | 664 | type intersection = decodeType; 665 | const intersect_decoder = intersection(tuple(number, number), array(number)); 666 | 667 | expect(intersect_decoder(test_value)).toEqual(test_value); 668 | expect(() => intersect_decoder([])).toThrow() 669 | expect(() => intersect_decoder([ 1, 2, 3, ])).toThrow() 670 | expect(() => intersect_decoder({ a: '' })).toThrow() 671 | expect(() => intersect_decoder({ b: 0 })).toThrow() 672 | }); 673 | 674 | test('intersection of compatible tuples', () => { 675 | 676 | const test_value: [ {a: string; b: number}, number] = [ { a: '1', b: 2 }, 3 ] 677 | 678 | type intersection = decodeType; 679 | const intersect_decoder = intersection([ { a: string }, number ], [ { b: number }, number ]); 680 | 681 | expect(intersect_decoder(test_value)).toEqual(test_value) 682 | expect(intersect_decoder(test_value)['0'].a).toEqual(test_value['0']['a']) 683 | expect(intersect_decoder(test_value)['0'].b).toEqual(test_value['0']['b']) 684 | expect(() => intersect_decoder([])).toThrow() 685 | expect(() => intersect_decoder([ 1, 2 ])).toThrow() 686 | expect(() => intersect_decoder([ '1', 2 ])).toThrow() 687 | expect(() => intersect_decoder('')).toThrow() 688 | expect(() => intersect_decoder(0)).toThrow() 689 | }); 690 | 691 | test('intersection of incompatible tuples', () => { 692 | 693 | const intersect_decoder = intersection(tuple(number, number), tuple(string, number)); 694 | 695 | expect(() => intersect_decoder([])).toThrow() 696 | expect(() => intersect_decoder([ 1, 2 ])).toThrow() 697 | expect(() => intersect_decoder([ '1', 2 ])).toThrow() 698 | expect(() => intersect_decoder('')).toThrow() 699 | expect(() => intersect_decoder(0)).toThrow() 700 | }); 701 | 702 | test('intersection with incompatible tuple lengths', () => { 703 | 704 | const tupleA: Decoder<[number, number]> = (x => { 705 | const arr = array(number)(x); 706 | return [arr[0], arr[1], ]; 707 | }); 708 | const tupleB: Decoder<[number, number, number]> = (x => { 709 | const arr = array(number)(x); 710 | return [arr[0], arr[1], arr[2], ]; 711 | }); 712 | const intersect_decoder = intersection(tupleA, tupleB); 713 | 714 | expect(() => intersect_decoder([])).toThrow() 715 | expect(() => intersect_decoder([ 1, 2 ])).toThrow() 716 | expect(() => intersect_decoder([ 1, 2, 3 ])).toThrow() 717 | }); 718 | 719 | test('intersection of primitives and object fail', () => { 720 | 721 | const intersect_decoder = intersection(number, { a: string }); 722 | 723 | expect(() => intersect_decoder([])).toThrow() 724 | expect(() => intersect_decoder({})).toThrow() 725 | expect(() => intersect_decoder({ a: '' })).toThrow() 726 | expect(() => intersect_decoder(1)).toThrow() 727 | }); 728 | 729 | test('intersection of arrays produces array', () => { 730 | const test_value = [ 1, 2, 3, ]; 731 | type intersection = decodeType; 732 | const intersect_decoder = intersection(array(number), array(number)); 733 | expect(intersect_decoder(test_value).toString()).toEqual(test_value.toString()); 734 | }) 735 | 736 | 737 | test('intersection with null', () => { 738 | type intersection = decodeType; 739 | const intersect = intersection(nullable(number), nullable(number)); 740 | 741 | expect(intersect(null)).toEqual(null); 742 | expect(intersect(5)).toEqual(5); 743 | expect(() => intersect(undefined)).toThrow; 744 | }) 745 | 746 | test('intersection with undefined', () => { 747 | type intersection = decodeType; 748 | const intersect = intersection(optional(number), optional(number)); 749 | 750 | expect(intersect(undefined)).toEqual(undefined); 751 | expect(intersect(5)).toEqual(5); 752 | expect(() => intersect(null)).toThrow; 753 | }) 754 | 755 | test('no intersection for map, set, custom classes', () => { 756 | const test_value1 = [1, 2, 3, ]; 757 | const test_value2 = 'test'; 758 | 759 | type intersection = decodeType; 760 | const intersect = (result: any) => intersection(_ => result, _ => result); 761 | 762 | class A extends Map {} 763 | class B {} 764 | 765 | expect(intersect(test_value1)(null)).toEqual(test_value1); 766 | expect(intersect(test_value2)(null)).toEqual(test_value2); 767 | expect(() => intersect(new A())(null)).toThrow(); 768 | expect(() => intersect(new B())(null)).toThrow(); 769 | expect(() => intersect(new Set())(null)).toThrow(); 770 | }) 771 | --------------------------------------------------------------------------------