Skip to content

Commit

Permalink
Merge pull request #27 from lydell/stringUnion
Browse files Browse the repository at this point in the history
  • Loading branch information
lydell authored Oct 14, 2023
2 parents b13230f + 2cebab4 commit 95319ab
Show file tree
Hide file tree
Showing 4 changed files with 42 additions and 86 deletions.
27 changes: 12 additions & 15 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -157,11 +157,11 @@ Here’s a summary of all decoders (with slightly simplified type annotations):
<td><code>string</code></td>
</tr>
<th><a href="#stringunion">stringUnion</a></th>
<td><pre>(mapping: {
string1: null,
string2: null,
stringN: null
}) =&gt;
<td><pre>(variants: [
"string1",
"string2",
"stringN"
]) =&gt;
Decoder&lt;
"string1"
| "string2"
Expand Down Expand Up @@ -298,26 +298,23 @@ Decodes a JSON string into a TypeScript `string`.
### stringUnion

```ts
function stringUnion<T extends Record<string, unknown>>(
mapping: T
): Decoder<keyof T>;
function stringUnion<T extends [string, ...Array<string>]>(
variants: T
): Decoder<T[number]>;
```

Decodes a set of specific JSON strings into a TypeScript union of those strings.

The `mapping` is an object where the keys are the strings you want. The keys must be strings (not numbers) and you must provide at least one key.
The `variants` is an array of the strings you want. You must provide at least one variant.

The values in the object can be anything – they don’t matter. The convention is to use `null` as values. If you already have an object with the correct keys but non-null values, then it can be handy to be able to use that object – that’s why any values are allowed. There’s an example of that in the [type inference file](examples/type-inference.test.ts).
If you have an object and want to use its keys for a string union there’s an example of that in the [type inference file](examples/type-inference.test.ts).

Example:

```ts
type Color = "green" | "red";

const colorDecoder: Decoder<Color> = stringUnion({
green: null,
red: null,
});
const colorDecoder: Decoder<Color> = stringUnion(["green", "red"]);
```

### array
Expand Down Expand Up @@ -500,7 +497,7 @@ See also the [renaming union field example](examples/renaming-union-field.test.t
### tuple
```ts
function tuple<T extends ReadonlyArray<unknown>>(
function tuple<T extends Array<unknown>>(
mapping: readonly [...{ [P in keyof T]: Decoder<T[P]> }]
): Decoder<[...T]>;
```
Expand Down
21 changes: 9 additions & 12 deletions examples/type-inference.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ test("making a type from a decoder", () => {
age: number,
active: boolean,
country: optional(string),
type: stringUnion({ user: null }),
type: stringUnion(["user"] as const),
});

// Then, let TypeScript infer the `User` type!
Expand Down Expand Up @@ -119,7 +119,7 @@ test("making a type from a decoder", () => {
age: field("age", number),
active: field("active", boolean),
country: field("country", optional(string)),
type: field("type", stringUnion({ user: null })),
type: field("type", stringUnion(["user"])),
}));

type User2 = ReturnType<typeof userDecoder2>;
Expand Down Expand Up @@ -385,28 +385,25 @@ test("making a type from an object and stringUnion", () => {
`${hex}:${text}`,
};

// An object with severity names and a corresponding color.
const SEVERITIES = {
const SEVERITIES = ["Low", "Medium", "High", "Critical"] as const;

type Severity = typeof SEVERITIES[number];

const SEVERITY_COLORS = {
Low: "007CBB",
Medium: "FFA500",
High: "E64524",
Critical: "FF0000",
} as const;
} as const satisfies Record<Severity, string>;

// Create a type from the object, for just the severity names.
type Severity = keyof typeof SEVERITIES;
expectType<TypeEqual<Severity, "Critical" | "High" | "Low" | "Medium">>(true);

// Make a decoder for the severity names out of the object.
// The values in the object passed to `stringUnion` are typically `null`,
// but this is a good use case for allowing other values (strings in this case).
const severityDecoder = stringUnion(SEVERITIES);
expectType<TypeEqual<Severity, ReturnType<typeof severityDecoder>>>(true);
expect(severityDecoder("High")).toBe("High");

// Use the object to color text.
function coloredSeverity(severity: Severity): string {
return chalk.hex(SEVERITIES[severity])(severity);
return chalk.hex(SEVERITY_COLORS[severity])(severity);
}
expect(coloredSeverity("Low")).toBe("007CBB:Low");
});
22 changes: 7 additions & 15 deletions index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,23 +37,15 @@ export function string(value: unknown): string {
return value;
}

export function stringUnion<T extends Record<string, unknown>>(
mapping: keyof T extends string
? keyof T extends never
? "stringUnion must have at least one key"
: T
: {
[P in keyof T]: P extends number
? ["stringUnion keys must be strings, not numbers", never]
: T[P];
}
): Decoder<keyof T> {
return function stringUnionDecoder(value: unknown): keyof T {
export function stringUnion<T extends [string, ...Array<string>]>(
variants: readonly [...T]
): Decoder<T[number]> {
return function stringUnionDecoder(value: unknown): T[number] {
const str = string(value);
if (!Object.prototype.hasOwnProperty.call(mapping, str)) {
if (!variants.includes(str)) {
throw new DecoderError({
tag: "unknown stringUnion variant",
knownVariants: Object.keys(mapping),
knownVariants: variants as unknown as Array<string>,
got: str,
});
}
Expand Down Expand Up @@ -242,7 +234,7 @@ export function fieldsUnion<T extends Record<string, Decoder<unknown>>>(
>;
}

export function tuple<T extends ReadonlyArray<unknown>>(
export function tuple<T extends Array<unknown>>(
mapping: readonly [...{ [P in keyof T]: Decoder<T[P]> }]
): Decoder<[...T]> {
return function tupleDecoder(value: unknown): [...T] {
Expand Down
58 changes: 14 additions & 44 deletions tests/decoders.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,11 +89,7 @@ test("string", () => {
describe("stringUnion", () => {
test("basic", () => {
type Color = ReturnType<typeof colorDecoder>;
const colorDecoder = stringUnion({
red: null,
green: null,
blue: null,
});
const colorDecoder = stringUnion(["red", "green", "blue"]);

expectType<TypeEqual<Color, "blue" | "green" | "red">>(true);

Expand All @@ -109,8 +105,8 @@ describe("stringUnion", () => {
expect(colorDecoder("blue")).toBe("blue");

expectType<Color>(colorDecoder("red"));
// @ts-expect-error Passed array instead of object.
stringUnion(["one", "two"]);
// @ts-expect-error Argument of type '{ one: null; two: null; }' is not assignable to parameter of type 'readonly string[]'.
stringUnion({ one: null, two: null });

expect(run(colorDecoder, "Red")).toMatchInlineSnapshot(`
At root:
Expand All @@ -119,47 +115,21 @@ describe("stringUnion", () => {
`);
});

test("edge case keys", () => {
const edgeCaseDecoder = stringUnion({
constructor: null,
// Specifying `__proto__` is safe here.
__proto__: null,
});
expect(edgeCaseDecoder("constructor")).toBe("constructor");
// But `__proto__` won’t work, because it’s not an “own” property for some reason.
// I haven’t been able to forbid `__proto__` using TypeScript.
// Notice how "__proto__" isn’t even in the expected keys.
expect(run(edgeCaseDecoder, "__proto__")).toMatchInlineSnapshot(`
At root:
Expected one of these variants: "constructor"
Got: "__proto__"
`);
expect(run(edgeCaseDecoder, "hasOwnProperty")).toMatchInlineSnapshot(`
At root:
Expected one of these variants: "constructor"
Got: "hasOwnProperty"
`);
});

test("empty object is not allowed", () => {
// @ts-expect-error Argument of type '{}' is not assignable to parameter of type '"stringUnion must have at least one key"'.
const emptyDecoder = stringUnion({});
// @ts-expect-error Argument of type 'string' is not assignable to parameter of type 'Record<string, null>'.
stringUnion("stringUnion must have at least one key");
expectType<TypeEqual<ReturnType<typeof emptyDecoder>, never>>(true);
test("empty array is not allowed", () => {
// @ts-expect-error Argument of type '[]' is not assignable to parameter of type 'readonly [string, ...string[]]'.
// Source has 0 element(s) but target requires 1.
const emptyDecoder = stringUnion([]);
expect(run(emptyDecoder, "test")).toMatchInlineSnapshot(`
At root:
Expected one of these variants: (none)
Got: "test"
`);
});

test("keys must be strings", () => {
// @ts-expect-error Type 'null' is not assignable to type '["stringUnion keys must be strings, not numbers", never]'.
stringUnion({ 1: null });
// @ts-expect-error Type 'null' is not assignable to type 'never'.
stringUnion({ 1: ["stringUnion keys must be strings, not numbers", null] });
const goodDecoder = stringUnion({ "1": null });
test("variants must be strings", () => {
// @ts-expect-error Type 'number' is not assignable to type 'string'.
stringUnion([1]);
const goodDecoder = stringUnion(["1"]);
expectType<TypeEqual<ReturnType<typeof goodDecoder>, "1">>(true);
expect(goodDecoder("1")).toBe("1");
});
Expand All @@ -168,7 +138,7 @@ describe("stringUnion", () => {
describe("array", () => {
test("basic", () => {
type Bits = ReturnType<typeof bitsDecoder>;
const bitsDecoder = array(stringUnion({ "0": null, "1": null }));
const bitsDecoder = array(stringUnion(["0", "1"]));

expectType<TypeEqual<Bits, Array<"0" | "1">>>(true);
expectType<Bits>(bitsDecoder([]));
Expand Down Expand Up @@ -204,7 +174,7 @@ describe("array", () => {
describe("record", () => {
test("basic", () => {
type Registers = ReturnType<typeof registersDecoder>;
const registersDecoder = record(stringUnion({ "0": null, "1": null }));
const registersDecoder = record(stringUnion(["0", "1"]));

expectType<TypeEqual<Registers, Record<string, "0" | "1">>>(true);
expectType<Registers>(registersDecoder({}));
Expand Down Expand Up @@ -703,7 +673,7 @@ describe("fieldsUnion", () => {
});

test("keys must be strings", () => {
const innerDecoder = fieldsAuto({ tag: stringUnion({ "1": null }) });
const innerDecoder = fieldsAuto({ tag: stringUnion(["1"] as const) });
// @ts-expect-error Type 'Decoder<{ 1: string; }, unknown>' is not assignable to type '"fieldsUnion keys must be strings, not numbers"'.
fieldsUnion("tag", { 1: innerDecoder });
// @ts-expect-error Type 'string' is not assignable to type 'Decoder<unknown, unknown>'.
Expand Down

0 comments on commit 95319ab

Please sign in to comment.