A
arktype3mo ago
errata

Compile-time typesafe merge/and for data-only "traits"

I've evaluating introducing arktype to a typescript codebase, but I got stuck trying to use it to set up my POJO "traits" system. This is similar in concept to the Trait abstract class in arktype/util but this is data only; so far I've avoided the need to introduce any classes and I'm hoping to maintain this approach. So I have a discriminated union that represents the different "traits" and the union members share a common shape (mainly references to other trait names), something along these lines:
type TraitName = 'TraitCanScream' | 'TraitHasMouth' // ideally this would be derived e.g. AnyTrait['type']
type Trait = {
requires: TraitName[] // trait can require other traits e.g. scream requires mouth
}

type TraitDefn<T extends Trait> = Trait & T

type TraitHasMouth = TraitDefn<{
type: 'TraitHasMouth'
requires: []
}>

type TraitCanScream = TraitDefn<{
type: 'TraitCanScream'
requires: ['TraitHasMouth'] // if object has 'TraitCanScream', it must also have 'TraitHasMouth'
}>

// @ts-expect-error
type TraitMistake = TraitDefn<{
type: 'TraitMistake'
requires: ['KJAHBSDKAJSB'] // should catch that this isn't a valid type
}>

// discriminated union here
type AnyTrait = TraitCanScream | TraitHasMouth

// an object with traits would have them in an object keyed by TraitName
// i.e. stricter Record<TraitName, AnyTrait>
type Traits = {
TraitHasMouth: TraitHasMouth
TraitCanScream: TraitCanScream
}

type TraitsObject = Partial<Traits>

function HasTrait<T extends TraitName>(traitName: T, traits: TraitsObject): traits is Pick<Traits, T> {
return traitName in traits && traits[traitName]?.type === traitName
}

// this is what I want to assert: if an object has a trait, it also has all of its required traits (recursively)
type RequiredTraits<T extends TraitName> = Pick<Traits, T | Traits[T]['requires'][number]>

// ensures traits has all required traits
function HasRequiredTraits<T extends TraitName>(traitName: T, traits: TraitsObject): traits is RequiredTraits<T> {
if (!HasTrait(traitName, traits)) return false
const trait = traits[traitName]
for (const requiredTrait of trait.requires) {
if (!HasRequiredTraits(requiredTrait, traits)) return false
}
return true
}
type TraitName = 'TraitCanScream' | 'TraitHasMouth' // ideally this would be derived e.g. AnyTrait['type']
type Trait = {
requires: TraitName[] // trait can require other traits e.g. scream requires mouth
}

type TraitDefn<T extends Trait> = Trait & T

type TraitHasMouth = TraitDefn<{
type: 'TraitHasMouth'
requires: []
}>

type TraitCanScream = TraitDefn<{
type: 'TraitCanScream'
requires: ['TraitHasMouth'] // if object has 'TraitCanScream', it must also have 'TraitHasMouth'
}>

// @ts-expect-error
type TraitMistake = TraitDefn<{
type: 'TraitMistake'
requires: ['KJAHBSDKAJSB'] // should catch that this isn't a valid type
}>

// discriminated union here
type AnyTrait = TraitCanScream | TraitHasMouth

// an object with traits would have them in an object keyed by TraitName
// i.e. stricter Record<TraitName, AnyTrait>
type Traits = {
TraitHasMouth: TraitHasMouth
TraitCanScream: TraitCanScream
}

type TraitsObject = Partial<Traits>

function HasTrait<T extends TraitName>(traitName: T, traits: TraitsObject): traits is Pick<Traits, T> {
return traitName in traits && traits[traitName]?.type === traitName
}

// this is what I want to assert: if an object has a trait, it also has all of its required traits (recursively)
type RequiredTraits<T extends TraitName> = Pick<Traits, T | Traits[T]['requires'][number]>

// ensures traits has all required traits
function HasRequiredTraits<T extends TraitName>(traitName: T, traits: TraitsObject): traits is RequiredTraits<T> {
if (!HasTrait(traitName, traits)) return false
const trait = traits[traitName]
for (const requiredTrait of trait.requires) {
if (!HasRequiredTraits(requiredTrait, traits)) return false
}
return true
}
I'm confused about how I would represent this setup in arktype in a typesafe manner? Or would you suggest an alternative way of representing this? I don't have any significant code written against these types so I'm open to rethinking this setup entirely. I got stuck almost immediately as neither .and(...) nor.merge(...) will allow me to inherit compile-time type checking of requires in my Trait subtypes. Please help! Thanks
24 Replies
ssalbdivad
ssalbdivad3mo ago
What specifically do you want to work that doesn't? I guess you want a way to inersect two objects so that the traits arrays can be intersected as sets? There's an API for writing arbitrary type-level logic as a generic:
const validateExternalGeneric = generic(
["S", "string"],
["N", { value: "number" }]
)(
args => [args.S.atLeastLength(1), args.N],
class extends Hkt<[string, { value: number }]> {
declare body: [this[0], this[1]]
}
)
const validateExternalGeneric = generic(
["S", "string"],
["N", { value: "number" }]
)(
args => [args.S.atLeastLength(1), args.N],
class extends Hkt<[string, { value: number }]> {
declare body: [this[0], this[1]]
}
)
errata
errataOP3mo ago
oh
ssalbdivad
ssalbdivad3mo ago
If you're wanting to map over two objects and intersect them except the traits key which you want to treat as a set instead of an array, I guess I'd say merge them then go back into the type and replace the requirements key by manually concatenating the lists The easiest change to the API you could make that would make this trivial was just having your trait sets be an object instead of a tuple e.g. {HasMouth: true, TraitCanScream: true} then you can intersect them without worrying about it
errata
errataOP3mo ago
Maybe that's the solution, I had in my head that I would be able to get what I wanted without the ark equivalent of the TraitDefn generic above, but it's likely that ark needs it to be generic for the exact same reason TS needs it i.e. TS won't type-check the requires with & either:
type TraitCanScream = Trait & {
type: 'TraitCanScream'
requires: ['TraitHasMouth']
}
type TraitCanScream = Trait & {
type: 'TraitCanScream'
requires: ['TraitHasMouth']
}
ssalbdivad
ssalbdivad3mo ago
Yeah, best to use an object to simplify things tuples are treated positionally so intersecting them will make the type unsatisfiable if you have multiple literals If you do that you will just be able to intersect them normally and the inference will be fine
errata
errataOP3mo ago
@ArkDavid so how would you represent type TraitDefn<T extends Trait> = Trait & T in ark?
ssalbdivad
ssalbdivad3mo ago
That doesn't do anything since T extends Trait intersecting should be a no-op
errata
errataOP3mo ago
I'm looking at
const
boxOf = type("<t>", { box: "t" })
const
boxOf = type("<t>", { box: "t" })
ssalbdivad
ssalbdivad3mo ago
If you want a generic with constraints you can do generic(["T", yourTraitType])("T")
errata
errataOP3mo ago
it's what's giving me type-checking in
type TraitCanScream = TraitDefn<{
type: 'TraitCanScream'
requires: ['TraitHasMouth']
}
type TraitCanScream = TraitDefn<{
type: 'TraitCanScream'
requires: ['TraitHasMouth']
}
ssalbdivad
ssalbdivad3mo ago
I meant the Trait & T part it should just be T
errata
errataOP3mo ago
ah yes you're right @ArkDavid ok that generic thing got me further! but now I'm having trouble when the requires isn't an empty array:
const ArkTraitName = type(["'TraitCanScream'", "'TraitHasMouth'"])
const ArkTrait = type({
requires: ArkTraitName.array(),
})
const ArkTraitDefn = generic(["T", ArkTrait])("T")
const ArkTraitHasMouth = ArkTraitDefn({
type: "'TraitHasMouth'",
requires: []
})
const ArkTraitCanScream = ArkTraitDefn({
type: "'TraitCanScream'",
requires: ["'TraitHasMouth'"] // how to represent: requires: ['TraitHasMouth'] ??
})
const ArkTraitName = type(["'TraitCanScream'", "'TraitHasMouth'"])
const ArkTrait = type({
requires: ArkTraitName.array(),
})
const ArkTraitDefn = generic(["T", ArkTrait])("T")
const ArkTraitHasMouth = ArkTraitDefn({
type: "'TraitHasMouth'",
requires: []
})
const ArkTraitCanScream = ArkTraitDefn({
type: "'TraitCanScream'",
requires: ["'TraitHasMouth'"] // how to represent: requires: ['TraitHasMouth'] ??
})
ssalbdivad
ssalbdivad3mo ago
Name should be "'TraitCanScream' | 'TraitHasMouth'" like TS or you can use type.enumerated() and pass them there
ssalbdivad
ssalbdivad3mo ago
I know there's a lot missing from the docs but do check those out and see if some of what you're looking for is there https://arktype.io/reference/api/
ArkType
API
TypeScript's 1:1 validator, optimized from editor to runtime
errata
errataOP3mo ago
This worked! I don't understand the difference though
ssalbdivad
ssalbdivad3mo ago
It's just like TS if you put them in a tuple like that it's a tuple type
errata
errataOP3mo ago
ahhhh
ssalbdivad
ssalbdivad3mo ago
You can mouse over it and see how it's inferred to see why it doesn't work
errata
errataOP3mo ago
right, it's just like ts I had zod's z.union([type1, type2]) syntax in my head right of course I didn't even look at that because library types are usually super nesty
ssalbdivad
ssalbdivad3mo ago
It will help to just get in the habit of mousing over your type to see how it was inferred if something is not working the way you expect The hovers (unlike Zod) are usually very self descriptive 😛
errata
errataOP3mo ago
👍 thanks for your help
ssalbdivad
ssalbdivad3mo ago
No problem good luck!
errata
errataOP3mo ago
a quick-fix could be to lift a bunch of examples out of __tests__ onto the site
ssalbdivad
ssalbdivad3mo ago
Interesting idea I will consider that I mean honestly even though I'm working on better docs it will take time to get every feature covered whereas every feature is already thoroughly tested. Guess it depends how hard the tests are too read for someone trying to learn the framework You have to work through extra stuff visually like the attest calls
Want results from more Discord servers?
Add your server