diff --git a/.changeset/bright-carrots-argue.md b/.changeset/bright-carrots-argue.md new file mode 100644 index 0000000000..5d0ddf1423 --- /dev/null +++ b/.changeset/bright-carrots-argue.md @@ -0,0 +1,5 @@ +--- +"effect": patch +--- + +Types: add `MatchRecord` diff --git a/.changeset/dry-spies-jog.md b/.changeset/dry-spies-jog.md new file mode 100644 index 0000000000..dbea0cca28 --- /dev/null +++ b/.changeset/dry-spies-jog.md @@ -0,0 +1,5 @@ +--- +"@effect/schema": patch +--- + +fix `pick` behavior when the input is a record diff --git a/.changeset/rich-toys-exist.md b/.changeset/rich-toys-exist.md new file mode 100644 index 0000000000..047f1f8c9b --- /dev/null +++ b/.changeset/rich-toys-exist.md @@ -0,0 +1,5 @@ +--- +"effect": patch +--- + +Struct: fix `pick` signature diff --git a/.changeset/small-beers-jam.md b/.changeset/small-beers-jam.md new file mode 100644 index 0000000000..de18715d2a --- /dev/null +++ b/.changeset/small-beers-jam.md @@ -0,0 +1,5 @@ +--- +"effect": patch +--- + +Struct: add `get` diff --git a/.changeset/smooth-pets-destroy.md b/.changeset/smooth-pets-destroy.md new file mode 100644 index 0000000000..83e03d4b66 --- /dev/null +++ b/.changeset/smooth-pets-destroy.md @@ -0,0 +1,5 @@ +--- +"effect": patch +--- + +Struct: fix `omit` signature diff --git a/packages/effect/dtslint/Struct.ts b/packages/effect/dtslint/Struct.ts index 444c8f5909..e6955ced58 100644 --- a/packages/effect/dtslint/Struct.ts +++ b/packages/effect/dtslint/Struct.ts @@ -1,8 +1,176 @@ -import { pipe } from "effect/Function" +import { hole, pipe } from "effect/Function" import * as S from "effect/Struct" +const asym = Symbol.for("effect/dtslint/a") +const bsym = Symbol.for("effect/dtslint/b") +const csym = Symbol.for("effect/dtslint/c") +const dsym = Symbol.for("effect/dtslint/d") + +declare const stringNumberRecord: Record +declare const symbolNumberRecord: Record +declare const numberNumberRecord: Record +declare const templateLiteralNumberRecord: Record<`a${string}`, number> + +const stringStruct = { a: "a", b: 1, c: true } +const symbolStruct = { [asym]: "a", [bsym]: 1, [csym]: true } +const numberStruct = { 1: "a", 2: 1, 3: true } + +// ------------------------------------------------------------------------------------- +// evolve +// ------------------------------------------------------------------------------------- + // $ExpectType { a: boolean; } S.evolve({ a: 1 }, { a: (x) => x > 0 }) // $ExpectType { a: number; b: number; } pipe({ a: "a", b: 2 }, S.evolve({ a: (s) => s.length })) + +// ------------------------------------------------------------------------------------- +// get +// ------------------------------------------------------------------------------------- + +// @ts-expect-error +pipe({}, S.get("a")) + +// $ExpectType string +pipe(stringStruct, S.get("a")) + +// $ExpectType string +S.get("a")(stringStruct) + +// $ExpectType number | undefined +pipe(stringNumberRecord, S.get("a")) + +// $ExpectType >(s: S) => MatchRecord +S.get("a") + +// $ExpectType string +pipe(symbolStruct, S.get(asym)) + +// $ExpectType string +S.get(asym)(symbolStruct) + +// $ExpectType number | undefined +pipe(symbolNumberRecord, S.get(asym)) + +// $ExpectType >(s: S) => MatchRecord +S.get(asym) + +// $ExpectType string +pipe(numberStruct, S.get(1)) + +// $ExpectType string +S.get(1)(numberStruct) + +// $ExpectType number | undefined +pipe(numberNumberRecord, S.get(1)) + +// $ExpectType >(s: S) => MatchRecord +S.get(1) + +// $ExpectType number | undefined +pipe(templateLiteralNumberRecord, S.get("ab")) + +// $ExpectType boolean +pipe(hole & { a: boolean }>(), S.get("a")) + +// @ts-expect-error +pipe(hole & { a: boolean }>(), S.get("b")) + +// ------------------------------------------------------------------------------------- +// pick +// ------------------------------------------------------------------------------------- + +// @ts-expect-error +pipe(stringStruct, S.pick("d")) + +// @ts-expect-error +S.pick("d")(stringStruct) + +// $ExpectType { [x: string]: unknown; } +S.pick("d" as string)(stringStruct) + +// $ExpectType { a: string; b: number; } +pipe(stringStruct, S.pick("a", "b")) + +// $ExpectType { a: number | undefined; b: number | undefined; } +pipe(stringNumberRecord, S.pick("a", "b")) + +// @ts-expect-error +pipe(symbolStruct, S.pick(dsym)) + +// @ts-expect-error +S.pick(dsym)(symbolStruct) + +// $ExpectType { [x: symbol]: unknown; } +S.pick(dsym as symbol)(symbolStruct) + +// $ExpectType { [asym]: string; [bsym]: number; } +pipe(symbolStruct, S.pick(asym, bsym)) + +// $ExpectType { [asym]: number | undefined; [bsym]: number | undefined; } +pipe(symbolNumberRecord, S.pick(asym, bsym)) + +// $ExpectType { 2: number; 1: string; } +pipe(numberStruct, S.pick(1, 2)) + +// @ts-expect-error +pipe(numberStruct, S.pick(4)) + +// @ts-expect-error +S.pick(4)(numberStruct) + +// $ExpectType { [x: number]: unknown; } +S.pick(4 as number)(numberStruct) + +// $ExpectType { 2: number | undefined; 1: number | undefined; } +pipe(numberNumberRecord, S.pick(1, 2)) + +// $ExpectType { ab: number | undefined; aa: number | undefined; } +pipe(templateLiteralNumberRecord, S.pick("aa", "ab")) + +// $ExpectType { a: boolean; } +pipe(hole & { a: boolean }>(), S.pick("a")) + +// @ts-expect-error +pipe(hole & { a: boolean }>(), S.pick("b")) + +// ------------------------------------------------------------------------------------- +// omit +// ------------------------------------------------------------------------------------- + +// @ts-expect-error +pipe(stringStruct, S.omit("d")) + +// @ts-expect-error +S.omit("d")(stringStruct) + +// $ExpectType { b: number; c: boolean; } +pipe(stringStruct, S.omit("a")) + +// @ts-expect-error +pipe(symbolStruct, S.omit(dsym)) + +// @ts-expect-error +S.omit(dsym)(symbolStruct) + +// $ExpectType { [bsym]: number; [csym]: boolean; } +pipe(symbolStruct, S.omit(asym)) + +// @ts-expect-error +pipe(numberStruct, S.omit(4)) + +// @ts-expect-error +S.omit(4)(numberStruct) + +// $ExpectType { 2: number; 3: boolean; } +pipe(numberStruct, S.omit(1)) + +// $ExpectType { [x: string]: number; } +pipe(stringNumberRecord, S.omit("a")) + +// $ExpectType { [x: symbol]: number; } +pipe(symbolNumberRecord, S.omit(asym)) + +// $ExpectType { [x: number]: number; } +pipe(numberNumberRecord, S.omit(1)) diff --git a/packages/effect/dtslint/Types.ts b/packages/effect/dtslint/Types.ts index 37351dfb2d..636a03e7c0 100644 --- a/packages/effect/dtslint/Types.ts +++ b/packages/effect/dtslint/Types.ts @@ -70,3 +70,13 @@ export type MutableTuple = Types.Mutable // $ExpectType { [x: string]: number; } export type MutableRecord = Types.Simplify> + +// ------------------------------------------------------------------------------------- +// MatchRecord +// ------------------------------------------------------------------------------------- + +// $ExpectType 1 +export type MatchRecord1 = Types.MatchRecord<{ [x: string]: number }, 1, 0> + +// $ExpectType 0 +export type MatchRecord2 = Types.MatchRecord<{ a: number }, 1, 0> diff --git a/packages/effect/src/Struct.ts b/packages/effect/src/Struct.ts index e0e3e354a3..0b2c0d5b17 100644 --- a/packages/effect/src/Struct.ts +++ b/packages/effect/src/Struct.ts @@ -7,7 +7,7 @@ import * as Equivalence from "./Equivalence.js" import { dual } from "./Function.js" import * as order from "./Order.js" -import type { Simplify } from "./Types.js" +import type { MatchRecord, Simplify } from "./Types.js" /** * Create a new object by picking properties of an existing object. @@ -20,13 +20,15 @@ import type { Simplify } from "./Types.js" * * @since 2.0.0 */ -export const pick = ]>( +export const pick = >( ...keys: Keys ) => -(s: S): Simplify> => { +>( + s: S +): MatchRecord => { const out: any = {} for (const k of keys) { - out[k] = s[k] + out[k] = (s as any)[k] } return out } @@ -42,10 +44,10 @@ export const pick = ]>( * * @since 2.0.0 */ -export const omit = ]>( +export const omit = >( ...keys: Keys ) => -(s: S): Simplify> => { +>(s: S): Simplify> => { const out: any = { ...s } for (const k of keys) { delete out[k] @@ -150,3 +152,19 @@ export const evolve: { return out as any } ) + +/** + * Retrieves the value associated with the specified key from a struct. + * + * @example + * import * as Struct from "effect/Struct" + * import { pipe } from "effect/Function" + * + * const value = pipe({ a: 1, b: 2 }, Struct.get("a")) + * + * assert.deepStrictEqual(value, 1) + * + * @since 2.0.0 + */ +export const get = + (key: K) => >(s: S): MatchRecord => s[key] diff --git a/packages/effect/src/Types.ts b/packages/effect/src/Types.ts index 8bef7ce47d..89838b052b 100644 --- a/packages/effect/src/Types.ts +++ b/packages/effect/src/Types.ts @@ -174,3 +174,8 @@ export type Covariant = (_: never) => A * @category models */ export type Contravariant = (_: A) => void + +/** + * @since 2.0.0 + */ +export type MatchRecord = {} extends S ? onTrue : onFalse diff --git a/packages/effect/test/Struct.test.ts b/packages/effect/test/Struct.test.ts index cf25c1fec9..ef2df3e69e 100644 --- a/packages/effect/test/Struct.test.ts +++ b/packages/effect/test/Struct.test.ts @@ -6,11 +6,13 @@ import { assert, describe, expect, it } from "vitest" describe("Struct", () => { it("exports", () => { - expect(Struct.getOrder).exist + expect(Struct.getOrder).exist // alias of order.struct, tested there }) it("pick", () => { expect(pipe({ a: "a", b: 1, c: true }, Struct.pick("a", "b"))).toEqual({ a: "a", b: 1 }) + const record: Record = {} + expect(pipe(record, Struct.pick("a", "b"))).toStrictEqual({ a: undefined, b: undefined }) }) it("omit", () => { diff --git a/packages/schema/src/AST.ts b/packages/schema/src/AST.ts index 3448133b6d..e053519fd7 100644 --- a/packages/schema/src/AST.ts +++ b/packages/schema/src/AST.ts @@ -850,7 +850,7 @@ export const createTypeLiteral = ( return { _tag: "TypeLiteral", propertySignatures: sortPropertySignatures(propertySignatures), - indexSignatures, + indexSignatures: sortIndexSignatures(indexSignatures), annotations } } @@ -1248,19 +1248,69 @@ export const appendElement = ( */ export const keyof = (ast: AST): AST => createUnion(_keyof(ast)) -/** - * @since 1.0.0 - */ -export const getPropertySignatures = ( - ast: AST -): ReadonlyArray => { +/** @internal */ +export const getTemplateLiteralRegex = (ast: TemplateLiteral): RegExp => { + let pattern = `^${ast.head}` + for (const span of ast.spans) { + if (isStringKeyword(span.type)) { + pattern += ".*" + } else if (isNumberKeyword(span.type)) { + pattern += "[+-]?\\d*\\.?\\d+(?:[Ee][+-]?\\d+)?" + } + pattern += span.literal + } + pattern += "$" + return new RegExp(pattern) +} + +const getIndexedAccess = (ast: AST, name: PropertyKey): PropertySignature => { switch (ast._tag) { - case "TypeLiteral": - return ast.propertySignatures + case "TypeLiteral": { + const ops = ReadonlyArray.findFirst(ast.propertySignatures, (ps) => ps.name === name) + if (Option.isSome(ops)) { + return ops.value + } else { + if (Predicate.isString(name)) { + for (const is of ast.indexSignatures) { + const parameterBase = getParameterBase(is.parameter) + switch (parameterBase._tag) { + case "TemplateLiteral": { + const regex = getTemplateLiteralRegex(parameterBase) + if (regex.test(name)) { + return createPropertySignature(name, is.type, false, false) + } + break + } + case "StringKeyword": + return createPropertySignature(name, is.type, false, false) + } + } + } else if (Predicate.isSymbol(name)) { + for (const is of ast.indexSignatures) { + const parameterBase = getParameterBase(is.parameter) + if (isSymbolKeyword(parameterBase)) { + return createPropertySignature(name, is.type, false, false) + } + } + } + } + break + } case "Suspend": - return getPropertySignatures(ast.f()) + return getIndexedAccess(ast.f(), name) } - throw new Error(`getPropertySignatures: unsupported schema (${ast._tag})`) + return createPropertySignature(name, neverKeyword, false, true) +} + +const getIndexedAccessKeys = (ast: AST): Array => { + switch (ast._tag) { + case "TypeLiteral": { + return ast.propertySignatures.map((ps) => ps.name) + } + case "Suspend": + return getIndexedAccessKeys(ast.f()) + } + return [] } /** @@ -1308,7 +1358,7 @@ export const createRecord = (key: AST, value: AST, isReadonly: boolean): TypeLit * @since 1.0.0 */ export const pick = (ast: AST, keys: ReadonlyArray): TypeLiteral => - createTypeLiteral(getPropertySignatures(ast).filter((ps) => keys.includes(ps.name)), []) + createTypeLiteral(keys.map((key) => getIndexedAccess(ast, key)), []) /** * Equivalent at runtime to the built-in TypeScript utility type `Omit`. @@ -1316,7 +1366,7 @@ export const pick = (ast: AST, keys: ReadonlyArray): TypeLiteral => * @since 1.0.0 */ export const omit = (ast: AST, keys: ReadonlyArray): TypeLiteral => - createTypeLiteral(getPropertySignatures(ast).filter((ps) => !keys.includes(ps.name)), []) + pick(ast, getIndexedAccessKeys(ast).filter((name) => !keys.includes(name))) /** * Equivalent at runtime to the built-in TypeScript utility type `Partial`. @@ -1577,6 +1627,22 @@ const sortPropertySignatures = ReadonlyArray.sort( pipe(Number.Order, Order.mapInput((ps: PropertySignature) => getCardinality(ps.type))) ) +const sortIndexSignatures = ReadonlyArray.sort( + pipe( + Number.Order, + Order.mapInput((is: IndexSignature) => { + switch (getParameterBase(is.parameter)._tag) { + case "StringKeyword": + return 2 + case "SymbolKeyword": + return 3 + case "TemplateLiteral": + return 1 + } + }) + ) +) + type Weight = readonly [number, number, number] const WeightOrder: Order.Order = Order.tuple< diff --git a/packages/schema/src/JSONSchema.ts b/packages/schema/src/JSONSchema.ts index 52f794f323..2ff5c3442c 100644 --- a/packages/schema/src/JSONSchema.ts +++ b/packages/schema/src/JSONSchema.ts @@ -7,7 +7,6 @@ import * as Predicate from "effect/Predicate" import * as ReadonlyArray from "effect/ReadonlyArray" import * as ReadonlyRecord from "effect/ReadonlyRecord" import * as AST from "./AST.js" -import * as Parser from "./Parser.js" import type * as Schema from "./Schema.js" /** @@ -388,7 +387,7 @@ const go = (ast: AST.AST, $defs: Record): JsonSchema7 => { } case "TemplateLiteral": { patternProperties = { - [Parser.getTemplateLiteralRegex(parameter).source]: goWithIdentifier( + [AST.getTemplateLiteralRegex(parameter).source]: goWithIdentifier( is.type, $defs ) @@ -493,7 +492,7 @@ const go = (ast: AST.AST, $defs: Record): JsonSchema7 => { throw new Error("cannot build a JSON Schema for a refinement without a JSON Schema annotation") } case "TemplateLiteral": { - const regex = Parser.getTemplateLiteralRegex(ast) + const regex = AST.getTemplateLiteralRegex(ast) return { type: "string", description: "a template literal", diff --git a/packages/schema/src/Parser.ts b/packages/schema/src/Parser.ts index 10f794a773..90c786b227 100644 --- a/packages/schema/src/Parser.ts +++ b/packages/schema/src/Parser.ts @@ -412,7 +412,7 @@ const go = (ast: AST.AST, isDecoding: boolean): Parser => { case "Enums": return fromRefinement(ast, (u): u is any => ast.enums.some(([_, value]) => value === u)) case "TemplateLiteral": { - const regex = getTemplateLiteralRegex(ast) + const regex = AST.getTemplateLiteralRegex(ast) return fromRefinement(ast, (u): u is any => Predicate.isString(u) && regex.test(u)) } case "Tuple": { @@ -1101,21 +1101,6 @@ const handleForbidden = ( : Either.left(ParseResult.forbidden(actual)) } -/** @internal */ -export const getTemplateLiteralRegex = (ast: AST.TemplateLiteral): RegExp => { - let pattern = `^${ast.head}` - for (const span of ast.spans) { - if (AST.isStringKeyword(span.type)) { - pattern += ".*" - } else if (AST.isNumberKeyword(span.type)) { - pattern += "[+-]?\\d*\\.?\\d+(?:[Ee][+-]?\\d+)?" - } - pattern += span.literal - } - pattern += "$" - return new RegExp(pattern) -} - function sortByIndex( es: ReadonlyArray.NonEmptyArray<[number, T]> ): ReadonlyArray.NonEmptyArray diff --git a/packages/schema/test/Schema/pick.test.ts b/packages/schema/test/Schema/pick.test.ts index abbd73badd..ffb0211385 100644 --- a/packages/schema/test/Schema/pick.test.ts +++ b/packages/schema/test/Schema/pick.test.ts @@ -97,4 +97,52 @@ describe("Schema > pick", () => { await Util.expectParseSuccess(schema, { a: "a", b: "1" }, { a: "a", b: 1 }) await Util.expectParseSuccess(schema, { b: "1" }, { a: "", b: 1 }) }) + + it("record(string, number)", async () => { + const schema = S.record(S.string, S.number).pipe(S.pick("a", "b")) + await Util.expectParseSuccess(schema, { a: 1, b: 2 }) + await Util.expectParseFailure( + schema, + { a: "a", b: 2 }, + `{ a: number; b: number } +└─ ["a"] + └─ Expected a number, actual "a"` + ) + await Util.expectParseFailure( + schema, + { a: 1, b: "b" }, + `{ a: number; b: number } +└─ ["b"] + └─ Expected a number, actual "b"` + ) + }) + + it("record(symbol, number)", async () => { + const a = Symbol.for("@effect/schema/test/a") + const b = Symbol.for("@effect/schema/test/b") + const schema = S.record(S.symbolFromSelf, S.number).pipe(S.pick(a, b)) + await Util.expectParseSuccess(schema, { [a]: 1, [b]: 2 }) + await Util.expectParseFailure( + schema, + { [a]: "a", [b]: 2 }, + `{ Symbol(@effect/schema/test/a): number; Symbol(@effect/schema/test/b): number } +└─ [Symbol(@effect/schema/test/a)] + └─ Expected a number, actual "a"` + ) + await Util.expectParseFailure( + schema, + { [a]: 1, [b]: "b" }, + `{ Symbol(@effect/schema/test/a): number; Symbol(@effect/schema/test/b): number } +└─ [Symbol(@effect/schema/test/b)] + └─ Expected a number, actual "b"` + ) + }) + + it("record(string, string) & record(`a${string}`, number)", async () => { + const schema = S.record(S.string, S.string).pipe( + S.extend(S.record(S.templateLiteral(S.literal("a"), S.string), S.number)), + S.pick("a", "b") + ) + await Util.expectParseSuccess(schema, { a: 1, b: "b" }) + }) })