sinclairzx81 / typebox

Json Schema Type Builder with Static Type Resolution for TypeScript
Other
4.98k stars 157 forks source link

Template string tag for TemplateLiteral() #398

Closed felixfbecker closed 1 year ago

felixfbecker commented 1 year ago

I saw the addition of TemplateLiteral and it's really cool, but right now it's a bit verbose to define. It would be awesome if typebox exported a template string tag to define TemplateLiteral()s, like so:

import { L } from "@sinclair/typebox"

const PostsUrlSchema = L`/users/${Type.Number()}/posts/${Type.String()}`

Additionally, if a template expression has no interpolated values, it could return TLiteral and therefor also be a shorthand to define literal values (e.g. in union types).

sinclairzx81 commented 1 year ago

@felixfbecker Hi!

I've just pushed a prepared implementation of this to one of the TB examples. See link below.

https://github.com/sinclairzx81/typebox/tree/master/example/template-dsl

Example

The following is the usage, replacing Type.String() with ${string} in the template.

import { Static } from '@sinclair/typebox'
import { TemplateLiteral } from './template-dsl'

// ----------------------------------------------------------------
// Path
// ----------------------------------------------------------------

const Path = TemplateLiteral('/users/${number}/posts/${string}')

type Path = Static<typeof Path> // type Path = '/users/${number}/posts/${string}'

// ----------------------------------------------------------------
// Bytes
// ----------------------------------------------------------------

const Byte = TemplateLiteral('${0|1}${0|1}${0|1}${0|1}${0|1}${0|1}${0|1}${0|1}')

type Byte = Static<typeof Byte> // type Byte = '00000000' | '00000001' | '00000010' ... | '11111111' 

I may look at integrating this into TB over the course of 0.28.0 as a overload for Type.TemplateLiteral(), but for now, you'll need to copy and paste the file into your project to use. The implementation is currently a draft, so open to feedback on the design.

Cheers S

sinclairzx81 commented 1 year ago

@felixfbecker Hi, I've just been mulling over the possible implementation of this DSL. There are two options for syntax I can implement for this, one TS template literal orientated, the other regular expression orientated. I'm open to suggestions on preferences for either syntax, contrasted with technical constraints.

// Option A: Emulate TS template literal Syntax
const Path = TemplateLiteral('/users/${1|2|3}/posts/${a|b|c}')

// Option B: Emulate Regular Expression Syntax
const Path = TemplateLiteral('/users/(1|2|3)/posts/(a|b|c)')

Option A

Option A would be familiar to TS developers, but the ${} syntax is somewhat superfluous as it doesn't actually breakout out of the string. Note, it's currently not possible to implement inference for JavaScript template strings (as per the L utility type given in your original example). This is due to a limitation in TS where it doesn't have a way to infer interleaved string + template parameters. I actually have another project which is currently stuck waiting on TS support in this area. Due to the limitations, I'm not sure about implementing the ${} syntax.

Option B

Option B is my current preference. I tend to lean more towards this syntax as it more closely matches the encoded pattern applied for template literal types. It would also be possible to support recursive union (as supported in regular expressions)

// Support for recursive union could be implemented more naturally with regular expression syntax.
const Path = TemplateLiteral('/users/(1|(2|(3)))/posts/((((a)|b)|c))')

However encoding for ${string} and ${number} would require non-standard syntax.

const Path = TemplateLiteral('/users/{string}') // invent {} to escape for number | string

const Path = TemplateLiteral('/users/(.*)') // just implement full regex (somewhat out of scope)

Due to above, I somewhat lean back in favor of Option A.

Open to thoughts on either approach.

felixfbecker commented 1 year ago

Oh, I wasn't aware that there is no support on the TypeScript side for inferring tagged template string arguments. In that case I don't know if the complexity of a DSL is worth it, it wouldn't allow you to reference/embed other Typebox types as parts of the string. The array syntax is probably fine.

One possibility would be to provide the template type explicitly as a required type parameter to the template tag (kind of like Type.Unsafe()).

sinclairzx81 commented 1 year ago

@felixfbecker Hi, thanks for the feedback.

Oh, I wasn't aware that there is no support on the TypeScript side for inferring tagged template string arguments. In that case I don't know if the complexity of a DSL is worth it, it wouldn't allow you to reference/embed other Typebox types as parts of the string. The array syntax is probably fine.

Yeah, this is a difficult call to make. I've been somewhat uncertain about implementing a non-standard / non-parameterized template literal overload for Type.TemplateLiteral mostly due to the TS inference limitation (and to mitigate introducing a feature I may end up having to break should TS add this functionality in later releases). But despite this......still very very tempting!!

I think for now, the best call is probably to hold off on integrating into TB in the short term to give the current template literal feature time to stabilize, but will probably spend a bit of time refining the existing dsl implementation in the background. This could be potentially introduced under an [Experimental] flag / comment. Previous features have come and gone under this flag in the past, and does provide a little bit of room to move should the implementation need to change.

One possibility would be to provide the template type explicitly as a required type parameter to the template tag (kind of like Type.Unsafe()).

This is partially supported now with Type.Unsafe. TypeScript Link Here

import { Type, Static } from '@sinclair/typebox'

const T = Type.Unsafe<`on${'open' | 'close'}`>({ type: 'string', pattern: '^on(open|close)$' })

type T = Static<typeof T>      // type T = "onopen" | "onclose"

I think for now, Id' like to avoid introducing additional Unsafe like types (the current Unsafe causes enough problems as is :D), but did you have something specific in mind for this? If you can draft up a example, I'd be happy to take a look.

Hey, thanks again for the feedback. Might close this one out for now, but will revisit and notify on this thread if I go through with the [Experimental] implementation of the DSL. I'm am quite keen on the feature despite the all the potential downsides, so will see about things eventuate over the next few months.

All the best! S