Skip to content

Commit b8abc8d

Browse files
milkyskiesclaude
andauthored
fix: [#1380] surface implicit Object methods on interop record types (#1382)
closes #1380 ## Summary TypeScript implicitly extends every interface from `Object`, which declares `toString()`, `toLocaleString()`, and `valueOf()`. Floe's interop loader only collected the members written in each interface body, so `url.toString()` failed with `E021` even though the WHATWG / `@types/node` URL interface relies on `Object` inheritance for that method. Same gap affected `Date.toString`, `Date.toLocaleString`, `Error.toString`, etc. The fix: - `wrap_boundary_type` now appends those three methods (when not already present) to every Record produced from the interop boundary — single source of truth in `IMPLICIT_OBJECT_METHOD_NAMES` (`crates/floe-core/src/interop/wrapper.rs`). - The same helper runs on the hand-crafted `Response`/`Error`/`Event` records in the checker (`crates/floe-core/src/checker.rs`). - `records_compatible` (`crates/floe-core/src/checker/type_compat.rs`) skips the implicit names on both sides of structural assignability, so plain Floe records are still assignable to wrapped param shapes — every JS object inherits these, so it would be wrong to require source records to declare them. User-defined Floe records (`type User = { ... }`) bypass `wrap_boundary_type`, so they still reject `.toString()` with E021 — only interop-boundary records gain the implicit methods. ## Test plan - [x] `crates/floe-core/src/checker/tests.rs::ambient_record_exposes_to_string` — `url.toString()` typechecks against an ambient URL with only `href` declared - [x] `crates/floe-core/src/checker/tests.rs::implicit_object_methods_do_not_block_assignment` — plain Floe record still assignable to wrapped foreign param - [x] `crates/floe-core/src/interop/tests.rs::explicit_to_string_overrides_implicit` — user-declared `toString(): number` keeps its signature - [x] Existing `wrap_*` tests updated via `record_with_object_methods` helper - [x] Quality gate on examples: `floe fmt` + `floe check` + `floe build` on `examples/todo-app/` and `examples/store/` — all pass - [x] `cargo test --release -p floe-core --lib` — 1615 passed - [x] `cargo clippy --all-targets --release -- -D warnings` — clean --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent b1b6856 commit b8abc8d

6 files changed

Lines changed: 201 additions & 16 deletions

File tree

crates/floe-core/src/checker.rs

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -318,7 +318,7 @@ impl Checker {
318318
// importing. Defined as Records so member access works through
319319
// the normal type-checking path.
320320

321-
let response_record = Type::Record(vec![
321+
let mut response_fields = vec![
322322
(
323323
"json".to_string(),
324324
Type::Function {
@@ -340,15 +340,19 @@ impl Checker {
340340
("statusText".to_string(), Type::String),
341341
("headers".to_string(), Type::Named("Headers".to_string())),
342342
("url".to_string(), Type::String),
343-
]);
343+
];
344+
crate::interop::inject_implicit_object_methods(&mut response_fields);
345+
let response_record = Type::Record(response_fields);
344346

345-
let error_record = Type::Record(vec![
347+
let mut error_fields = vec![
346348
("message".to_string(), Type::String),
347349
("name".to_string(), Type::String),
348350
("stack".to_string(), Type::option_of(Type::String)),
349-
]);
351+
];
352+
crate::interop::inject_implicit_object_methods(&mut error_fields);
353+
let error_record = Type::Record(error_fields);
350354

351-
let event_record = Type::Record(vec![
355+
let mut event_fields = vec![
352356
(
353357
"target".to_string(),
354358
Type::Record(vec![
@@ -374,7 +378,9 @@ impl Checker {
374378
required_params: 0,
375379
},
376380
),
377-
]);
381+
];
382+
crate::interop::inject_implicit_object_methods(&mut event_fields);
383+
let event_record = Type::Record(event_fields);
378384

379385
// Register as named types that display nicely and resolve to
380386
// records for member access via resolve_type_to_concrete

crates/floe-core/src/checker/tests.rs

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9764,3 +9764,95 @@ fn ambient_type_alias_resolves_as_type() {
97649764
.collect::<Vec<_>>()
97659765
);
97669766
}
9767+
9768+
// ── Implicit Object methods on interop records (#1380) ──────
9769+
9770+
#[test]
9771+
fn ambient_record_exposes_to_string() {
9772+
use crate::interop::ObjectField;
9773+
use crate::interop::TsType;
9774+
use crate::interop::ambient::AmbientDeclarations;
9775+
9776+
// Mirror @types/node's `URL` interface: declares `href` but not
9777+
// `toString`. Without the implicit Object-method injection,
9778+
// `url.toString()` fails with E021 even though every JS object
9779+
// has `toString` via `Object.prototype`.
9780+
let url_type = crate::interop::wrap_boundary_type(&TsType::Object(vec![ObjectField {
9781+
name: "href".to_string(),
9782+
ty: TsType::Primitive("string".to_string()),
9783+
optional: false,
9784+
}]));
9785+
let mut ambient = AmbientDeclarations::default();
9786+
ambient.types.insert("URL".to_string(), url_type);
9787+
9788+
let source = r#"
9789+
export let f(u: URL) -> string = {
9790+
u.toString()
9791+
}
9792+
"#;
9793+
let program = Parser::new(source).parse_program().expect("parse");
9794+
let checker = Checker::from_context(
9795+
HashMap::new(),
9796+
HashMap::new(),
9797+
Some(ambient),
9798+
HashSet::new(),
9799+
);
9800+
let diags = checker.check(&program);
9801+
assert!(
9802+
diags.iter().all(|d| d.severity != Severity::Error),
9803+
"url.toString() should typecheck; diags: {:?}",
9804+
diags
9805+
.iter()
9806+
.filter(|d| d.severity == Severity::Error)
9807+
.collect::<Vec<_>>()
9808+
);
9809+
}
9810+
9811+
#[test]
9812+
fn implicit_object_methods_do_not_block_assignment() {
9813+
use crate::interop::ObjectField;
9814+
use crate::interop::TsType;
9815+
use crate::interop::{DtsExport, FunctionParam};
9816+
9817+
// Foreign function expects `{ code: string }`. After interop wrapping
9818+
// the parameter shape gains `toString`/`toLocaleString`/`valueOf`, but
9819+
// the user passes a plain Floe record without them — assignability
9820+
// must still succeed. Exact pattern caught by #1380's regression.
9821+
let program = Parser::new(
9822+
r#"
9823+
import trusted { write } from "some-lib"
9824+
type Row = { code: string }
9825+
let _u = write(Row(code: "abc"))
9826+
"#,
9827+
)
9828+
.parse_program()
9829+
.expect("parse");
9830+
9831+
let export = DtsExport {
9832+
name: "write".to_string(),
9833+
ts_type: TsType::Function {
9834+
params: vec![FunctionParam {
9835+
ty: TsType::Object(vec![ObjectField {
9836+
name: "code".to_string(),
9837+
ty: TsType::Primitive("string".to_string()),
9838+
optional: false,
9839+
}]),
9840+
optional: false,
9841+
}],
9842+
return_type: Box::new(TsType::Primitive("void".to_string())),
9843+
},
9844+
};
9845+
let mut dts = HashMap::new();
9846+
dts.insert("some-lib".to_string(), vec![export]);
9847+
9848+
let checker = Checker::with_all_imports(HashMap::new(), dts);
9849+
let (diags, _, _, _) = checker.check_with_types(&program);
9850+
assert!(
9851+
diags.iter().all(|d| d.severity != Severity::Error),
9852+
"plain Floe record should still be assignable to wrapped param; diags: {:?}",
9853+
diags
9854+
.iter()
9855+
.filter(|d| d.severity == Severity::Error)
9856+
.collect::<Vec<_>>()
9857+
);
9858+
}

crates/floe-core/src/checker/type_compat.rs

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
use std::sync::Arc;
22

33
use super::*;
4+
use crate::interop::is_implicit_object_method;
45

56
/// Split a Foreign type name like `Foo<a, b<c>>` into a (base, args) pair
67
/// where args are the top-level comma-separated segments. Returns `None`
@@ -247,13 +248,17 @@ impl Checker {
247248
false
248249
}
249250
} else {
250-
// Field omitted — OK if it's Settable or Option
251-
ty.is_settable() || ty.is_option()
251+
// Field omitted — OK if it's Settable, Option, or one of the implicit
252+
// Object.prototype methods that the interop boundary injects on every
253+
// Record. Every JS object inherits these, so a Floe value lacking them
254+
// explicitly is still assignable to a parameter shape that does.
255+
ty.is_settable() || ty.is_option() || is_implicit_object_method(name)
252256
}
253257
})
254-
// No extra fields in actual that aren't in expected
258+
// No extra fields in actual that aren't in expected — implicit Object methods
259+
// on the actual side don't count as "extra" for the same reason.
255260
&& actual.iter().all(|(name, _)| {
256-
expected.iter().any(|(n, _)| n == name)
261+
expected.iter().any(|(n, _)| n == name) || is_implicit_object_method(name)
257262
})
258263
}
259264

crates/floe-core/src/interop.rs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,10 @@ pub use dts::{
3434
};
3535
pub use ts_types::{FunctionParam, ObjectField, TsType, ts_type_to_string};
3636
pub use tsgo::{TsgoResolver, TsgoResult};
37-
pub use wrapper::wrap_boundary_type;
37+
pub use wrapper::{
38+
IMPLICIT_OBJECT_METHOD_NAMES, inject_implicit_object_methods, is_implicit_object_method,
39+
wrap_boundary_type,
40+
};
3841

3942
// Re-export internal helpers so tests (and sibling submodules) can access via `use super::*`
4043
#[cfg(test)]

crates/floe-core/src/interop/tests.rs

Lines changed: 43 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,15 @@ use std::sync::Arc;
33

44
use super::*;
55

6+
/// Build a `Type::Record` with the implicit Object methods (`toString`,
7+
/// `toLocaleString`, `valueOf`) appended. Mirrors what `wrap_boundary_type`
8+
/// produces for `TsType::Object`, so boundary tests can express the
9+
/// declared fields without restating the implicit ones every time.
10+
fn record_with_object_methods(mut fields: Vec<(String, Type)>) -> Type {
11+
inject_implicit_object_methods(&mut fields);
12+
Type::Record(fields)
13+
}
14+
615
// ── Type Parsing ────────────────────────────────────────────
716

817
#[test]
@@ -270,7 +279,7 @@ fn wrap_object_wraps_fields() {
270279
let wrapped = wrap_boundary_type(&ts);
271280
assert_eq!(
272281
wrapped,
273-
Type::Record(vec![
282+
record_with_object_methods(vec![
274283
("name".to_string(), Type::String),
275284
("value".to_string(), Type::option_of(Type::Number)),
276285
])
@@ -288,7 +297,7 @@ fn wrap_optional_nullable_becomes_settable() {
288297
let wrapped = wrap_boundary_type(&ts);
289298
assert_eq!(
290299
wrapped,
291-
Type::Record(vec![(
300+
record_with_object_methods(vec![(
292301
"email".to_string(),
293302
Type::Settable(Arc::new(Type::String))
294303
),])
@@ -306,7 +315,7 @@ fn wrap_optional_non_nullable_becomes_option() {
306315
let wrapped = wrap_boundary_type(&ts);
307316
assert_eq!(
308317
wrapped,
309-
Type::Record(vec![(
318+
record_with_object_methods(vec![(
310319
"nickname".to_string(),
311320
Type::option_of(Type::String),
312321
)])
@@ -324,13 +333,43 @@ fn wrap_required_nullable_stays_option() {
324333
let wrapped = wrap_boundary_type(&ts);
325334
assert_eq!(
326335
wrapped,
327-
Type::Record(vec![(
336+
record_with_object_methods(vec![(
328337
"deletedAt".to_string(),
329338
Type::option_of(Type::String),
330339
)])
331340
);
332341
}
333342

343+
#[test]
344+
fn explicit_to_string_overrides_implicit() {
345+
// An interface that declares its own `toString` keeps that signature
346+
// — the implicit injection must not clobber a user-declared method.
347+
let ts = TsType::Object(vec![ObjectField {
348+
name: "toString".to_string(),
349+
ty: TsType::Function {
350+
params: vec![],
351+
return_type: Box::new(TsType::Primitive("number".to_string())),
352+
},
353+
optional: false,
354+
}]);
355+
let wrapped = wrap_boundary_type(&ts);
356+
let Type::Record(fields) = wrapped else {
357+
panic!("expected Record, got {wrapped:?}");
358+
};
359+
let to_string_count = fields.iter().filter(|(n, _)| n == "toString").count();
360+
assert_eq!(to_string_count, 1, "toString must not be duplicated");
361+
let (_, ty) = fields.iter().find(|(n, _)| n == "toString").unwrap();
362+
assert_eq!(
363+
ty,
364+
&Type::Function {
365+
params: vec![],
366+
return_type: Arc::new(Type::Number),
367+
required_params: 0,
368+
},
369+
"explicit toString signature should win over the implicit string-returning one",
370+
);
371+
}
372+
334373
// ── .d.ts Parsing ───────────────────────────────────────────
335374

336375
#[test]

crates/floe-core/src/interop/wrapper.rs

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -113,7 +113,7 @@ pub fn wrap_boundary_type(ts_type: &TsType) -> Type {
113113
TsType::Array(inner) => Type::Array(Arc::new(wrap_boundary_type(inner))),
114114

115115
TsType::Object(fields) => {
116-
let wrapped: Vec<(String, Type)> = fields
116+
let mut wrapped: Vec<(String, Type)> = fields
117117
.iter()
118118
.map(|f| {
119119
let ty = if f.optional && f.ty.is_nullable() {
@@ -130,6 +130,7 @@ pub fn wrap_boundary_type(ts_type: &TsType) -> Type {
130130
(f.name.clone(), ty)
131131
})
132132
.collect();
133+
inject_implicit_object_methods(&mut wrapped);
133134
Type::Record(wrapped)
134135
}
135136

@@ -196,6 +197,45 @@ pub(super) fn evaluate_indexed_access(object: &TsType, index: &TsType) -> Option
196197
}
197198
}
198199

200+
/// Names of the methods that TypeScript inherits from `Object` onto every
201+
/// interface. Single source of truth for both the wrapper (which injects them)
202+
/// and the assignability check (which knows to skip them).
203+
pub const IMPLICIT_OBJECT_METHOD_NAMES: &[&str] = &["toString", "toLocaleString", "valueOf"];
204+
205+
/// True if `name` is one of `IMPLICIT_OBJECT_METHOD_NAMES`.
206+
pub fn is_implicit_object_method(name: &str) -> bool {
207+
IMPLICIT_OBJECT_METHOD_NAMES.contains(&name)
208+
}
209+
210+
/// Append the methods that TypeScript inherits from `Object` to every interface
211+
/// (`toString`, `toLocaleString`, `valueOf`). TS treats every interface as
212+
/// implicitly extending `Object`, but Floe's interop only collects the members
213+
/// written in the interface body — so legitimate calls like `url.toString()`
214+
/// or `date.toLocaleString()` would otherwise miss. Existing fields with the
215+
/// same name win, so an interface that explicitly overrides `toString` keeps
216+
/// its declared signature.
217+
pub fn inject_implicit_object_methods(fields: &mut Vec<(String, Type)>) {
218+
for name in IMPLICIT_OBJECT_METHOD_NAMES {
219+
if fields.iter().any(|(n, _)| n == name) {
220+
continue;
221+
}
222+
// `valueOf` returns `Object` in TS — widen to `unknown` so the user
223+
// narrows before use. The other two return `string`.
224+
let return_type = match *name {
225+
"valueOf" => Type::Unknown,
226+
_ => Type::String,
227+
};
228+
fields.push((
229+
(*name).to_string(),
230+
Type::Function {
231+
params: vec![],
232+
return_type: Arc::new(return_type),
233+
required_params: 0,
234+
},
235+
));
236+
}
237+
}
238+
199239
/// Wraps a union type at the boundary, converting null/undefined members to Option.
200240
fn wrap_union_boundary(parts: &[TsType]) -> Type {
201241
let has_null = parts.iter().any(|p| matches!(p, TsType::Null));

0 commit comments

Comments
 (0)