feat: Add generic defaults to generated types - #110
Conversation
|
The hard part of this one holds up, so let me start there rather than bury it. TypeScript only allows defaults on a trailing run of parameters, and dropping one in the middle has to take every default before it. The core trailing-default logic held up across everything I tried beyond the cases you already test: the undeclared parameter nested inside the default ( Everything below was reproduced on Three symptoms of one gapA default is rendered as a type, but it is not propagated through the code paths that resolve types as references. A concrete type whose TypeScript name collides with a parameter. Valid Rust: #[derive(Tsify)] pub struct T { pub z: u32 }
#[derive(Tsify)] struct Later<U = crate::T, T = String> { u: U, t: T }
#[derive(Tsify)] struct Earlier<T, U = crate::T> { t: T, u: U }export interface Later<U = T, T = string> { ... } // error TS2744
export interface Earlier<T, U = T> { ... } // no error, wrong type
A default inside a namespaced enum resolves against the namespace. #[derive(Tsify)] pub struct Error { pub m: String }
#[derive(Tsify)]
#[tsify(namespace)]
enum Outcome<T = crate::Error> { Done(T), Error(String) }declare namespace Outcome {
export type Done<T = Error> = { Done: T };
export type Error = { Error: string };
}
export type Outcome<T = Error> = Outcome.Done<T> | Outcome.Error;The same written default means two different types: inside the namespace A renamed declaration named in a default. One aside, so it does not surprise you while testing: On #76Until #76 is fixed, merging this first would turn a visible diagnostic into a silently wrong default. That is a sequencing constraint for me, not a defect for you to address in this PR — and your reading in the description already called it. The result is a little more concerning than your description suggests. With
The Rust type is What would unblock itI would like this in. Two conditions:
Everything else is mine. I am responsible for the merge order relative to #76, and for the The parameter-list handling here is careful, and the three items are one piece of missing wiring rather than three separate mistakes. |
A default is read where the parameters are, so its names resolve against
the parameter list before the declarations around it. The first cut
rendered a default as a type and stopped there, which left three ways for
one to reach something other than the Rust it came from. All three are the
same missing step, so `resolve_defaults` now makes it once, next to the
`TsTypeParam` it settles, for the derive and `#[declare]` alike.
**A type whose TypeScript name a parameter has taken.** The check only
judged refs whose source is `TypeParam`, so a concrete type went through
unexamined:
struct Later<U = crate::T, T = String> // export interface Later<U = T, T = string>
struct Earlier<T, U = crate::T> // export interface Earlier<T, U = T>
`crate::T` loses its qualification when it renders. `Later` is a `TS2744` --
a default may only name a parameter declared before it -- and `Earlier` is
worse, because it compiles and `T` now means the parameter. Neither can be
spelled around inside a parameter list, where a parameter shadows, so the
default goes and the trailing-run rule takes the defaults before it.
**A default inside a namespace.** The alias collection and `prefix_type_refs`
walked `type_ann` and nothing else, so a default was left to resolve against
the namespace it is printed in:
enum Outcome<T = crate::Error> { Done(T), Error(String) }
`Outcome.Done`'s `T` meant the sibling variant rather than the interface --
nothing in the declaration is a syntax error, so it surfaced only where the
two met. A default is collected and rewritten like any other reference now,
and reaches the same `__OutcomeError` alias.
**A renamed declaration named in a default** is not fixed here, and is not
particular to defaults: a field of the same type emits the same dangling
`Original`, which is madonoharu#103. Both are pinned side by side so the day madonoharu#103
lands, they move together. `#[tsify(type_params = "T = Renamed")]` says it
in the meantime.
The e2e crate follows madonoharu#111's shape -- one source built under both features,
since a default renders like any other type and `Option` and `HashMap`
inside one move with the feature. It also records what a default does to
madonoharu#76: the signature still loses its argument, and `Wrapper` now resolves
through the default rather than failing, so that line should change when madonoharu#76
is fixed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
9cec4d2 to
bf00c4e
Compare
Declare a default type parameter with its default
A default on a Rust type parameter never reached TypeScript:
The declaration path never read
syn::TypeParam::default. #67 touched defaults from the other side — they broke the Rust build — and theDECLassertions added with it snapshotted whatever came out at the time, so those expectations are updated here.What changes
type_paramson the three decls goes fromVec<String>toVec<TsTypeParam>:Name and default have to be separate because a parameter is written three ways and only one may carry a default: the declaration (
A, B = number), the reference a namespaced enum unions over (Spaced.A<T>, where a default is a syntax error), and identity — the "is this ref my own parameter" filter, the affix exception list,prefix_type_refs— which matches on.name.That distinction is the whole difficulty. Formatting
"T = number"into the existingVec<String>breaks a#[tsify(namespace)]enum three ways at once:Thoisted into a__SpacedTalias as if foreign, the affix applied to it, andSpaced.A<T = number>in the union, which does not parse.Defaults render through
TsType::from_syn_typelike any other type —T = Vec<Option<u32>>becomesT = (number | null)[], or| undefinedunderjs.#[declare]aliases get the same, and#[tsify(type_params = "T = string")]keeps working verbatim.Two cases where a default can't be kept. One that names a parameter no field mentions is dropped, since that parameter is declared nowhere to point at; and then so is every default before it, because TypeScript only allows defaults on a trailing run —
<B = number, C>is not valid.Breaking
If you use a default parameter, its
.d.tschanges. A reference naming every argument is unaffected; one that left the defaulted argument out was aTS2314before and now resolves.While #76 is open this makes that bug quieter:
fn bar(foo: Ts<Foo<i64>>)still writesbar(foo: Foo), which aT = Stringdefault resolves toFoo<string>— silently wrong where it used to fail loudly. Checked withtsc --strictboth ways. An argument for landing #76's fix alongside this, not against it.Testing
tests/generics.rscovers nested defaults, a default naming another parameter, both drop cases, the namespace split and thetype_paramsoverride;tsify-macroscovers#[declare].tests-e2e/test_defaults1builds real wasm and its.d.tspassestsc --noEmit --strict. All six existing e2e references are byte-identical.If this lands after #109
That PR adds
Decl::type_params()andname_type_params(), which resolve each declared parameter back to a Rust ident by string. Rebasing needs two edits:Decl::type_params()returns&[TsTypeParam], andparam.ident == *declaredbecomesparam.ident == declared.name. The stale comparison is a type error rather than a silent fallback, so it cannot pass unnoticed. The second CHANGELOG bullet also goes, since the masking it describes is gone once both are in.Disclaimer
This PR was created by using claude