diff --git a/README.md b/README.md index 74e5170..153f683 100644 --- a/README.md +++ b/README.md @@ -157,11 +157,11 @@ Here’s a summary of all decoders (with slightly simplified type annotations): string stringUnion -
(mapping: {
-  string1: null,
-  string2: null,
-  stringN: null
-}) =>
+
(variants: [
+  "string1",
+  "string2",
+  "stringN"
+]) =>
   Decoder<
     "string1"
     | "string2"
@@ -298,26 +298,23 @@ Decodes a JSON string into a TypeScript `string`.
 ### stringUnion
 
 ```ts
-function stringUnion>(
-  mapping: T
-): Decoder;
+function stringUnion]>(
+  variants: T
+): Decoder;
 ```
 
 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 = stringUnion({
-  green: null,
-  red: null,
-});
+const colorDecoder: Decoder = stringUnion(["green", "red"]);
 ```
 
 ### array
@@ -500,7 +497,7 @@ See also the [renaming union field example](examples/renaming-union-field.test.t
 ### tuple
 
 ```ts
-function tuple>(
+function tuple>(
   mapping: readonly [...{ [P in keyof T]: Decoder }]
 ): Decoder<[...T]>;
 ```
diff --git a/examples/type-inference.test.ts b/examples/type-inference.test.ts
index a03a6a5..a10067f 100644
--- a/examples/type-inference.test.ts
+++ b/examples/type-inference.test.ts
@@ -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!
@@ -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;
@@ -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;
 
-  // Create a type from the object, for just the severity names.
-  type Severity = keyof typeof SEVERITIES;
   expectType>(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>>(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");
 });
diff --git a/index.ts b/index.ts
index 8d15d7f..461e50e 100644
--- a/index.ts
+++ b/index.ts
@@ -37,23 +37,15 @@ export function string(value: unknown): string {
   return value;
 }
 
-export function stringUnion>(
-  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 {
-  return function stringUnionDecoder(value: unknown): keyof T {
+export function stringUnion]>(
+  variants: readonly [...T]
+): Decoder {
+  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,
         got: str,
       });
     }
@@ -242,7 +234,7 @@ export function fieldsUnion>>(
   >;
 }
 
-export function tuple>(
+export function tuple>(
   mapping: readonly [...{ [P in keyof T]: Decoder }]
 ): Decoder<[...T]> {
   return function tupleDecoder(value: unknown): [...T] {
diff --git a/tests/decoders.test.ts b/tests/decoders.test.ts
index a5b9825..bf86005 100644
--- a/tests/decoders.test.ts
+++ b/tests/decoders.test.ts
@@ -89,11 +89,7 @@ test("string", () => {
 describe("stringUnion", () => {
   test("basic", () => {
     type Color = ReturnType;
-    const colorDecoder = stringUnion({
-      red: null,
-      green: null,
-      blue: null,
-    });
+    const colorDecoder = stringUnion(["red", "green", "blue"]);
 
     expectType>(true);
 
@@ -109,8 +105,8 @@ describe("stringUnion", () => {
     expect(colorDecoder("blue")).toBe("blue");
 
     expectType(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:
@@ -119,34 +115,10 @@ 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'.
-    stringUnion("stringUnion must have at least one key");
-    expectType, 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)
@@ -154,12 +126,10 @@ describe("stringUnion", () => {
     `);
   });
 
-  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, "1">>(true);
     expect(goodDecoder("1")).toBe("1");
   });
@@ -168,7 +138,7 @@ describe("stringUnion", () => {
 describe("array", () => {
   test("basic", () => {
     type Bits = ReturnType;
-    const bitsDecoder = array(stringUnion({ "0": null, "1": null }));
+    const bitsDecoder = array(stringUnion(["0", "1"]));
 
     expectType>>(true);
     expectType(bitsDecoder([]));
@@ -204,7 +174,7 @@ describe("array", () => {
 describe("record", () => {
   test("basic", () => {
     type Registers = ReturnType;
-    const registersDecoder = record(stringUnion({ "0": null, "1": null }));
+    const registersDecoder = record(stringUnion(["0", "1"]));
 
     expectType>>(true);
     expectType(registersDecoder({}));
@@ -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'.