Skip to content
Open
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
4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -40,11 +40,13 @@
"dayjs": "^1.11.23",
"lit": "^2.8.0 || ^3.3.2",
"magic-snowflakes": "^7.0.2",
"tslib": "^2.8.1"
"tslib": "^2.8.1",
"typescript-cookie": "^1.0.6"
},
"devDependencies": {
"@eslint/eslintrc": "^3.3.5",
"@eslint/js": "^9.39.1",
"@internetarchive/local-cache": "1.0.0",
"@lit/localize-tools": "^0.8.2",
"@open-wc/testing-helpers": "^3.0.1",
"@typescript-eslint/eslint-plugin": "^8.46.4",
Expand Down
24 changes: 24 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

55 changes: 55 additions & 0 deletions src/services/result-type/result-type-story.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { fixture } from '@open-wc/testing-helpers';
import { describe, expect, test } from 'vitest';
import { html } from 'lit';

import type { ResultTypeStory } from './result-type-story';
import './result-type-story';

async function divide(a: string, b: string) {
const el = await fixture<ResultTypeStory>(
html`<result-type-story></result-type-story>`,
);
const root = el.shadowRoot!;
const [inputA, inputB] = Array.from(
root.querySelectorAll('input[type="text"]'),
) as HTMLInputElement[];
inputA.value = a;
inputA.dispatchEvent(new Event('input'));
inputB.value = b;
inputB.dispatchEvent(new Event('input'));
(root.querySelector('form') as HTMLFormElement).requestSubmit();
await el.updateComplete;
return {
call: root.querySelector('.call')?.textContent,
output: root.querySelector('.output')?.textContent,
};
}

describe('result-type story', () => {
test('shows no result until the form is submitted', async () => {
const el = await fixture<ResultTypeStory>(
html`<result-type-story></result-type-story>`,
);

expect(el.shadowRoot!.querySelector('.output')).to.be.null;
});

test('returns a success value', async () => {
const { call, output } = await divide('10', '4');

expect(call).to.equal('divide("10", "4")');
expect(output).to.equal('{ success: 2.5 }');
});

test('returns an error for a zero divisor', async () => {
const { output } = await divide('1', '0');

expect(output).to.equal('{ error: DivisionError("divide-by-zero") }');
});

test('returns an error for input that is not a number', async () => {
const { output } = await divide('abc', '2');

expect(output).to.equal('{ error: DivisionError("not-a-number") }');
});
});
166 changes: 166 additions & 0 deletions src/services/result-type/result-type-story.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
import { css, html, LitElement, nothing, type CSSResultGroup } from 'lit';
import { state } from 'lit/decorators.js';
import { customElement } from '@src/util/custom-element';
import interfaceSource from './result.ts?raw';

import '@demo/service-template';
import type { Result } from './result-type';

class DivisionError extends Error {
readonly kind: 'not-a-number' | 'divide-by-zero';

constructor(kind: 'not-a-number' | 'divide-by-zero') {
super(kind);
this.kind = kind;
}
}

/** The kind of function that returns a Result instead of throwing. */
function divide(a: string, b: string): Result<number, DivisionError> {
const top = Number(a);
const bottom = Number(b);
if (
a.trim() === '' ||
b.trim() === '' ||
Number.isNaN(top) ||
Number.isNaN(bottom)
) {
return { error: new DivisionError('not-a-number') };
}
if (bottom === 0) return { error: new DivisionError('divide-by-zero') };
return { success: top / bottom };
}

const USAGE = `import type { Result } from '@internetarchive/elements/services/result-type/result-type';

function divide(a: number, b: number): Result<number, DivisionError> {
if (b === 0) return { error: new DivisionError('divide-by-zero') };
return { success: a / b };
}

const { success, error } = divide(1, 0);
if (error) console.log(error.kind); // 'divide-by-zero'`;

@customElement('result-type-story')
export class ResultTypeStory extends LitElement {
@state() private a = '';

@state() private b = '';

@state() private result?: { call: string; output: string };

render() {
return html`
<service-template
serviceName="result-type"
importCode="import type { Result } from '@internetarchive/elements/services/result-type/result-type';"
.usage=${USAGE}
.apiSource=${interfaceSource.trim()}
>
<form slot="console" @submit=${this.run}>
<label>
a
<input
type="text"
name="a"
placeholder="10"
autocomplete="off"
.value=${this.a}
@input=${(e: Event) =>
(this.a = (e.target as HTMLInputElement).value)}
/>
</label>
<label>
b
<input
type="text"
name="b"
placeholder="4"
autocomplete="off"
.value=${this.b}
@input=${(e: Event) =>
(this.b = (e.target as HTMLInputElement).value)}
/>
</label>
<button type="submit">Divide</button>
<div class="result" aria-live="polite" ?hidden=${!this.result}>
${this.result
? html`<code class="call">${this.result.call}</code>
<code class="output">${this.result.output}</code>`
: nothing}
</div>
</form>
<div slot="usage-notes">
<p>
<code>Result</code> is a type, so there's nothing to call. A
function returns one instead of throwing, and the caller checks
<code>error</code> before using <code>success</code>. This page runs
a small <code>divide</code> that does that.
</p>
</div>
</service-template>
`;
}

private run(e: Event) {
e.preventDefault();
const result = divide(this.a, this.b);
this.result = {
call: `divide(${JSON.stringify(this.a)}, ${JSON.stringify(this.b)})`,
output: result.error
? `{ error: DivisionError("${result.error.kind}") }`
: `{ success: ${result.success} }`,
};
}

static get styles(): CSSResultGroup {
return css`
form {
display: flex;
flex-wrap: wrap;
align-items: flex-end;
gap: 0.5rem;
}

label {
display: flex;
flex-direction: column;
gap: 2px;
font-size: 0.8rem;
}

input[type='text'] {
min-width: 0;
width: 8rem;
max-width: 100%;
}

.result[hidden] {
display: none;
}

.result {
box-sizing: border-box;
flex-basis: 100%;
min-width: 0;
max-width: 100%;
display: flex;
flex-direction: column;
gap: 2px;
padding: 0.5rem;
background: #fff;
border: 1px solid #ccc;
font-size: 0.85rem;
}

.call,
.output {
overflow-wrap: anywhere;
}

.output {
font-weight: 600;
}
`;
}
}
1 change: 1 addition & 0 deletions src/services/result-type/result-type.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export type { Result } from './result';
29 changes: 29 additions & 0 deletions src/services/result-type/result.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { describe, expect, it } from 'vitest';
import type { Result } from './result';

describe('Result', () => {
it('can be initialized with a success value', async () => {
const result: Result<string, Error> = {
success: 'foo',
};
expect(result.success).to.equal('foo');
expect(result.error).to.equal(undefined);
});

it('can be initialized with an error', async () => {
type FooErrorType = 'networkError' | 'decodingError';
class FooError extends Error {
type?: FooErrorType;

constructor(type: FooErrorType) {
super();
this.type = type;
}
}
const result: Result<string, FooError> = {
error: new FooError('decodingError'),
};
expect(result.success).to.equal(undefined);
expect(result.error?.type).to.equal('decodingError');
});
});
18 changes: 18 additions & 0 deletions src/services/result-type/result.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
/**
* The Result is a container for a response.
*
* It contains an optional success result which is generic
* and can be anything depending on the context,
* or an Error or subclass of an error.
*
* This allows us to return rich, typed errors instead of
* an untyped Promise rejection.
*
* This is modeled after Swift's Result type:
* https://developer.apple.com/documentation/swift/result
*/
export interface Result<T, E extends Error> {
success?: T;

error?: E;
}
Loading
Loading