Runtime coercion

I'm building a system to load declarative runtime configuration from a couple different sources, all of which provide values as strings (env vars, AWS SSM Parameters). Currently I'm walking through the typeJson at runtime to build accessors for each field, such as joining field names with __ to build environment variable keys, and that is working great. I can cleanly detect when I reach a "leaf" type to load, and I either have a string or an object with a "domain"; my challenge is whether there is an elegant way of coercing the string I load into the type expected at this point (without making some huge imperative logic block). I'm hopeful there might be some clever thing under the hood that I can use in place of rolling my own. Also, are there types defined somewhere that more concretely describe the typeJson structure?
29 Replies
ssalbdivad
ssalbdivad9mo ago
Hey, cool to hear you've been delving into some of the type system in depth! The easiest thing would be to traverse the type nodes directly instead of the JSON. If you create a type like:
const t = type({
foo: "number"
}).or({
foo: "string"
})

// access internal nodes
console.log(t.raw)
console.log(t.raw.kind)
console.log(t.raw.references)

if(t.raw.hasKind("union")) {
console.log(t.raw.discriminant)
}
const t = type({
foo: "number"
}).or({
foo: "string"
})

// access internal nodes
console.log(t.raw)
console.log(t.raw.kind)
console.log(t.raw.references)

if(t.raw.hasKind("union")) {
console.log(t.raw.discriminant)
}
I'm not totally sure what you mean about coercing the string into an expected type, maybe an example would help?
SneakyTurtle
SneakyTurtleOP9mo ago
Sure! (also, thanks for the quick reply!) So, I want to define a "config" type like
const MyConfig = type({
server:{ base_url: "url" }
})
const MyConfig = type({
server:{ base_url: "url" }
})
and then introspect that type to load the value from process.env.SERVER__BASE_URL. This works fine currently if the type (url in this case) is a string-based type, but if I want to load a number, I need a way to coerce into the base type (before any validators)
ssalbdivad
ssalbdivad9mo ago
Maybe something like this would be helpful:
const user = type({
name: "string",
age: "number"
})
const parsedUser = type("string").pipe(s => JSON.parse(s), user)
const user = type({
name: "string",
age: "number"
})
const parsedUser = type("string").pipe(s => JSON.parse(s), user)
It's not like Zod where there's a "before validation" phase or similar, types can be chained through transformations to other types There are also some builtin keywords that could be helpful here like parse.number
SneakyTurtle
SneakyTurtleOP9mo ago
Oh that sounds interesting where can I get parse.number?
ssalbdivad
ssalbdivad9mo ago
It should be available by default
SneakyTurtle
SneakyTurtleOP9mo ago
As a global?
ssalbdivad
ssalbdivad9mo ago
Like
type("parse.number")
type("parse.number")
I mean You can also import ark And use ark.parse.number
SneakyTurtle
SneakyTurtleOP9mo ago
Oh I see still wrapping my head around using type like that
ssalbdivad
ssalbdivad9mo ago
The completions should help a lot
SneakyTurtle
SneakyTurtleOP9mo ago
yeah, those are super nifty
ssalbdivad
ssalbdivad9mo ago
And very specific error messages if you get anything wrong
SneakyTurtle
SneakyTurtleOP9mo ago
kudos on building such an intricate type machine
ssalbdivad
ssalbdivad9mo ago
The best thing about the syntax IMO is that once you call type once you don't have to keep wrapping things You can define an object as deep as you want within a single type call
SneakyTurtle
SneakyTurtleOP9mo ago
how would I go about parsing a comma separated string into an array of things
ssalbdivad
ssalbdivad9mo ago
So it looks much more like pure TS syntax
const t = type("string").pipe(s => s.split(","))
const t = type("string").pipe(s => s.split(","))
Then whatever additional transforms/validation you want on each item
SneakyTurtle
SneakyTurtleOP9mo ago
I'm guessing thats what I would use a morph for?
ssalbdivad
ssalbdivad9mo ago
Yeah Pipe accepts N morphs. Where another Type is basically just a morph that will stop chaining if there are errors if you need intermediate validation
SneakyTurtle
SneakyTurtleOP9mo ago
🤯
ssalbdivad
ssalbdivad9mo ago
I hope once people get the hang of the first few concepts everything is very composable and intuitive from there. Working on new docs now 🙏
SneakyTurtle
SneakyTurtleOP9mo ago
Should this work as an inline morph:
list_o_strings: ['string', '|>', (s:string)=>s.split(',').map(x=>x.trim())],
list_o_strings: ['string', '|>', (s:string)=>s.split(',').map(x=>x.trim())],
ssalbdivad
ssalbdivad9mo ago
Which version are you using?
SneakyTurtle
SneakyTurtleOP9mo ago
2.0.0-dev.18 I believe
ssalbdivad
ssalbdivad9mo ago
Ahh okay that was the alpha morph operator for inlining, you want => in 2.0
SneakyTurtle
SneakyTurtleOP9mo ago
ahh, that makes more sense too
ssalbdivad
ssalbdivad9mo ago
You shouldn't need to explicitly type string although there are limits to what TS will do with some nested tuples You can also always define the morph type on its own, then reference it
SneakyTurtle
SneakyTurtleOP9mo ago
I think it was just lacking inferrence because of the wrong operator in the middle yep, works without the explicit string
ssalbdivad
ssalbdivad9mo ago
const splitAndTrim = type("string").pipe(s => s.split(",").map(s => s.trim()))
const obj = type({
list_o_strings: splitAndTrim
})
const splitAndTrim = type("string").pipe(s => s.split(",").map(s => s.trim()))
const obj = type({
list_o_strings: splitAndTrim
})
Or you can inline the type call, whatever is easiest (although I've found defining them as separate variables is nice for reusability, readability, and TS has an easier time with it) Or tuple expressions, it's really just the nested type` calls that TS doesn't like
SneakyTurtle
SneakyTurtleOP9mo ago
One last question for you @ssalbdivad (though I see you're offline for now, so no rush) If I wanted to do listOfInts how should I go about adding validation? Just doing
type('string').pipe((s) => s.split(',').map((x) => parseInt(x.trim())));
type('string').pipe((s) => s.split(',').map((x) => parseInt(x.trim())));
allows for NaN to slip into the array I tried type('string').pipe((s) => s.split(',').map((x) => type('integer')(x.trim()))); but that felt clunky
ssalbdivad
ssalbdivad8mo ago
Maybe this?
type("string").pipe(s => s.split(",").map(x => x.trim()), type("parse.integer"))
type("string").pipe(s => s.split(",").map(x => x.trim()), type("parse.integer"))

Did you find this page helpful?