diff --git a/packages/core/src/schema.ts b/packages/core/src/schema.ts index 9c25bee19b1..338255309f5 100644 --- a/packages/core/src/schema.ts +++ b/packages/core/src/schema.ts @@ -43,37 +43,47 @@ export type DeepMutable = T extends string | number | boolean | bigint | symb * Nominal wrapper for scalar types. The class itself is a valid schema — * pass it directly to `Schema.decode`, `Schema.decodeEffect`, etc. * - * Overrides `~type.make` on the derived `Schema.Opaque` so `Schema.Schema.Type` - * of a field using this newtype resolves to `Self` rather than the underlying - * branded phantom. Without that override, passing a class instance to code - * typed against `Schema.Schema.Type` would require a cast even - * though the values are structurally equivalent at runtime. + * The runtime value remains an unwrapped primitive. `Schema.brand` supplies + * the primitive schema behavior and constructor validation, while the class + * supplies the nominal TypeScript identity. + * Apply checks and annotations to the underlying schema before wrapping it; + * schema rebuild operations intentionally return the underlying schema shape. * * @example - * class QuestionID extends Newtype()("QuestionID", Schema.String) { - * static make(id: string): QuestionID { - * return this.make(id) - * } - * } + * class QuestionID extends Newtype()("QuestionID", Schema.String) {} * - * Schema.decodeEffect(QuestionID)(input) + * const id = QuestionID.make("question-1") + * Schema.decodeUnknownEffect(QuestionID)(input) */ +type NewtypeSchema = (abstract new (_: never) => { + readonly _newtype: Tag +}) & + Schema.Bottom< + Self, + S["Encoded"], + S["DecodingServices"], + S["EncodingServices"], + S["ast"], + S["Rebuild"], + S["~type.make.in"], + Self, + S["~type.parameters"], + Self, + S["~type.mutability"], + S["~type.optionality"], + S["~type.constructor.default"], + S["~encoded.mutability"], + S["~encoded.optionality"] + > & + Omit + export function Newtype() { - return (tag: Tag, schema: S) => { + return (tag: Tag, schema: S): NewtypeSchema => { abstract class Base { declare readonly _newtype: Tag - - static make(value: Schema.Schema.Type): Self { - return value as unknown as Self - } } - Object.setPrototypeOf(Base, schema) - - return Base as unknown as (abstract new (_: never) => { readonly _newtype: Tag }) & { - readonly make: (value: Schema.Schema.Type) => Self - } & Omit, "make" | "~type.make"> & { - readonly "~type.make": Self - } + Object.setPrototypeOf(Base, schema.pipe(Schema.brand(tag))) + return Base as unknown as NewtypeSchema } } diff --git a/packages/core/test/newtype.test.ts b/packages/core/test/newtype.test.ts new file mode 100644 index 00000000000..1bcba5709db --- /dev/null +++ b/packages/core/test/newtype.test.ts @@ -0,0 +1,47 @@ +import { expect, test } from "bun:test" +import { Effect, Schema } from "effect" +import { Newtype } from "../src/schema" + +class UserID extends Newtype()("Test.UserID", Schema.NonEmptyString) {} +class ProjectID extends Newtype()("Test.ProjectID", Schema.NonEmptyString) {} +class Port extends Newtype()("Test.Port", Schema.FiniteFromString) {} + +const User = Schema.Struct({ id: UserID }) + +test("constructs nominal values from the underlying type", () => { + const id = UserID.make("user-1") + const acceptUserID = (_id: UserID) => undefined + + expect(String(id)).toBe("user-1") + acceptUserID(id) + + if (false) { + // @ts-expect-error distinct newtypes are not interchangeable + acceptUserID(ProjectID.make("project-1")) + } +}) + +test("preserves constructor validation", () => { + expect(() => UserID.make("")).toThrow() +}) + +test("decodes and encodes as a schema", async () => { + const decoded = await Effect.runPromise(Schema.decodeUnknownEffect(User)({ id: "user-1" })) + const encoded = await Effect.runPromise(Schema.encodeEffect(User)(decoded)) + + expect(String(decoded.id)).toBe("user-1") + expect(encoded).toEqual({ id: "user-1" }) +}) + +test("preserves the underlying schema validation", async () => { + const result = await Effect.runPromise(Schema.decodeUnknownEffect(UserID)("").pipe(Effect.result)) + expect(result._tag).toBe("Failure") +}) + +test("preserves transformed encoded and decoded representations", async () => { + const decoded = await Effect.runPromise(Schema.decodeUnknownEffect(Port)("8080")) + const encoded = await Effect.runPromise(Schema.encodeEffect(Port)(decoded)) + + expect(Number(decoded)).toBe(8080) + expect(encoded).toBe("8080") +})