fix(core): validate scalar newtypes (#35381)

This commit is contained in:
Kit Langton 2026-07-04 21:43:08 -04:00 committed by GitHub
parent 905123b9c0
commit 44b182fe23
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 80 additions and 23 deletions

View file

@ -43,37 +43,47 @@ export type DeepMutable<T> = 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<FieldSchema>` 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>()("QuestionID", Schema.String) {
* static make(id: string): QuestionID {
* return this.make(id)
* }
* }
* class QuestionID extends Newtype<QuestionID>()("QuestionID", Schema.String) {}
*
* Schema.decodeEffect(QuestionID)(input)
* const id = QuestionID.make("question-1")
* Schema.decodeUnknownEffect(QuestionID)(input)
*/
type NewtypeSchema<Self, Tag extends string, S extends Schema.Top> = (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<S, keyof Schema.Top>
export function Newtype<Self>() {
return <const Tag extends string, S extends Schema.Top>(tag: Tag, schema: S) => {
return <const Tag extends string, S extends Schema.Top>(tag: Tag, schema: S): NewtypeSchema<Self, Tag, S> => {
abstract class Base {
declare readonly _newtype: Tag
static make(value: Schema.Schema.Type<S>): 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<S>) => Self
} & Omit<Schema.Opaque<Self, S, {}>, "make" | "~type.make"> & {
readonly "~type.make": Self
}
Object.setPrototypeOf(Base, schema.pipe(Schema.brand(tag)))
return Base as unknown as NewtypeSchema<Self, Tag, S>
}
}

View file

@ -0,0 +1,47 @@
import { expect, test } from "bun:test"
import { Effect, Schema } from "effect"
import { Newtype } from "../src/schema"
class UserID extends Newtype<UserID>()("Test.UserID", Schema.NonEmptyString) {}
class ProjectID extends Newtype<ProjectID>()("Test.ProjectID", Schema.NonEmptyString) {}
class Port extends Newtype<Port>()("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")
})