-
Notifications
You must be signed in to change notification settings - Fork 329
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Keep Refinements? #373
Comments
Hi @mikecann , the problem with "old" refinements is that they don't carry the information at the type level, so for instance, with your definition, the following is allowed: const details: t.TypeOf<typeof NewVideoMessageDetails> = {
subject: "", // length = 0! but allowed
sender: {
name: "name",
email: "foo" // invalid! but allowed
}
}; The cost you have to pay to use refinements is just adding one more interface per refinement you define, which looks legit if you consider the benefits. So for instance: // this is the added brand
interface EmailBrand {
readonly Email: unique symbol;
}
// this is the same as before + the t.Branded annotation
const Email = t.brand(
t.string,
(s): s is t.Branded<string, EmailBrand> => rxEmail.test(s),
"Email"
);
// this is the added brand
interface MaxLengthString<N> {
readonly MaxLengthString: unique symbol;
readonly length: N;
}
const MaxLengthString = <N extends number>(len: N) =>
// this is the same as before + the t.Branded annotation
t.brand(
t.string,
(s): s is t.Branded<string, MaxLengthString<N>> => s.length <= len,
"MaxLengthString"
);
// ...
const details: t.TypeOf<typeof NewVideoMessageDetails> = {
subject: "", // correctly errors: Type 'string' is not assignable to type 'Branded<string, MaxLengthString<50>>'
sender: {
name: "name",
email: "foo" // correctly errors: Type 'string' is not assignable to type 'Branded<string, EmailBrand>'
}
};
yes, bonus: const EmailSubject = MaxLengthString(50);
const Another = MaxLengthString(20);
declare let one: t.TypeOf<typeof EmailSubject>;
declare let two: t.TypeOf<typeof Another>;
one = two; // Type '20' is not assignable to type '50'
two = one; // Type '50' is not assignable to type '20' |
Ye.. Its interesting as I had never seen that unique keyword before.. So I guess its basically undoing the "structural" typing nature of Typescript and making it more "nominal" ? (not sure if I got the terminology right here) So it works more like other functional languages? Im not 100% sold the extra typing here is worth the benefit but I guess io-ts or fp-ts was never going to be perfect while building ontop of TS :) |
Correct. The TS team is currently investigating different ways to add support for nominal types directly in the language, so hopefully things are going to improve in the future. |
@mikecann @giogonzo I was actually planning on opening an issue just like this this week. Would you consider reopening? Even though you are right that branded types have better type safety, the extra boilerplate is often not worth it. io-ts doesn't need to perfectly correspond to the static type system in all cases to be useful. One other problem with branded types is they're not just a pain to set up, they're also harder to use, and sometimes require non-trivial refactors. In the team I'm on, if we didn't have refinements, I'd worry people would be disincentivised from adding the extra type safety of a predicate verifier at all. To use the example above, const details: t.TypeOf<typeof NewVideoMessageDetails> = {
subject: "Hi", // incorrectly errors: Type 'string' is not assignable to type 'Branded<string, MaxLengthString<50>>'
sender: {
name: "name",
email: "a@b.com" // incorrectly errors: Type 'string' is not assignable to type 'Branded<string, EmailBrand>'
}
}; (in fact, even in the example given, To be clear, I 100% agree that |
Yes, to clarify, they disallow values of the "carrier" type (e.g. string here), avoiding a whole set of errors at compile time (which is the point of having brands). The example was a bit contrived (and wrong in some sense, as you point out) but, generally speaking, you'll obtain branded values from:
For 1., they should be validated anyway, no overhead in usage export function unsafeEmail(s: string): Email {
return s as any
} Usages of this function are in my experience very rare, usually for testing purposes or developing with mock values - something that is not part of the final source code anyway.
Actually I believe this is one of the main goals of io-ts, being a precise mapping to the TS type system. I understand this:
and it makes me think sometimes refinements are just used to e.g. provide nice errors to the users of your system, but not really to obtain more type-safety throughout the codebase. For more context, please also have a look at #265 and #304 where Giulio does a great job at explaining the whys behind brands and "smart constructors" Hope this helps :) |
Here's another use case. We have a system which builds a server with an io-ts schema. The schema is used to decode incoming requests, but it's also used to generate a client which encodes the request before sending with an http client. This gives very nice type safety (the kind joi, class-validator, runtypes etc. can't give) when used with types like schema: ...
search: {
request: t.type({
query: t.partial({ before: DateFromISOString, after: DateFromISOString })
}),
response: t.type({ ... }),
}
... The library we use decodes requests for us, so server implementors only have to deal with The nice thing is, clients can now also only deal with client: await myClient.search({ query: { before: new Date('2020'), after: new Date('2010') } })
// library uses io-ts to encode this and send `GET /search?before=2020&after=2010` The benefit is that invalid/unsupported formats are now a compile-time error on the client side - this gives await myClient.search({ query: { before: 'tomorrow', after: 'yesterday' } }) Branded types make things tricky. Let's say we want to add a new schema: ...
search: {
request: t.type({
query: t.partial({ limit: t.Int, before: DateFromISOString, after: DateFromISOString })
}),
response: t.type({ ... }),
}
... client: await myClient.search({ query: { limit: 10, before: new Date('2020') } }) This will error, because typescript doesn't know that I would argue this is an example of typescript and io-ts not corresponding perfectly already. Right now, the solution is easy: we use Is the plan to keep refinements around at least until a solution for microsoft/TypeScript#202 lands? Re-evaluating then may be a good compromise, since likely branding will change at that point anyway. |
(sorry I'm from mobile) Thanks for the explanation, yes we use similar setups and it's just a joi (😂) to work with it fullstack
I'd argue that client developers should validate the limit if it's user input (basically my case 1. from last comment). If it's not, then I'd say this is case 2. and values of type Int are hardcoded - in this scenario they can be hardcoded using the cast once in the project as described above |
Im reopening this because I think I agree with the comments of @mmkal personally im not sure I would want to use io-ts if we have to use t.brand with the current syntax. For me (and probably a lot of other TS developers) Typing is about pragmatism and im always looking for a way to increase safely without extra typing overhead and t.brand really does add a lot more syntax and clutter, confusing the code. If the solution is to pull in another library (joi, class-validator or whatever) then maybe I will go with just one of those in the future. Having said that I do appreciate that io-ts really is trying to be something different and is trying to add nominal types on Typescript, thus anything that weakens that goal goes against the library's purpose. Its a tough one. |
I've run into this as well and it's kept me from actually using as a workaround for the cases where I really do want a branded codec, in the case of calling a REST API, for example, I try to include one-off convenience versions of functions that replace branded input types with unbranded ones, and then decode in the helpers before actually firing off the requests. but this is very one-off as of now. For anyone interested these types will strip brands from a given branded type, if any exists (the resulting types on hover in VSCode are pretty messy, though): // These are not tested thoroughly so use at your own risk
/**
* Remove all brands from a type, if any are present
*/
type Unbranded<T> = T extends Branded<infer Orig, any>
? { recurse: Unbranded<Orig>; end: Orig }[Orig extends Branded<any, any>
? "recurse"
: "end"]
: T;
/**
* Remove all brands from children of an object
*
* Doesn't remove the brand from T if T itself is branded
*/
type DeepUnbrandedChildren<T> = {
[key in keyof T]: {
recurse: DeepUnbrandedChildren<Unbranded<T[key]>>;
end: Unbranded<T[key]>;
}[Unbranded<T[key]>[keyof Unbranded<T[key]>] extends Branded<any, any>
? "recurse"
: "end"];
};
/**
* Remove brands from a type and any of its children
*/
type DeepUnbranded<T> = DeepUnbrandedChildren<Unbranded<T>>; |
We chose this library, because it ensures TS types and validation are in sync and we can choose where finer validations take place. We actually want support of potentially invalid data (e.g. form state, store state) to be allowed, we only want that basic TS types (e.g. If this feature gets removed, we either stop updating or move to a fork which still supports it, or modify OpenAPI -> io-ts schema generator to generate joi+TS types. |
Another issue where it turned out to be annoying for my teammate was that we have a Maybe we can make an abstraction (doesn't have to be in |
Ah, that looks useful! Thanks for the tip, I’ll figure out later how I can use that to make an easily usable API for that particular use case (ideally not involving manual application of the codec to values repeatedly). |
I don't hate the idea of deprecating refinement, but I do think some work could be done to address the amount of boilerplate necessary. This is a runtime type library, not a validation library per se. |
Another issue with the brands that they use a Unfortunately, because of the |
In the experimental module import { Refinement } from 'fp-ts/function'
export declare const refine: <A, B extends A>(
refinement: Refinement<A, B>, // <= this is `(a: A) => a is B`
id: string
) => (<I>(from: Decoder<I, A>) with "agnostic" I mean that, based on the So you can have a branded type (which is advised) with a custom encoding... import { pipe } from 'fp-ts/function'
import * as D from 'io-ts/Decoder'
type Int = number & { __brand__: 'Int' } // <= choose your preferred encoding here
// const Int: D.Decoder<unknown, Int>
const Int = pipe(
D.number,
D.refine((n): n is Int => Number.isInteger(n), 'Int')
) ...or a much simpler resulting type (if you really want to) // const Integer: D.Decoder<unknown, number>
const Integer = pipe(
D.number,
D.refine((n): n is number => Number.isInteger(n), 'Integer')
) I could revert the import { Predicate, Refinement } from 'fp-ts/function'
import * as t from 'io-ts'
// new overloading
export declare function refinement<C extends t.Any, B extends t.TypeOf<C>>(
codec: C,
refinement: Refinement<t.TypeOf<C>, B>,
name?: string
): t.RefinementC<C, B>
// old signature
export declare function refinement<C extends t.Any>(
codec: C,
predicate: Predicate<t.TypeOf<C>>,
name?: string
): t.RefinementC<C>
type Int = number & { __brand__: 'Int' }
// const Int: t.RefinementC<t.NumberC, Int>
const Int = refinement(t.number, (n): n is Int => Number.isInteger(n), 'Int')
// const Integer: t.RefinementC<t.NumberC, number>
const Integer = refinement(t.number, Number.isInteger, 'Integer') |
Please @gcanti bring the new overloading I have many brand types defined with I don’t understand why |
Thank you so much @gcanti |
Hi,
Apologies if this doesn't follow the usual issue convention.
I noticed that refinements are now deprecated in favour of brands. Im just wondering the reason for this exactly as the new syntax for brands is considerably more verbose and is limited.
Previously I enjoyed writing nice terse validation such as:
Now with brands this would be considerably more verbose and im not even sure if
MaxLengthString
is even possible.Thoughts?
The text was updated successfully, but these errors were encountered: