Skip to content

Commit

Permalink
Merge pull request #7 from vbudovski/doc/tutorial
Browse files Browse the repository at this point in the history
Doc/tutorial
  • Loading branch information
vbudovski authored Jul 26, 2024
2 parents 580c5e7 + 3ea6ee5 commit b561009
Show file tree
Hide file tree
Showing 6 changed files with 208 additions and 46 deletions.
1 change: 1 addition & 0 deletions paseri-docs/astro.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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' },
],
},
{
Expand Down
104 changes: 104 additions & 0 deletions paseri-docs/src/content/docs/guides/tutorial-pokemon-api.mdx
Original file line number Diff line number Diff line change
@@ -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<Pokemon> {
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<typeof pokemonSchema> {
// 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<Pokemon> {
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!
83 changes: 63 additions & 20 deletions paseri-docs/src/content/docs/reference/Collections/object.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -21,43 +21,86 @@ 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.
}
```

## Validators

### `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`.
}
```
11 changes: 7 additions & 4 deletions paseri-lib/src/schemas/chain.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)]));

Expand All @@ -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) => {
Expand Down
48 changes: 27 additions & 21 deletions paseri-lib/src/schemas/object.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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(
Expand Down Expand Up @@ -307,15 +307,15 @@ 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);
expect(issueOrSuccess).toBe(undefined);
});

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);
Expand All @@ -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);
Expand All @@ -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);
});
});
7 changes: 6 additions & 1 deletion paseri-lib/src/schemas/object.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ type Mode = 'strip' | 'strict' | 'passthrough';

class ObjectSchema<ShapeType extends ValidShapeType<ShapeType>> extends Schema<Infer<ShapeType>> {
private readonly _shape: Map<string, Schema<unknown>>;
private _mode: Mode = 'strip';
private _mode: Mode = 'strict';

readonly issues = {
INVALID_TYPE: { type: 'leaf', code: 'invalid_type' },
Expand Down Expand Up @@ -145,7 +145,12 @@ class ObjectSchema<ShapeType extends ValidShapeType<ShapeType>> extends Schema<I

return undefined;
}
strip(): ObjectSchema<ShapeType> {
const cloned = this._clone();
cloned._mode = 'strip';

return cloned;
}
strict(): ObjectSchema<ShapeType> {
const cloned = this._clone();
cloned._mode = 'strict';
Expand Down

0 comments on commit b561009

Please sign in to comment.