A
arktypeā€¢5mo ago
Bjorno

Extending types similar to an interface

How would one go about extending types similar to how one would extend an interface in TypeScript? It's really the only thing I feel like is missing for ArkType to be a viable alternative. A minimal example of what I want for ArkType, but written in TypeScript:
interface BaseTitle {
kind: 'series' | 'episode' | 'movie';
name: string;
seasonNumber?: string;
episodeNumber?: string;
seasonCount?: number;
episodeCount?: number;
}

interface SeriesTitle extends BaseTitle {
kind: 'series';
seasonCount: number;
episodeCount: number;
seasonNumber?: never;
episodeNumber?: never;
}

interface EpisodeTitle extends BaseTitle {
kind: 'episode';
seasonNumber: string;
episodeNumber: string;
seasonCount?: never;
episodeCount?: never;
}

interface MovieTitle extends BaseTitle {
kind: 'movie';
seasonNumber?: never;
episodeNumber?: never;
seasonCount?: never;
episodeCount?: never;
}

type Title = SeriesTitle | EpisodeTitle | MovieTitle;
interface BaseTitle {
kind: 'series' | 'episode' | 'movie';
name: string;
seasonNumber?: string;
episodeNumber?: string;
seasonCount?: number;
episodeCount?: number;
}

interface SeriesTitle extends BaseTitle {
kind: 'series';
seasonCount: number;
episodeCount: number;
seasonNumber?: never;
episodeNumber?: never;
}

interface EpisodeTitle extends BaseTitle {
kind: 'episode';
seasonNumber: string;
episodeNumber: string;
seasonCount?: never;
episodeCount?: never;
}

interface MovieTitle extends BaseTitle {
kind: 'movie';
seasonNumber?: never;
episodeNumber?: never;
seasonCount?: never;
episodeCount?: never;
}

type Title = SeriesTitle | EpisodeTitle | MovieTitle;
53 Replies
PIat
PIatā€¢5mo ago
You can do something like this:
const baseTitleType = type({
kind: "'series' | 'episode' | 'movie'",
name: 'string'
})

const seriesTitleType = type(baseTitleType, '&', {
kind: "'series'",
seasonCount: 'number'
})

const episodeTitleType = type(baseTitleType, '&', {
kind: "'episode'",
'episodeCount?': 'never'
})

const titleType = type(seriesTitleType, '|', episodeTitleType)
const baseTitleType = type({
kind: "'series' | 'episode' | 'movie'",
name: 'string'
})

const seriesTitleType = type(baseTitleType, '&', {
kind: "'series'",
seasonCount: 'number'
})

const episodeTitleType = type(baseTitleType, '&', {
kind: "'episode'",
'episodeCount?': 'never'
})

const titleType = type(seriesTitleType, '|', episodeTitleType)
If you really need it to work like interfaces, you can do this: https://discord.com/channels/957797212103016458/1162539860574347385/1162722103959760926
ssalbdivad
ssalbdivadā€¢5mo ago
You can chain .and or .merge depending on the behavior you want. Instead of manually typing the keys as never, you can just add "+": "reject" on your object definitions to specify that no undeclared keys are allowed.
Bjorno
BjornoOPā€¢5mo ago
I guess my question is more related to discriminated unions from a DX standpoint. I find myself not being able to declare types as I mostly do in TS with ArkType. And that's the only reason I can't adopt it.
ssalbdivad
ssalbdivadā€¢5mo ago
You can literally write:
const BaseTitle = type({
kind: "'series'|'episode'|'movie'",
name: "string",
"seasonNumber?": "string",
"episodeNumber?": "string",
"seasonCount?": "number",
"episodeCount?": "number"
})

const SeriesTitle = BaseTitle.merge({
kind: "'series'",
"seasonCount": "number",
"episodeCount": "number",
"seasonNumber?": "never",
"episodeNumber?": "never"
})
const BaseTitle = type({
kind: "'series'|'episode'|'movie'",
name: "string",
"seasonNumber?": "string",
"episodeNumber?": "string",
"seasonCount?": "number",
"episodeCount?": "number"
})

const SeriesTitle = BaseTitle.merge({
kind: "'series'",
"seasonCount": "number",
"episodeCount": "number",
"seasonNumber?": "never",
"episodeNumber?": "never"
})
What could be more similar than that šŸ˜…
Bjorno
BjornoOPā€¢5mo ago
It is similar, but it's not enforcing a constraint, you could merge with a kind not related to the original type.
ssalbdivad
ssalbdivadā€¢5mo ago
I guess you want .and If the types are disjoint for a non-optional key, it would throw a parse error But the second still wouldn't have to be a subtype of the first
Bjorno
BjornoOPā€¢5mo ago
Is there any way to make that work within ArkType? Or is it planned?
ssalbdivad
ssalbdivadā€¢5mo ago
Yeah there are lots of ways one sec Something like this would be one way, although it would require a full subtype (not missing props):
const title = generic(["t", {
kind: "'series'|'episode'|'movie'",
name: "string",
"seasonNumber?": "string",
"episodeNumber?": "string",
"seasonCount?": "number",
"episodeCount?": "number"
}])("t")

const seriesTitle = title({
kind: "'series'",
name: "string",
seasonCount: "number",
episodeCount: "number",
"seasonNumber?": "never",
"episodeNumber?": "never"
})

const invalidTitle = title({
kind: "'whoops'",
name: "string",
seasonCount: "number",
episodeCount: "number",
"seasonNumber?": "never",
"episodeNumber?": "never"
})
const title = generic(["t", {
kind: "'series'|'episode'|'movie'",
name: "string",
"seasonNumber?": "string",
"episodeNumber?": "string",
"seasonCount?": "number",
"episodeCount?": "number"
}])("t")

const seriesTitle = title({
kind: "'series'",
name: "string",
seasonCount: "number",
episodeCount: "number",
"seasonNumber?": "never",
"episodeNumber?": "never"
})

const invalidTitle = title({
kind: "'whoops'",
name: "string",
seasonCount: "number",
episodeCount: "number",
"seasonNumber?": "never",
"episodeNumber?": "never"
})
It would be pretty easy to build a utility function or something that would iterate over each prop and check if it's present instead if you wanted to do that. Personally I think there would be better ways to express these kinds of relationships in general. I'm not sure I'd add the exact equivalent of interface extends to the API when and and or generics can be used to do something quite similar, but I'm open to considering it more. The implementation would be fairly easy externally anyways, so really a matter of whether it convolutes the API
Bjorno
BjornoOPā€¢5mo ago
I'm assuming this would break if you had nested discriminated unions
ssalbdivad
ssalbdivadā€¢5mo ago
Why? Another option that would give better type errors if something is incorrect for a complex constraint like that would be something like this:
const subtype = <base, sub extends base>(base:Type<base>, sub:Type<sub>) => {
if(!base.extends(sub as never)) throw new Error(`Type ${sub.expression} must extend ${base.expression}`)
return sub
}
const subtype = <base, sub extends base>(base:Type<base>, sub:Type<sub>) => {
if(!base.extends(sub as never)) throw new Error(`Type ${sub.expression} must extend ${base.expression}`)
return sub
}
You could change it to accept a validated definition so you don't have to wrap it in type with some extra work
Bjorno
BjornoOPā€¢5mo ago
I don't think I understand Could you give an example of nested discriminated unions, similarly to how you would do in TS?
ssalbdivad
ssalbdivadā€¢5mo ago
I don't know what you mean you can just do whatever you'd do in TS the same way:
type({
foo: {
key: "'bar'"
}
}).or({
foo: {
key: "'baz'"
}
})
type({
foo: {
key: "'bar'"
}
}).or({
foo: {
key: "'baz'"
}
})
Keep in mind internally arktype always discriminates unions for you so you'll get those optimizations for free without having to do anything special Including nested discriminated unions
Chakib Ouhajjou
Chakib Ouhajjouā€¢5w ago
is there a way to have kind filled automatically. I tried pipe, nothing works for me
ssalbdivad
ssalbdivadā€¢5w ago
You will have to be more specific haha
Chakib Ouhajjou
Chakib Ouhajjouā€¢5w ago
in this example I assumed that s will have kind set to 'series'
const BaseTitle = type({
kind: "'series'|'episode'|'movie'",
name: "string",
"seasonNumber?": "string",
"episodeNumber?": "string",
"seasonCount?": "number",
"episodeCount?": "number",
});

const SeriesTitle = BaseTitle.and({
kind: "'series'",
seasonCount: "number",
episodeCount: "number",
"seasonNumber?": "never",
"episodeNumber?": "never",
});

const s = SeriesTitle({
name: "Coucou",
seasonCount: 0,
episodeCount: 0,
});
const BaseTitle = type({
kind: "'series'|'episode'|'movie'",
name: "string",
"seasonNumber?": "string",
"episodeNumber?": "string",
"seasonCount?": "number",
"episodeCount?": "number",
});

const SeriesTitle = BaseTitle.and({
kind: "'series'",
seasonCount: "number",
episodeCount: "number",
"seasonNumber?": "never",
"episodeNumber?": "never",
});

const s = SeriesTitle({
name: "Coucou",
seasonCount: 0,
episodeCount: 0,
});
I need kind to default to 'series' in SeriesTitle
ssalbdivad
ssalbdivadā€¢5w ago
Have you read the docs on default values?
Chakib Ouhajjou
Chakib Ouhajjouā€¢5w ago
I've read all the docs twice ... hehe ... but let me recheck
const titleKind = type("'series'|'episode'|'movie'");
const BaseTitle = type({
kind: titleKind,
name: "string",
"seasonNumber?": "string",
"episodeNumber?": "string",
"seasonCount?": "number",
"episodeCount?": "number",
});

const SeriesTitle = BaseTitle.and({
kind: titleKind.default("series"),

seasonCount: "number",
episodeCount: "number",
"seasonNumber?": "never",
"episodeNumber?": "never",
});

const s = SeriesTitle({
name: "Coucou",
seasonCount: 0,
episodeCount: 0,
});
const titleKind = type("'series'|'episode'|'movie'");
const BaseTitle = type({
kind: titleKind,
name: "string",
"seasonNumber?": "string",
"episodeNumber?": "string",
"seasonCount?": "number",
"episodeCount?": "number",
});

const SeriesTitle = BaseTitle.and({
kind: titleKind.default("series"),

seasonCount: "number",
episodeCount: "number",
"seasonNumber?": "never",
"episodeNumber?": "never",
});

const s = SeriesTitle({
name: "Coucou",
seasonCount: 0,
episodeCount: 0,
});
Chakib Ouhajjou
Chakib Ouhajjouā€¢5w ago
I just did
ssalbdivad
ssalbdivadā€¢5w ago
Does that solve the problem?
Chakib Ouhajjou
Chakib Ouhajjouā€¢5w ago
nope āžœ ark git:(auth) āœ— bun test.ts error: kind must be "episode", "movie" or "series" (was missing)
ssalbdivad
ssalbdivadā€¢5w ago
Oh I see what you want. Well and means all the constraints of both types have to be satisfied, so you can't intersect something with a required key and get an optional key (or a defaultable key) You should remove kind from BaseTitle or make it optional there You can also do that after creating BaseTitle using .omit("kind") , but it's kind of an antipattern in that situation. Best to just build up the type with the shared props
Chakib Ouhajjou
Chakib Ouhajjouā€¢5w ago
cool thanks. so many gotchas. I guess it is because of typescript.
ssalbdivad
ssalbdivadā€¢5w ago
Yeah, that's based on TS but that one actually makes sense according to the underlying set theory as well. It gets tougher when I have to figure out to do with stuff TS gets wrong without breaking compatibility haha
Chakib Ouhajjou
Chakib Ouhajjouā€¢5w ago
here is my temporary conclusion. cuelang typesystem is far ahead of anything i ve seen. And I used so many languages. I think it's good to keep as a benchmark. just my 2 cents.
ssalbdivad
ssalbdivadā€¢5w ago
Haha you have to learn more before you can make judgments about that. There are some areas where TS really shines, e.g. enabling a library like ArkType to give such strong type safety and completions You aren't at the point yet where you have good intuitions about set-based types. TS does have its weaknesses, but it has some really significant strengths as well.
Chakib Ouhajjou
Chakib Ouhajjouā€¢5w ago
do you know cuelang ?
ssalbdivad
ssalbdivadā€¢5w ago
No, and so I will reserve judgment about that. I'm just saying I've talked to lots of other devs who work with lots of languages and never heard anyone say TS has a weak type system. Idiosyncratic and unsound? Yes, but like I said, you can create APIs with it that are really rich and responsive in a way I haven't seen in other languages, at least without macros or custom editor extensions
Chakib Ouhajjou
Chakib Ouhajjouā€¢5w ago
since you are busy. take a look here: https://cuelang.org/docs/tour/basics/types-are-values/ cue is JSON supeset
ssalbdivad
ssalbdivadā€¢5w ago
Most of what's on that page exists in TS and all exists in ArkType other than negated types. What makes it so good?
Chakib Ouhajjou
Chakib Ouhajjouā€¢5w ago
what make it so good, is less rules. smaller grammar. easier to read
package config

import "strings"

#Field: #Boolean | #Integer | #Float | #Monetary | #Text | #Html |
#Date | #Datetime | #Selection | #Binary | #Image |
#Many2One | #One2Many | #Many2Many

#decimal_precision: string
#callable: string

#AbstractField: {
type: "boolean" | "integer" | "float" | "monetary" | "text" | "html" |
"date" | "datetime" | "selection" | "binary" | "image" |
"many2one" | "one2many" | "many2many"

name: string
label: string | *strings.Replace(strings.ToTitle(name), "_", " ", -1)
help?: string

readonly?: bool
if compute != _|_ {readonly: true}
if related != _|_ {readonly: true}

required?: bool
default?: _
groups?: string
company_dependent?: bool
copy?: bool | *true
store?: bool | *true
aggregator?:
"array_agg" | "count" | "count_distinct" | "bool_and" |
"bool_or" | "max" | "min" | "avg" | "sum"

compute?: string
if compute != _|_ { // _|_ means undefined in cue
inverse?: string
search?: string
export?: bool
// LATER// depends: [string]
}

related?: string
}

#Boolean: {
#AbstractField

type: "boolean"
zod: {
required_error?: string
required_error?: string
}
}
package config

import "strings"

#Field: #Boolean | #Integer | #Float | #Monetary | #Text | #Html |
#Date | #Datetime | #Selection | #Binary | #Image |
#Many2One | #One2Many | #Many2Many

#decimal_precision: string
#callable: string

#AbstractField: {
type: "boolean" | "integer" | "float" | "monetary" | "text" | "html" |
"date" | "datetime" | "selection" | "binary" | "image" |
"many2one" | "one2many" | "many2many"

name: string
label: string | *strings.Replace(strings.ToTitle(name), "_", " ", -1)
help?: string

readonly?: bool
if compute != _|_ {readonly: true}
if related != _|_ {readonly: true}

required?: bool
default?: _
groups?: string
company_dependent?: bool
copy?: bool | *true
store?: bool | *true
aggregator?:
"array_agg" | "count" | "count_distinct" | "bool_and" |
"bool_or" | "max" | "min" | "avg" | "sum"

compute?: string
if compute != _|_ { // _|_ means undefined in cue
inverse?: string
search?: string
export?: bool
// LATER// depends: [string]
}

related?: string
}

#Boolean: {
#AbstractField

type: "boolean"
zod: {
required_error?: string
required_error?: string
}
}
Narrowing done right.
ssalbdivad
ssalbdivadā€¢5w ago
It's easy to read because you're familiar with it haha.
Chakib Ouhajjou
Chakib Ouhajjouā€¢5w ago
no come on !! hehe you can read the doc in no time hehe
ssalbdivad
ssalbdivadā€¢5w ago
I did read some of the docs though. Shares a lot of features with ArkType, plus some specific syntax for computation like comprehensions. I'm am curious about the underlying type system- how unions, tuples and intersections are reduced. To me that is the pinnacle of what makes a good set-based type system- the ability to reduce and normalize so that any two types can be accurately compared. Saw some cool tricks with cyclic references but they feel a bit more like a party trick. At least the ones I saw haha- e.g. it being legal to define a scope where a: b and b: a
Chakib Ouhajjou
Chakib Ouhajjouā€¢5w ago
since everyting is a value in cuelang you can merge any two type sets. you can merge a string and a value to a value if they dont conflict cuelang is not turing complete. so it's a specialized tool. it's also order independent
ssalbdivad
ssalbdivadā€¢5w ago
Have you tried intersection logic in arktype? merging a string and a value is one of the simplest things it does hahah
Chakib Ouhajjou
Chakib Ouhajjouā€¢5w ago
No description
ssalbdivad
ssalbdivadā€¢5w ago
Haha check out tuple intersection tests in arktype... slightly different level of complexity šŸ˜› E.g.
// based on the equivalent type-level test from @ark/util
it("kitchen sink", () => {
const l = type([
{ a: "0" },
[{ b: "1" }, "?"],
[{ c: "2" }, "?"],
"...",
[{ d: "3" }, "[]"]
])
const r = type([
[{ e: "4" }, "?"],
[{ f: "5" }, "?"],
"...",
[{ g: "6" }, "[]"]
])
const result = l.and(r)

const expected = type([
{ a: "0", e: "4" },
[{ b: "1", f: "5" }, "?"],
[{ c: "2", g: "6" }, "?"],
"...",
[{ d: "3", g: "6" }, "[]"]
])

attest(result.expression).snap(
"[{ a: 0, e: 4 }, { b: 1, f: 5 }?, { c: 2, g: 6 }?, ...{ d: 3, g: 6 }[]]"
)

attest<typeof expected>(result)
attest(result.expression).equals(expected.expression)
})

it("prefix and postfix", () => {
const l = type(["...", [{ a: "0" }, "[]"], { b: "0" }, { c: "0" }])
const r = type([{ x: "0" }, { y: "0" }, "...", [{ z: "0" }, "[]"]])

const expected = type([
{ a: "0", x: "0" },
{ a: "0", y: "0" },
"...",
[{ a: "0", z: "0" }, "[]"],
{ b: "0", z: "0" },
{ c: "0", z: "0" }
])
.or([
{ a: "0", x: "0" },
{ b: "0", y: "0" },
{ c: "0", z: "0" }
])
.or([
{ b: "0", x: "0" },
{ c: "0", y: "0" }
])

const lrResult = l.and(r)
attest(lrResult.json).snap(expected.json)
const rlResult = r.and(l)
attest(rlResult.json).snap(expected.json)
})

it("reduces minLength", () => {
const t = type(["number", "number", "...", "number[]", "number"])
const expected = type("number[]>=3")
attest(t.json).equals(expected.json)
})
// based on the equivalent type-level test from @ark/util
it("kitchen sink", () => {
const l = type([
{ a: "0" },
[{ b: "1" }, "?"],
[{ c: "2" }, "?"],
"...",
[{ d: "3" }, "[]"]
])
const r = type([
[{ e: "4" }, "?"],
[{ f: "5" }, "?"],
"...",
[{ g: "6" }, "[]"]
])
const result = l.and(r)

const expected = type([
{ a: "0", e: "4" },
[{ b: "1", f: "5" }, "?"],
[{ c: "2", g: "6" }, "?"],
"...",
[{ d: "3", g: "6" }, "[]"]
])

attest(result.expression).snap(
"[{ a: 0, e: 4 }, { b: 1, f: 5 }?, { c: 2, g: 6 }?, ...{ d: 3, g: 6 }[]]"
)

attest<typeof expected>(result)
attest(result.expression).equals(expected.expression)
})

it("prefix and postfix", () => {
const l = type(["...", [{ a: "0" }, "[]"], { b: "0" }, { c: "0" }])
const r = type([{ x: "0" }, { y: "0" }, "...", [{ z: "0" }, "[]"]])

const expected = type([
{ a: "0", x: "0" },
{ a: "0", y: "0" },
"...",
[{ a: "0", z: "0" }, "[]"],
{ b: "0", z: "0" },
{ c: "0", z: "0" }
])
.or([
{ a: "0", x: "0" },
{ b: "0", y: "0" },
{ c: "0", z: "0" }
])
.or([
{ b: "0", x: "0" },
{ c: "0", y: "0" }
])

const lrResult = l.and(r)
attest(lrResult.json).snap(expected.json)
const rlResult = r.and(l)
attest(rlResult.json).snap(expected.json)
})

it("reduces minLength", () => {
const t = type(["number", "number", "...", "number[]", "number"])
const expected = type("number[]>=3")
attest(t.json).equals(expected.json)
})
The prefix + postfix one I think is especially cool as its the only intersection I know if that splits into a union
Chakib Ouhajjou
Chakib Ouhajjouā€¢5w ago
let me convert it
ssalbdivad
ssalbdivadā€¢5w ago
If it can represent and reduce the prefix + postfix case I will be blown away and immediately sold Cuelang's description of unification though is exactly the core principle of ArkType I mentioned so it's cool to see the alignment there. ArkType is extremely rigorous about that process- intersections that result in unsatisfiable types will error and we can compare equivalent types like number[] >= 3 and [number, number, ...number[], number] even if they're represented differently
Chakib Ouhajjou
Chakib Ouhajjouā€¢5w ago
can you please provide the simplest example from that code ?
ssalbdivad
ssalbdivadā€¢5w ago
Do you know how variadic elements in tuples work? ...number[] means an arbitrary number of consecutive number elements Not sure if that is a feature or not
Chakib Ouhajjou
Chakib Ouhajjouā€¢5w ago
yes // A is an open list with 3 predefined elements. // Any additional elements must be ints. A: [1, 2, 3, ...int] Lists: https://cuelang.org/docs/tour/types/lists/
ssalbdivad
ssalbdivadā€¢5w ago
Yeah but they only support trailing rest elements and no optional So types like those from my example can't exist in the first place The more I see of their docs though it really does parallel arktype in a lot of ways in terms of these reductions. If the underlying type system just can't represent as much it's hard to compare the unification
Chakib Ouhajjou
Chakib Ouhajjouā€¢5w ago
so you what a first and a last element and some stuff in between ?
ssalbdivad
ssalbdivadā€¢5w ago
That's a variadic element. There are also optional elements in TS/ArkType which is a single element that may or may not be present In ArkType they are called prefix, optional, variadic and postfix
Chakib Ouhajjou
Chakib Ouhajjouā€¢5w ago
wow ! do people use this stuff ?
ssalbdivad
ssalbdivadā€¢5w ago
Advanced users mostly, but yeah. Like I said you're definitely underestimating TS before you get to know it šŸ˜Š I will say though even though ArkType mirrors TS, the philosophy of the type system itself is much closer to Cue
Chakib Ouhajjou
Chakib Ouhajjouā€¢5w ago
I'am a proponent of least design principle
ssalbdivad
ssalbdivadā€¢5w ago
There's a lot of overlap for stuff like intersecting ranges which is really cool to see
Chakib Ouhajjou
Chakib Ouhajjouā€¢5w ago
I do build apps not libs. so I tend to go with simple designs. so that I can 'show' code to business people it's kindof a lowcode mindset
ssalbdivad
ssalbdivadā€¢5w ago
Yeah I think not cooking too hard for app code is probably good haha ArkType is complex internally but the goal is to make things as simple as possible externally which hopefully is a good trade off
Chakib Ouhajjou
Chakib Ouhajjouā€¢5w ago
I guess it's the workaround for Variadic lists.
import "list"

#goodList: {
l: [int, ..._] & list.MinItems(2)
lastIsString: l[len(l) - 1] & string
}

myList: #goodList & {
l: [1, true, false, "hello"]
}
import "list"

#goodList: {
l: [int, ..._] & list.MinItems(2)
lastIsString: l[len(l) - 1] & string
}

myList: #goodList & {
l: [1, true, false, "hello"]
}

Did you find this page helpful?