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
(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 '.