A
arktype•2mo ago
jacksteamdev

Union Fallthrough?

I'm transforming some input using morphs like so:
const weightUnit = type({
value: 'number',
unit: toStringUnion(weightAliasList),
})
.pipe(({ value: fromValue, unit: fromUnit }) => {
const stdUnit = weightAliases[fromUnit as StandardWeightUnit];
const factor = standardUnitConversions.mg[stdUnit];
const toValue = Math.ceil(fromValue * factor);
return { value: toValue, unit: 'mg' };
})
.to({ value: 'number.integer', unit: '"mg"' });
const weightUnit = type({
value: 'number',
unit: toStringUnion(weightAliasList),
})
.pipe(({ value: fromValue, unit: fromUnit }) => {
const stdUnit = weightAliases[fromUnit as StandardWeightUnit];
const factor = standardUnitConversions.mg[stdUnit];
const toValue = Math.ceil(fromValue * factor);
return { value: toValue, unit: 'mg' };
})
.to({ value: 'number.integer', unit: '"mg"' });
And creating a union type like this, where otherUnit.unit is "string":
const normalizedUnit = weightUnit.or(volumeUnit).or(sizeUnit).or(otherUnit);
const normalizedUnit = weightUnit.or(volumeUnit).or(sizeUnit).or(otherUnit);
I expected otherUnit to catch non-matching values, but I'm getting this error:
ParseError: An unordered union of a type including a morph and a type with overlapping input is indeterminate:
Left: (In: { unit: string, value: number }) => Out<{ name: string, unit: "unit", value: number % 1 }>
Right: (In: { unit: "cup" | "cups" | "l" | "liter" | "liters" | "milliliter" | "milliliters" | "ml" | "tablespoon" | "tablespoons" | "tbsp" | "teaspoon" | "teaspoons" | "tsp", value: number }) => Out<{ unit: "ml", value: number % 1 }>
ParseError: An unordered union of a type including a morph and a type with overlapping input is indeterminate:
Left: (In: { unit: string, value: number }) => Out<{ name: string, unit: "unit", value: number % 1 }>
Right: (In: { unit: "cup" | "cups" | "l" | "liter" | "liters" | "milliliter" | "milliliters" | "ml" | "tablespoon" | "tablespoons" | "tbsp" | "teaspoon" | "teaspoons" | "tsp", value: number }) => Out<{ unit: "ml", value: number % 1 }>
Which makes sense if the union is unordered. Is there a way to make this union ordered? If not, is there a more fluent way to do this than wrapping it in a function?
5 Replies
TizzySaurus
TizzySaurus•2mo ago
Are you aware of type("...", arrOfTypes)? That may be what you're looking for with toStringUnion It's not entirely clear to me what you're trying to achieve though
jacksteamdev
jacksteamdevOP•2mo ago
Hi @TizzySaurus ! My goal is to normalize recipe ingredient measurements before writing to the db. Later I have an SQL function that combines ingredients from different recipes into a grocery list. This is toStringUnion:
const weightAliases = {
oz: 'oz',
lb: 'lb',
lbs: 'lb',
pound: 'lb',
pounds: 'lb',
// other...
} as const satisfies Record<string, StandardWeightUnit>;

const weightAliasList = Object.keys(weightAliases) as StandardWeightUnit[];

const toStringUnion = (aliasList: string[]) =>
aliasList.map((str) => `"${str}"`).join(' | ') as '"string"';
const weightAliases = {
oz: 'oz',
lb: 'lb',
lbs: 'lb',
pound: 'lb',
pounds: 'lb',
// other...
} as const satisfies Record<string, StandardWeightUnit>;

const weightAliasList = Object.keys(weightAliases) as StandardWeightUnit[];

const toStringUnion = (aliasList: string[]) =>
aliasList.map((str) => `"${str}"`).join(' | ') as '"string"';
It's not exactly a minimal repro, sorry 😓 Right now I'm wrapping it in a function:
export function normalizedUnit(unit: {
amount: number;
unit: string;
}): ReturnType<typeof normalizedUnitUnion> | ReturnType<typeof namedUnit> {
const normalized = normalizedUnitUnion(unit);
if (normalized instanceof type.errors) {
return namedUnit(unit);
}
return normalized;
}
export function normalizedUnit(unit: {
amount: number;
unit: string;
}): ReturnType<typeof normalizedUnitUnion> | ReturnType<typeof namedUnit> {
const normalized = normalizedUnitUnion(unit);
if (normalized instanceof type.errors) {
return namedUnit(unit);
}
return normalized;
}
Which works, but I was curious if there was a better way to do it.
TizzySaurus
TizzySaurus•2mo ago
toStringUnion should just be type("...", aliasList). I wouldn't even wrap it in a function since then you loose the type inference -- just use it directly
const t = type("...", [1, 2, 3])
type t = t.infer // 1 | 2 | 3
const t = type("...", [1, 2, 3])
type t = t.infer // 1 | 2 | 3
There's also type.enumerated([1, 2, 3]) iirc What is normalizedUnitUnion and namedUnit? Your type also seems to be wrong because weightAliases is Record<string, StandardWeightUnit>, but then you do Object.keys(weightAliases) as StandardWeightUnit[]. But the keys are string, not StandardWeightUnit. It's the values that are StandardWeightUnit But in theory you could do something like....
const weightAliases = {
oz: 'oz',
lb: 'lb',
lbs: 'lb',
pound: 'lb',
pounds: 'lb',
// other...
} as const satisfies Record<string, StandardWeightUnit>;

const weightNames = Object.keys(weightAliases) as StandardWeightName[];
const weightUnits = Object.values(weightAliases) as StandardWeightUnit[];

const nameUnion = type.enumerated(weightNames);
const unitUnion = type.enumerated(weightUnits);


const normalizedUnit = type({amount: "number", unit: nameUnion.or(unitUnion)});
const weightAliases = {
oz: 'oz',
lb: 'lb',
lbs: 'lb',
pound: 'lb',
pounds: 'lb',
// other...
} as const satisfies Record<string, StandardWeightUnit>;

const weightNames = Object.keys(weightAliases) as StandardWeightName[];
const weightUnits = Object.values(weightAliases) as StandardWeightUnit[];

const nameUnion = type.enumerated(weightNames);
const unitUnion = type.enumerated(weightUnits);


const normalizedUnit = type({amount: "number", unit: nameUnion.or(unitUnion)});
Fwiw there's also
const nameUnion = type("keyof", weightAliases)
const nameUnion = type("keyof", weightAliases)
iirc
jacksteamdev
jacksteamdevOP•2mo ago
@TizzySaurus Thanks for the help! I wasn't able to get type("keyof", weightAliases) working, it requires weightAliases to be a Type, as is it can't resolve the property values. I was able to reduce casting with type.enumerated(...weightNames) and an Object.keys wrapper:
const keys = <T extends Record<string, unknown>>(obj: T) => Object.keys(obj) as (keyof T)[];
const keys = <T extends Record<string, unknown>>(obj: T) => Object.keys(obj) as (keyof T)[];
I think what I was looking for was essentially if ... else support for string matching:
// if string is a, b, or c
type('"a"|"b"|"c"')
.pipe(doSomething)
// otherwise if it's a string
.or(type('string').pipe(doSomethingElse));
// if string is a, b, or c
type('"a"|"b"|"c"')
.pipe(doSomething)
// otherwise if it's a string
.or(type('string').pipe(doSomethingElse));
Maybe subset matching... but I got there in the end.
ssalbdivad
ssalbdivad•2mo ago
There's a match API I'll release soon for a case like this. As is, you'd just want to write it as string and then do the narrowing logic in the pipe function itself. You can probably imagine why unless someone explicitly opts into ordering here, having a union where multiple branches overlap and have different piping logic is probably not desirable- but match will be perfect for that!

Did you find this page helpful?