Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "ts.data.json",
"version": "3.3.0",
"version": "3.4.0-beta.0",
"description": "A JSON decoding library for Typescript",
"type": "module",
"main": "./dist/cjs/index.min.js",
Expand Down
82 changes: 82 additions & 0 deletions src/schemas/template-literal.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import { describe, expect, it } from 'vitest';
import { literal } from './literal';
import { number } from './number';
import { oneOf } from './one-of';
import { string } from './string';
import { templateLiteral } from './template-literal';

describe('templateLiteral', () => {
describe('string literal', () => {
const decoder = templateLiteral<'hi'>(['hi']);
it('should succeed', () => {
expect(decoder.parse('hi')).toBe('hi');
});
it('should fail', () => {
expect(() => decoder.parse('bye')).toThrowError(
'"bye" is not exactly "hi"'
);
});
});

it('should fail to decode a number', () => {
const decoder = templateLiteral<'hi'>(['hi']);
expect(() => decoder.parse(99)).toThrowError('99 is not exactly "hi"');
});

describe('prefix.${string}.suffix', () => {
type Tpl = `prefix.${string}.suffix`;
const decoder = templateLiteral<Tpl>(['prefix.', string(), '.suffix']);
it('should succeed', () => {
expect(decoder.parse('prefix.anything.suffix')).toBe(
'prefix.anything.suffix'
);
});
it('should fail', () => {
expect(() => decoder.parse('prefix.anything')).toThrowError(
'"prefix.anything" is not exactly "prefix.?.suffix"'
);
});
});

describe(`100px via [100, 'px']`, () => {
const decoder = templateLiteral<'100px'>([100, 'px']);
it('should succeed', () => {
expect(decoder.parse('100px')).toBe('100px');
});
it('should fail providing 100rem', () => {
expect(() => decoder.parse('100rem')).toThrowError(
'"100rem" is not exactly "100px"'
);
});
it('should fail providing 99px', () => {
expect(() => decoder.parse('99px')).toThrowError(
'"99px" is not exactly "100px"'
);
});
});

describe('${number}px', () => {
it('should succeed', () => {
type Tpl = `${number}px`;
const decoder = templateLiteral<Tpl>([number(), 'px']);
expect(decoder.parse('100px')).toBe('100px');
expect(decoder.parse('1px')).toBe('1px');
});
it('should fail', () => {
type Tpl = `${number}px`;
const decoder = templateLiteral<Tpl>([number(), 'px']);
expect(() => decoder.parse(2)).toThrowError('2 is not exactly "?px"');
});
});

// FIXME: how to allow various decoders one after the other???
it('should decode template with number decoder and a oneOf decoder', () => {
type Unit = `${number}.${'px' | 'rem'}`;
const decoder = templateLiteral<Unit>([
number(),
'.',
oneOf([literal('px'), literal('rem')], 'px | rem')
]);
expect(decoder.parse('100.px')).toBe('100.px');
});
});
91 changes: 91 additions & 0 deletions src/schemas/template-literal.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
/**
* @module
* @mergeModuleWith decoders
* @category Api docs
*/

import { Decoder } from '../core';
import { exactlyError } from '../errors/exactly-error';
import * as Result from '../utils/result';

/**
* Decoder that validates a template literal.
*
* @category Utils
* @param parts Any number of string literals (e.g. "hello") and string or number decoders (e.g. JsonDecoder.string()).
* @returns A decoder that only accepts the specified value
*
* @example
* ```ts
* const pxDecoder = JsonDecoder.templateLiteral([JsonDecoder.number(), "px"]);
*
* pxDecoder.decode("99px"); // Ok<"99px">({value: "99px"})
* pxDecoder.decode(2); // Err({error: '2 is not exactly "?px"'})
* ```
*/
export function templateLiteral<const T extends string>(
parts: readonly (string | number | Decoder<any>)[]
): Decoder<T> {
return new Decoder((json: any) => {
if (typeof json !== 'string') {
const template = parts
.map(p => (p instanceof Decoder ? '?' : p))
.join('');
return Result.err(exactlyError(json, template));
}

let index = 0;
let jsonIndex = 0;

for (const part of parts) {
if (part instanceof Decoder) {
// Find the next literal part to know where this value ends
const nextLiteralIndex = parts.findIndex(
(p, i) => i > index && typeof p === 'string'
);
const endIndex =
nextLiteralIndex === -1
? json.length
: json.indexOf(parts[nextLiteralIndex] as string, jsonIndex);

if (endIndex === -1) {
const template = parts
.map(p => (p instanceof Decoder ? '?' : p))
.join('');
return Result.err(exactlyError(json, template));
}

const value = json.substring(jsonIndex, endIndex);
let result = part.decode(value);
if (!result.isOk()) {
// Try again. It might be a number() decoder
result = part.decode(parseInt(value));
}
if (!result.isOk()) {
return result;
}
jsonIndex = endIndex;
} else {
// String or number literal
const literal = String(part);
if (!json.startsWith(literal, jsonIndex)) {
const template = parts
.map(p => (p instanceof Decoder ? '?' : p))
.join('');
return Result.err(exactlyError(json, template));
}
jsonIndex += literal.length;
}
index++;
}

if (jsonIndex !== json.length) {
const template = parts
.map(p => (p instanceof Decoder ? '?' : p))
.join('');
return Result.err(exactlyError(json, template));
}

return Result.ok(json as T);
});
}