diff --git a/CHANGELOG.md b/CHANGELOG.md index a45fa51a..608c076e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ ## [Unreleased] +**New decoders:** + +- `startsWith(prefix)` +- `endsWith(suffix)` + ## [2.4.2] - 2024-06-30 - Fix a regression in `taggedUnion` (thanks for reporting, @programever) diff --git a/docs/_data.py b/docs/_data.py index 7e4f5ebb..8631612e 100644 --- a/docs/_data.py +++ b/docs/_data.py @@ -65,6 +65,46 @@ """, }, + 'startsWith': { + 'section': 'Strings', + 'params': [ + ('prefix', 'P'), + ], + 'return_type': 'Decoder<\\`${P}${string}\\`>', + 'example': """ + const decoder = startsWith('abc'); + + // 👍 + decoder.verify('abc') === 'abc'; + decoder.verify('abcdefg') === 'abcdefg'; + + // 👎 + decoder.verify(42); // throws + decoder.verify('ab'); // throws + decoder.verify('ABC'); // throws + """, + }, + + 'endsWith': { + 'section': 'Strings', + 'params': [ + ('suffix', 'S'), + ], + 'return_type': 'Decoder<\\`${string}${S}\\`>', + 'example': """ + const decoder = endsWith('bar'); + + // 👍 + decoder.verify('bar') === 'bar'; + decoder.verify('foobar') === 'foobar'; + + // 👎 + decoder.verify(42); // throws + decoder.verify('Bar'); // throws + decoder.verify('bark'); // throws + """, + }, + 'decimal': { 'section': 'Strings', 'return_type': 'Decoder', diff --git a/docs/api.md b/docs/api.md index f7559ece..ee0dfba1 100644 --- a/docs/api.md +++ b/docs/api.md @@ -28,7 +28,7 @@ All "batteries included" decoders available in the standard library. for section, names in DECODERS_BY_SECTION.items(): cog.outl(f'- [**{section}**](#{slugify(section)}): {", ".join(ref(name) for name in names)}') ]]]--> -- [**Strings**](#strings): [`string`](/api.html#string), [`nonEmptyString`](/api.html#nonEmptyString), [`regex()`](/api.html#regex), [`decimal`](/api.html#decimal), [`hexadecimal`](/api.html#hexadecimal), [`numeric`](/api.html#numeric), [`email`](/api.html#email), [`url`](/api.html#url), [`httpsUrl`](/api.html#httpsUrl), [`identifier`](/api.html#identifier), [`nanoid`](/api.html#nanoid), [`uuid`](/api.html#uuid), [`uuidv1`](/api.html#uuidv1), [`uuidv4`](/api.html#uuidv4) +- [**Strings**](#strings): [`string`](/api.html#string), [`nonEmptyString`](/api.html#nonEmptyString), [`regex()`](/api.html#regex), [`startsWith()`](/api.html#startsWith), [`endsWith()`](/api.html#endsWith), [`decimal`](/api.html#decimal), [`hexadecimal`](/api.html#hexadecimal), [`numeric`](/api.html#numeric), [`email`](/api.html#email), [`url`](/api.html#url), [`httpsUrl`](/api.html#httpsUrl), [`identifier`](/api.html#identifier), [`nanoid`](/api.html#nanoid), [`uuid`](/api.html#uuid), [`uuidv1`](/api.html#uuidv1), [`uuidv4`](/api.html#uuidv4) - [**Numbers**](#numbers): [`number`](/api.html#number), [`integer`](/api.html#integer), [`positiveNumber`](/api.html#positiveNumber), [`positiveInteger`](/api.html#positiveInteger), [`anyNumber`](/api.html#anyNumber), [`bigint`](/api.html#bigint) - [**Booleans**](#booleans): [`boolean`](/api.html#boolean), [`truthy`](/api.html#truthy) - [**Dates**](#dates): [`date`](/api.html#date), [`iso8601`](/api.html#iso8601), [`datelike`](/api.html#datelike) @@ -40,7 +40,7 @@ for section, names in DECODERS_BY_SECTION.items(): - [**JSON values**](#json-values): [`json`](/api.html#json), [`jsonObject`](/api.html#jsonObject), [`jsonArray`](/api.html#jsonArray) - [**Unions**](#unions): [`either()`](/api.html#either), [`oneOf()`](/api.html#oneOf), [`enum_()`](/api.html#enum_), [`taggedUnion()`](/api.html#taggedUnion), [`select()`](/api.html#select) - [**Utilities**](#utilities): [`define()`](/api.html#define), [`prep()`](/api.html#prep), [`never`](/api.html#never), [`instanceOf()`](/api.html#instanceOf), [`lazy()`](/api.html#lazy), [`fail`](/api.html#fail) - + + diff --git a/src/index.ts b/src/index.ts index 516d28df..50e25dc9 100644 --- a/src/index.ts +++ b/src/index.ts @@ -12,7 +12,7 @@ export { instanceOf, lazy, prep } from './misc'; export { anyNumber, integer, number, positiveInteger, positiveNumber } from './numbers'; export { bigint } from './numbers'; export { exact, inexact, object, pojo } from './objects'; -export { nonEmptyString, regex, string } from './strings'; +export { endsWith, nonEmptyString, regex, startsWith, string } from './strings'; export { identifier, nanoid, uuid, uuidv1, uuidv4 } from './strings'; export { email, httpsUrl, url } from './strings'; export { decimal, hexadecimal, numeric } from './strings'; diff --git a/src/strings.ts b/src/strings.ts index f7bbe565..ffa37b35 100644 --- a/src/strings.ts +++ b/src/strings.ts @@ -36,6 +36,26 @@ export function regex(regex: RegExp, msg: string): Decoder { return string.refine((s) => regex.test(s), msg); } +/** + * Accepts and returns strings that start with the given prefix. + */ +export function startsWith

(prefix: P): Decoder<`${P}${string}`> { + return string.refine( + (s): s is `${P}${string}` => s.startsWith(prefix), + `Must start with '${prefix}'`, + ); +} + +/** + * Accepts and returns strings that end with the given suffix. + */ +export function endsWith(suffix: S): Decoder<`${string}${S}`> { + return string.refine( + (s): s is `${string}${S}` => s.endsWith(suffix), + `Must end with '${suffix}'`, + ); +} + /** * Accepts and returns strings that are syntactically valid email addresses. * (This will not mean that the email address actually exist.) diff --git a/test-d/inference.test-d.ts b/test-d/inference.test-d.ts index 897e0759..e59ed4ab 100644 --- a/test-d/inference.test-d.ts +++ b/test-d/inference.test-d.ts @@ -14,6 +14,7 @@ import { dict, either, email, + endsWith, enum_, exact, fail, @@ -54,6 +55,7 @@ import { select, set, setFromArray, + startsWith, string, taggedUnion, truthy, @@ -122,6 +124,8 @@ expectType(test(decimal)); expectType(test(hexadecimal)); expectType(test(email)); expectType(test(regex(/foo/, 'Must be foo'))); +expectType<`foo-${string}`>(test(startsWith('foo-'))); +expectType<`${string}-bar`>(test(endsWith('-bar'))); expectType(test(url)); expectType(test(httpsUrl)); expectType(test(identifier)); diff --git a/test/strings.test.ts b/test/strings.test.ts index 8364217c..eaed71e3 100644 --- a/test/strings.test.ts +++ b/test/strings.test.ts @@ -5,6 +5,7 @@ import { describe, expect, test } from 'vitest'; import { decimal, email, + endsWith, hexadecimal, httpsUrl, identifier, @@ -12,6 +13,7 @@ import { nonEmptyString, numeric, regex, + startsWith, string, url, uuid, @@ -58,6 +60,43 @@ describe('regex', () => { }); }); +describe('startsWith', () => { + const decoder = startsWith('foo'); + + test('valid', () => { + expect(decoder.verify('foo')).toBe('foo'); + expect(decoder.verify('fooooooo')).toBe('fooooooo'); + expect(decoder.verify('foo bar')).toBe('foo bar'); + }); + + test('invalid', () => { + expect(() => decoder.verify(42)).toThrow('Must be string'); + expect(() => decoder.verify('1234')).toThrow("Must start with 'foo'"); + expect(() => decoder.verify('ofoo')).toThrow("Must start with 'foo'"); + expect(() => decoder.verify('FoO')).toThrow("Must start with 'foo'"); + expect(() => decoder.verify('fo')).toThrow("Must start with 'foo'"); + }); +}); + +describe('endsWith', () => { + const decoder = endsWith('bar'); + + test('valid', () => { + expect(decoder.verify('bar')).toBe('bar'); + expect(decoder.verify('obar')).toBe('obar'); + expect(decoder.verify('bababar')).toBe('bababar'); + expect(decoder.verify('foo bar')).toBe('foo bar'); + }); + + test('invalid', () => { + expect(() => decoder.verify(42)).toThrow('Must be string'); + expect(() => decoder.verify('1234')).toThrow("Must end with 'bar'"); + expect(() => decoder.verify('baro')).toThrow("Must end with 'bar'"); + expect(() => decoder.verify('arb')).toThrow("Must end with 'bar'"); + expect(() => decoder.verify('BaR')).toThrow("Must end with 'bar'"); + }); +}); + describe('email', () => { const decoder = email;