I am using io-ts to define my typescript types, in order to do runtime type validation and type declarations from a single source.
In this usecase I would like to define an interface with a string member and that member should validate against a regular expression, e.g. a version string. So valid input would look like this:
{version: '1.2.3'}
The mechanism to do this seems to be via branded types and I came up with this:
import { isRight } from 'fp-ts/Either';
import { brand, Branded, string, type, TypeOf } from 'io-ts';
interface VersionBrand {
readonly Version: unique symbol; // use `unique symbol` here to ensure uniqueness across modules / packages
}
export const TypeVersion = brand(
string, // a codec representing the type to be refined
(value: string): value is Branded<string, VersionBrand> =>
/^\d \.\d \.\d $/.test(value), // a custom type guard using the build-in helper `Branded`
'Version' // the name must match the readonly field in the brand
);
export const TypeMyStruct = type({
version: TypeVersion,
});
export type Version = TypeOf<typeof TypeVersion>;
export type MyStruct = TypeOf<typeof TypeMyStruct>;
export function callFunction(data: MyStruct): boolean {
// type check
const validation = TypeMyStruct.decode(data);
return isRight(validation);
}
This works as expected for type validation inside my callFunction
method but I cannot invoke the function using a regular object, i.e. the following does not compile:
callFunction({ version: '1.2.3' });
This is failing with Type 'string' is not assignable to type 'Branded<string, VersionBrand>'
.
While the error message makes sense, because Version
is a specialization of string
I would like to allow a caller to invoke the function with any string and then do a runtime validation that would check against the regular expression. Still I'd like to have some typing information, so I would not like to define the input as any
.
Ideally there would be a way to derive a version VersionForInput
from Version
that uses the primitive data types for the branded fields, so it would be equivalent to:
interface VersionForInput { version: string }
Of course I could declare it explicitly but this would mean to duplicate the type definition to some extend.
Question: Is there a way in io-ts to derive an unbranded version from a branded version of a type? Is the use of a branded type even the right choice for this usecase? The goal is to do extra validation on top of a primitive type (e.g. extra regexp checks for a string value).
CodePudding user response:
I think you've asked sort of two questions so I'll try to answer both.
Extracting the the base type from a branded type
It is possible to introspect on which io-ts
codec is being branded via the type
field on an instance of Brand
.
TypeVersion.type // StringType
So it would be possible to write a function that consumes your struct type and creates a codec from the base. You could also extract the type using something like this:
import * as t from 'io-ts';
type BrandedBase<T> = T extends t.Branded<infer U, unknown> ? U : T;
type Input = BrandedBase<Version>; // string
Using the branded type
But I think instead of defining things in this way, I would instead define my struct input type and then define a refinement so that you only have to specify the parts of the codec that are refined from the input. This would be using the newer io-ts
api.
import { pipe } from 'fp-ts/function';
import * as D from 'io-ts/Decoder';
import * as t from 'io-ts';
import * as E from 'fp-ts/Either';
const MyInputStruct = D.struct({
version: D.string,
});
interface VersionBrand {
readonly Version: unique symbol; // use `unique symbol` here to ensure uniqueness across modules / packages
}
const isVersionString = (value: string): value is t.Branded<string, VersionBrand> => /^\d \.\d \.\d $/.test(value);
const VersionType = pipe(
D.string,
D.refine(isVersionString, 'Version'),
);
const MyStruct = pipe(
MyInputStruct,
D.parse(({ version }) => pipe(
VersionType.decode(version),
E.map(ver => ({
version: ver,
})),
)),
);
This would define the input type first, and then define a refinement on the input type to be your stricter internal decoder. If you had other values you could ...rest
them through in the E.map
to avoid repeating yourself.
To answer the question, "Should I even use a branded type", I think you should but I would like to offer a change of perspective about how to use your io-ts
validations. There is an interesting article about using parsing logic at the edge of your app so that you can parse once and rely on stronger types within the core of the application.
I would therefore suggest that you parse the Version
string as early as possible and then talk about the branded type everywhere else in the app. So for example, if the version comes in as a command line argument you could have something like:
import { pipe } from 'fp-ts/function';
import * as t from 'io-ts';
import * as E from 'fp-ts/Either';
// Version being the branded type
function useVersion(version: Version) {
// Does something with the branded type
}
function main (args: unknown) {
pipe(
MyStructType.decode(args) // accepts unknown
// Map is called on Right only and passes Lefts along so you can
// focus in on specifically what to do if validation succeeded
E.map(({ version }) => useVersion(version)),
// later on maybe handle Left
E.fold(
(l: t.Errors): void => console.error(l),
(r) => {},
),
),
}
CodePudding user response:
What is working fine for my usecase is to define an input type without branding and a refinement as an intersection of that type with branding, e.g. like so:
export const TypeMyStructIn = type({
version: string,
});
export const TypeMyStruct = intersection([
TypeMyStructIn,
type({
version: TypeVersion,
}),
]);
export type Version = TypeOf<typeof TypeVersion>;
export type MyStruct = TypeOf<typeof TypeMyStruct>;
export type MyStructIn = TypeOf<typeof TypeMyStructIn>;
export function callFunction(data: MyStructIn): boolean {
// type check
const validation = TypeMyStruct.decode(data);
return isRight(validation);
}