From 322a21bc087489a213db7077fb8d4ac7df0a0aa5 Mon Sep 17 00:00:00 2001 From: Vitaly Budovski Date: Wed, 24 Jul 2024 20:55:13 +1000 Subject: [PATCH 1/2] feature: Make strict the default object validator --- .../docs/reference/Collections/object.mdx | 83 ++++++++++++++----- paseri-lib/src/schemas/chain.test.ts | 11 ++- paseri-lib/src/schemas/object.test.ts | 48 ++++++----- paseri-lib/src/schemas/object.ts | 7 +- 4 files changed, 103 insertions(+), 46 deletions(-) diff --git a/paseri-docs/src/content/docs/reference/Collections/object.mdx b/paseri-docs/src/content/docs/reference/Collections/object.mdx index 7970aff..cba34e2 100644 --- a/paseri-docs/src/content/docs/reference/Collections/object.mdx +++ b/paseri-docs/src/content/docs/reference/Collections/object.mdx @@ -4,8 +4,8 @@ sidebar: order: 23 --- -By default, the `object` schema will strip any unrecognised keys during parsing. This behaviour can be changed with -`strict` and `passthrough`. +By default, the `object` schema will raise errors if it comes across unrecognised keys during parsing. This behaviour +can be changed with `strip` and `passthrough`. ```typescript import * as p from '@vbudovski/paseri'; @@ -21,8 +21,8 @@ const data = { }; const result = schema.safeParse(data); -if (result.ok) { - // result.value typed as `{foo: string; bar: number}`. +if (!result.ok) { + // result.issue flags `other` as unrecognised. } ``` @@ -30,34 +30,77 @@ if (result.ok) { ### `strict` -If we were to modify the schema above to be: -```typescript -const schema = p.object({ - foo: p.string(), - bar: p.number(), -}).strict(); -``` -then an error would be raised during validation: +This is the default behaviour. Errors are raised for any unrecognised keys in the data. + ```typescript +import * as p from '@vbudovski/paseri'; + +const schema = p + .object({ + foo: p.string(), + bar: p.number(), + }) + .strict(); +const data = { + foo: 'baz', + bar: 123, + other: 'something' +}; + const result = schema.safeParse(data); if (!result.ok) { // result.issue flags `other` as unrecognised. } ``` -### `passthrough` +### `strip` + +Unrecognised keys are stripped from the parsed value. -If we were to modify the schema above to be: ```typescript -const schema = p.object({ - foo: p.string(), - bar: p.number(), -}).passthrough(); +import * as p from '@vbudovski/paseri'; + +const schema = p + .object({ + foo: p.string(), + bar: p.number(), + }) + .strip(); +const data = { + foo: 'baz', + bar: 123, + other: 'something' +}; + +const result = schema.safeParse(data); +if (result.ok) { + // result.value typed as `{foo: string; bar: number}`. + // `other` is stripped from `result.value`. +} ``` -then validation would succeed: + +### `passthrough` + +Unrecognised keys are preserved in the parsed value. + ```typescript +import * as p from '@vbudovski/paseri'; + +const schema = p + .object({ + foo: p.string(), + bar: p.number(), + }) + .passthrough(); +const data = { + foo: 'baz', + bar: 123, + other: 'something' +}; + const result = schema.safeParse(data); if (result.ok) { - // result.value typed as `{foo: string; bar: number}`, but the value still contains the unrecognised key `other`. + // result.value typed as `{foo: string; bar: number}`. + // `other` is preserved in `result.value`. } ``` diff --git a/paseri-lib/src/schemas/chain.test.ts b/paseri-lib/src/schemas/chain.test.ts index 9dd412e..4bbd940 100644 --- a/paseri-lib/src/schemas/chain.test.ts +++ b/paseri-lib/src/schemas/chain.test.ts @@ -128,7 +128,7 @@ test('Chain Array to primitive', () => { }); test('Chain primitive to Object with unrecognised keys', () => { - const schema = p.string().chain(p.object({ foo: p.number(), bar: p.number() }), (value) => { + const schema = p.string().chain(p.object({ foo: p.number(), bar: p.number() }).strip(), (value) => { const [foo, bar, ...other] = value.split(','); const extra = Object.fromEntries(other.map((o) => [o, Number(o)])); @@ -151,9 +151,12 @@ test('Chain primitive to Object with unrecognised keys', () => { }); test('Chain Object with unrecognised keys to primitive', () => { - const schema = p.object({ foo: p.number(), bar: p.number() }).chain(p.string(), ({ foo, bar }) => { - return p.ok(`${numberToString(foo)},${numberToString(bar)}`); - }); + const schema = p + .object({ foo: p.number(), bar: p.number() }) + .strip() + .chain(p.string(), ({ foo, bar }) => { + return p.ok(`${numberToString(foo)},${numberToString(bar)}`); + }); fc.assert( fc.property(fc.record({ foo: fc.float(), bar: fc.float(), extra: fc.anything() }), (data) => { diff --git a/paseri-lib/src/schemas/object.test.ts b/paseri-lib/src/schemas/object.test.ts index da274f4..85c1b78 100644 --- a/paseri-lib/src/schemas/object.test.ts +++ b/paseri-lib/src/schemas/object.test.ts @@ -41,12 +41,16 @@ test('Invalid type', () => { }); test('Strip', () => { - const schema = p.object({ - foo: p.string(), - bar: p.object({ - baz: p.number(), - }), - }); + const schema = p + .object({ + foo: p.string(), + bar: p + .object({ + baz: p.number(), + }) + .strip(), + }) + .strip(); fc.assert( fc.property( @@ -70,16 +74,12 @@ test('Strip', () => { }); test('Strict', () => { - const schema = p - .object({ - foo: p.string(), - bar: p - .object({ - baz: p.number(), - }) - .strict(), - }) - .strict(); + const schema = p.object({ + foo: p.string(), + bar: p.object({ + baz: p.number(), + }), + }); fc.assert( fc.property( @@ -307,7 +307,7 @@ test('Nullable', () => { test('White-box', async (t) => { await t.step('Strip success returns undefined', () => { - const schema = p.object({ foo: p.string() }); + const schema = p.object({ foo: p.string() }).strip(); const data = Object.freeze({ foo: 'bar' }); const issueOrSuccess = schema._parse(data); @@ -315,7 +315,7 @@ test('White-box', async (t) => { }); await t.step('Strict success returns undefined', () => { - const schema = p.object({ foo: p.string() }).strict(); + const schema = p.object({ foo: p.string() }); const data = Object.freeze({ foo: 'bar' }); const issueOrSuccess = schema._parse(data); @@ -331,7 +331,7 @@ test('White-box', async (t) => { }); await t.step('Modified child returns new value', () => { - const schema = p.object({ child: p.object({ foo: p.string() }) }); + const schema = p.object({ child: p.object({ foo: p.string() }).strip() }).strip(); const data = Object.freeze({ child: { foo: 'bar', extra: 'baz' } }); const issueOrSuccess = schema._parse(data); @@ -340,15 +340,21 @@ test('White-box', async (t) => { }); test('Immutable', async (t) => { + await t.step('strip', () => { + const original = p.object({ foo: p.string() }); + const modified = original.strip(); + expect(modified).not.toBe(original); + }); + await t.step('strict', () => { const original = p.object({ foo: p.string() }); const modified = original.strict(); - expect(modified).not.toEqual(original); + expect(modified).not.toBe(original); }); await t.step('passthrough', () => { const original = p.object({ foo: p.string() }); const modified = original.passthrough(); - expect(modified).not.toEqual(original); + expect(modified).not.toBe(original); }); }); diff --git a/paseri-lib/src/schemas/object.ts b/paseri-lib/src/schemas/object.ts index 9154523..907a46a 100644 --- a/paseri-lib/src/schemas/object.ts +++ b/paseri-lib/src/schemas/object.ts @@ -14,7 +14,7 @@ type Mode = 'strip' | 'strict' | 'passthrough'; class ObjectSchema> extends Schema> { private readonly _shape: Map>; - private _mode: Mode = 'strip'; + private _mode: Mode = 'strict'; readonly issues = { INVALID_TYPE: { type: 'leaf', code: 'invalid_type' }, @@ -145,7 +145,12 @@ class ObjectSchema> extends Schema { + const cloned = this._clone(); + cloned._mode = 'strip'; + return cloned; + } strict(): ObjectSchema { const cloned = this._clone(); cloned._mode = 'strict'; From 3ea6ee502f55e6ae92c7c4fc949583d3e92a3907 Mon Sep 17 00:00:00 2001 From: Vitaly Budovski Date: Wed, 24 Jul 2024 22:17:36 +1000 Subject: [PATCH 2/2] =?UTF-8?q?doc:=20Pok=C3=A9mon=20API=20tutorial?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- paseri-docs/astro.config.mjs | 1 + .../docs/guides/tutorial-pokemon-api.mdx | 104 ++++++++++++++++++ 2 files changed, 105 insertions(+) create mode 100644 paseri-docs/src/content/docs/guides/tutorial-pokemon-api.mdx diff --git a/paseri-docs/astro.config.mjs b/paseri-docs/astro.config.mjs index 021c7d5..3d959c9 100644 --- a/paseri-docs/astro.config.mjs +++ b/paseri-docs/astro.config.mjs @@ -18,6 +18,7 @@ export default defineConfig({ // Each item here is one entry in the navigation menu. { label: 'Introduction', slug: 'guides/introduction' }, { label: 'Getting Started', slug: 'guides/getting-started' }, + { label: 'Tutorial: Pokémon API', slug: 'guides/tutorial-pokemon-api' }, ], }, { diff --git a/paseri-docs/src/content/docs/guides/tutorial-pokemon-api.mdx b/paseri-docs/src/content/docs/guides/tutorial-pokemon-api.mdx new file mode 100644 index 0000000..90791c3 --- /dev/null +++ b/paseri-docs/src/content/docs/guides/tutorial-pokemon-api.mdx @@ -0,0 +1,104 @@ +--- +title: "Tutorial: Pokémon API" +--- + +## The usual way + +Before we get into using the Paseri library, let's try to fetch some [Pokémon data](https://pokeapi.co/docs/v2#pokemon) +using a typical TypeScript approach. + +```typescript +interface Pokemon { + name: string; + height: number; + weight: number; +} + +async function getPokemon(name: string): Promise { + const response = await fetch(`https://pokeapi.co/api/v2/pokemon/${name}`); + if (!response.ok) { + throw new Error(`Failed fetching ${name}.`); + } + + return response.json(); +} + +const bulbasaur = await getPokemon('bulbasaur'); +console.log(bulbasaur.name, bulbasaur.height, bulbasaur.weight); // OK. +``` + +This isn't bad. We've asserted that the fetch response contains `Pokemon` data, so we can use it without any extra type +assertions, and with the benefit of auto-completion in the editor. In a perfect world, this might be fine, but APIs +change, and developers make mistakes. + +## The better way + +Rather than assume that the data we get from the endpoint is what we expect, we're far better off validating that it is. +We can create a schema that will guarantee that the data we receive from the endpoint has the exact structure we need, +and have it generate the TypeScript type for us! + +```typescript +import * as p from '@vbudovski/paseri'; + +const pokemonSchema = p.object({ + name: p.string(), + height: p.number(), + weight: p.number(), +}); + +interface Pokemon extends p.Infer { + // Identical to the interface we created above. +} +``` + +Now that we have our schema, we can make the `getPokemon` function validate the response. + +```typescript +async function getPokemon(name: string): Promise { + const response = await fetch(`https://pokeapi.co/api/v2/pokemon/${name}`); + if (!response.ok) { + throw new Error(`Failed fetching ${name}.`); + } + + const data = await response.json(); // `any` type. + + return pokemonSchema.parse(data); // Now it's `Pokemon` type. +} + +const bulbasaur = await getPokemon('bulbasaur'); +``` + +Run the code above. Does it work? + +:::note +Objects are validated in strict mode by default. This means that unrecognised keys will result in a parsing error, and +helps to catch unexpected changes to an API. +::: + +You'll see a long list of unrecognised keys in the error message. This tells us that our assumption about the endpoint +response is incorrect. We can fix the error by modifying our schema to include the missing keys. For now though, let's +just strip them. Update the `pokemonSchema` above to look as follows: + +```typescript +const pokemonSchema = p + .object({ + name: p.string(), + height: p.number(), + weight: p.number(), + }) + .strip(); // This will sanitise our data. + +const bulbasaur = await getPokemon('bulbasaur'); +console.log(bulbasaur); // { height: 7, name: "bulbasaur", weight: 69 } +``` + +Our data is parsed successfully, and all the other keys have been removed. We can now access the data with confidence, +knowing that the structure is exactly as we expect it to be. + +:::tip +It's generally better to parse objects in strict mode, as it catches errors that might otherwise have been missed. The +full list of options can be found in the [reference](/reference/collections/object/). +::: + +Now that you've had a taste of what Paseri can do, you can explore the full [API documentation](/reference/schema/). +Happy parsing!