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
ssalbdivad2w 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
errata2w ago
oh
ssalbdivad
ssalbdivad2w 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
errata2w 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
ssalbdivad2w 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
errata2w ago
@ArkDavid so how would you represent type TraitDefn<T extends Trait> = Trait & T in ark?
ssalbdivad
ssalbdivad2w ago
That doesn't do anything since T extends Trait intersecting should be a no-op
errata
errata2w ago
I'm looking at
const
boxOf = type("<t>", { box: "t" })
const
boxOf = type("<t>", { box: "t" })
ssalbdivad
ssalbdivad2w ago
If you want a generic with constraints you can do generic(["T", yourTraitType])("T")
errata
errata2w 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
ssalbdivad2w ago
I meant the Trait & T part it should just be T
errata
errata2w 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
ssalbdivad2w ago
Name should be "'TraitCanScream' | 'TraitHasMouth'" like TS or you can use type.enumerated() and pass them there
ssalbdivad
ssalbdivad2w 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
errata2w ago
This worked! I don't understand the difference though
Want results from more Discord servers?
Add your server