sinclairzx81 / typebox

Json Schema Type Builder with Static Type Resolution for TypeScript
Other
4.76k stars 152 forks source link

typebox class creator and bodyguard pattern #334

Closed gotjoshua closed 1 year ago

gotjoshua commented 1 year ago

I am a long term user and fan of Object Model and recently asked the author if he wanted to add Object Model to @samchon 's comparisons

His reply was a very insightful metaphor

"Typia, zod and other validation libraries are like security checks [...] ObjectModel is like a bodyguard escort"

This triggered a poc exploration of using typebox to create proxys that check on every assignment https://gitlab.com/onezoomin/ztax/typebox-trpc-plasmo/-/commit/012c7dfef0902b37920c164953941e4c27521062#note_1300058374

Previously I had already created a pattern for creating a class that does checking and casting or throwing in the constructor: https://gitlab.com/onezoomin/ztax/typebox-trpc-plasmo/-/blob/trunk/model/utils.ts#L28-51

So, I open this issue as a show and tell, and with the question to @sinclairzx81 : Would you welcome a PR that implements these patterns as core typebox features? If not, are you willing to share some thoughts and philosophies why not?

gotjoshua commented 1 year ago

I discovered that there are some relevant examples here: https://github.com/sinclairzx81/typebox/blob/master/example/collections

still curious for your thoughts about including the pattern in the lib in a more core way than examples...

sinclairzx81 commented 1 year ago

@gotjoshua Hi!, Nice work on the POC! it's great to see implementations like this that experiment with high level usage. Good work :)

still curious for your thoughts about including the pattern in the lib in a more core way than examples...

Yeah it's quite unlikely TypeBox will add additional functionality above and beyond what it's currently offering. TypeBox is generally more aligned at being a relatively low level type system and providing just enough "opt-in" functionality to make higher level abstractions possible (but stops short of actually implementing any of them, noting the collections example is just a reference for what higher level abstractions could look like).

The reasoning for not including more concrete abstractions, is there are many different (and often contentious) ways to express higher level functionality (but usually only a few ways to express the operations underneath). For example, functional libraries such as lodash, ramba, etc all express .map(), but there's only one way to actually implement it. In much the same way, there is only one way to express a string schema, and only one way to validate it. TypeBox tries to extend this principle all the way down to type composition itself.

const T = { type: 'string', exlusiveMinimum: 10 } // the rawest implementation. (abstraction 0)

const T = Type.String({ exclusiveMaximum: 10 }) // string + optional constraints (abstraction 1)

const T = z.string().lt(10) // fluent pattern (abstraction 2)

Where raw type expressions can more easily map to fluent (or other) patterns, but the inverse is not always necessarily true (especially for more sophisticated types). For this reason, TypeBox opts for the lowest possible expression that still provides hooks to reconcile the type with the TS type system.

Type Safe Abstractions

In projects where I'm using TypeBox, the following pattern is generally how I go about approximating something like ObjectModel (which you've discovered with your POC). The following is some generic Model<T> with type safe set.

import { TypeCompiler } from '@sinclair/typebox/compiler'
import { TSchema, Static } from '@sinclair/typebox'

export class Model<T extends TSchema> {
  #check: TypeCheck<T>
  constructor(private readonly schema: T) {
     this.#check = TypeCompiler.Compile(schema) // compilation tied to object creation
  }
  public set(value: Static<T>): void {
     if(!#check.Check(value)) throw Error('invalid')
  }
  public get(): Static<T> {}
}

Of course, the above can be extended to any class operating on values of generic type T where a TSchema is passed as an argument on the classes constructor (which is enough to hide much of the Static<T> inference for the users of the class)

const queue = new Queue(Type.String())

const [sender, receiver] = new Channel(Type.String())

const publisher = new AwsSqsPublisher(Type.String())

const table = new Table(Type.Object({ 
  id: Type.Integer(), 
  name: Type.String() 
}))

const commandLineArgs = Cli.Parse(Type.Object({
  strict: Type.Boolean(),
  target: Type.Union([
    Type.Literal('ESNext'),
    Type.Literal('ES2022')
  ])
})

Given the number of ways TypeBox can be integrated into applications and infrastructure, keeping things low level generally provides the most options to implement checks in ways aligned to the surrounding infrastructure.

Hope this helps! S

gotjoshua commented 1 year ago

Hope this helps!

indeed! many thanks!

i wonder if a sidecar library with Typebox as a peer dep, would make sense.

I'd need to be sure the patterns are sound and well documented, but it could prove useful.

gotjoshua commented 1 year ago
constructor(private readonly schema: T)

could you possibly give a quick typescript lesson about this private readonly constructor arg?

edit: found this ref

sinclairzx81 commented 1 year ago

@gotjoshua Hi

could you possibly give a quick typescript lesson about this private readonly constructor arg?

Ah, this is just a convenience syntax for class field assignment in TypeScript. Not sure of the specific name of the syntax, I've generally just called them primary constructors in recent years (borrowing on C#'s definition for them)

class Foo {
   private readonly value: string
   constructor(value: string) {
      this.value = value
    }
}

can be written shorthand where the this.value = value is implicit

class Foo {
   constructor(private readonly value: string) {}
}

i wonder if a sidecar library with Typebox as a peer dep, would make sense.

Yeah, maybe. I think there's potential to explore implementing type safe abstractions across quite a lot of JavaScript (not only Map, Set, Array and Atom), but for things like functions also. Here's a rough prototype.

import { Typed } from '<package-name-here>'

// collections + atom (renamed to value)
const V = new Typed.Value(Type.Number())              // number (i.e. proxied Atom)
const M = new Typed.Map(Type.String(), Type.Number()) // Map<string, number>
const S = new Typed.Set(Type.Number())                // Set<number>
const A = new Typed.Array(Type.Number())              // Array<number>

// function: signature
const F = Type.Function([                             // (a: number, b: number) => number
  Type.Number(), 
  Type.Number()
], Type.Number())  

// function: implementations
const add = Typed.Function(F, (a, b) => a + b)
const sub = Typed.Function(F, (a, b) => a - b)
const mul = Typed.Function(F, (a, b) => a * b)
const div = Typed.Function(F, (a, b) => a / b)
const mod = Typed.Function(F, (a, b) => a % b)

// i.e: add(1, 2) === 3

// constructor: signature
const C = Type.Constructor(() => Type.Object({    // new () => {
   add: F                                         //   add: (a: number, b: number) => number
   sub: F                                         //   sub: (a: number, b: number) => number
   mul: F                                         //   mul: (a: number, b: number) => number
   div: F                                         //   div: (a: number, b: number) => number
   mod: F                                         //   mod: (a: number, b: number) => number
})                                                // }

// constructor: implementation
const math = new Typed.Constructor(C, { add, sub, mul, div, mod  })

// i.e: math.add(1, 2)

There might be more abstractions possible, these are the ones that immediately spring to mind.

Hey, might close off this issue for now, but happy to discuss higher level abstractions in more depth (either here or on GH discussions). I think a sidecar library would be quite interesting to explore and may prove quite useful for some application types (particularly for NPM libraries that expose interfaces to callers; but can't easily assert the caller is calling that interface correctly). Using types + assertions (as above) would offer both validation and reflectable interface documentation. Something like that could be quite cool and is worth thinking about :)

All the best! S